@camstack/addon-provider-hikvision 1.2.27 → 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.
Files changed (3) hide show
  1. package/dist/addon.js +297 -70
  2. package/dist/addon.mjs +297 -70
  3. package/package.json +1 -1
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-C0lyLd5U.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";
@@ -45,6 +45,22 @@ var EventCategory = /* @__PURE__ */ function(EventCategory) {
45
45
  EventCategory["AddonInstalled"] = "addon.installed";
46
46
  EventCategory["AddonUninstalled"] = "addon.uninstalled";
47
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";
48
64
  EventCategory["AddonError"] = "addon.error";
49
65
  EventCategory["AddonPageReady"] = "addon.page-ready";
50
66
  EventCategory["AddonWidgetReady"] = "addon.widget-ready";
@@ -14737,6 +14753,7 @@ var NcSystemEventKindSchema = _enum([
14737
14753
  "node-offline",
14738
14754
  "node-inference-unavailable",
14739
14755
  "detection-blind",
14756
+ "addon-crash-loop",
14740
14757
  "addon-update-available",
14741
14758
  "server-update-available",
14742
14759
  "alarm-triggered",
@@ -20474,20 +20491,6 @@ var VectorStatsResultSchema = object({
20474
20491
  exact: boolean()
20475
20492
  });
20476
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);
20477
- /**
20478
- * `videoclips` — the unified, navigable-clip surface for a camera.
20479
- *
20480
- * A device-scoped WRAPPER cap (like `pipeline-analytics`): exactly one active
20481
- * provider per device, substitutable. The DEFAULT provider (registered by
20482
- * `addon-post-analysis`, `defaultActive: true`) composes the analytics event
20483
- * log (markers + thumbnails) with the recorder's `getPlaybackManifest` — a clip
20484
- * is a time-WINDOW over existing footage, never a separate file. A camera that
20485
- * exposes NATIVE onboard clips (Reolink/Hikvision NVR) can later substitute the
20486
- * wrapper provider for its device and serve its own clip catalog + URLs.
20487
- *
20488
- * A `Clip` is purely time-based (subtree-blind): playback resolves segments by
20489
- * temporal overlap, so the API never decides `continuous` vs `events`.
20490
- */
20491
20494
  var ClipSchema = object({
20492
20495
  /** Opaque, provider-namespaced id. The default provider encodes the time
20493
20496
  * window so `getClipPlayback` is self-contained (no event re-query). */
@@ -20513,12 +20516,41 @@ var ClipSchema = object({
20513
20516
  * of the event that owns `kind` (object > motion > audio). Do not extract
20514
20517
  * a keyframe from the recorded segments. Other providers (onboard, HKSV)
20515
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.
20516
20526
  */
20517
20527
  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. */
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
+ */
20521
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(),
20522
20554
  /** Intra-visit footage holes (GOP rolls, discarded segments) still shorter
20523
20555
  * than `VISIT_MERGE_GAP_MS`. Playback concatenates around them; the timeline
20524
20556
  * bar keeps showing them via `recording.getAvailability`. */
@@ -20989,7 +21021,14 @@ var SearchResultSchema = object({
20989
21021
  });
20990
21022
  var AutoUpdateSettingsSchema = object({
20991
21023
  channel: ChannelSchema,
20992
- 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()
20993
21032
  });
20994
21033
  var AddonAutoUpdateSchema = ChannelWithInheritSchema;
20995
21034
  var RestartAddonResultSchema = unknown();
@@ -21130,7 +21169,9 @@ method(_void(), array(AddonListItemSchema).readonly()), method(object({
21130
21169
  auth: "admin"
21131
21170
  }), method(_void(), AutoUpdateSettingsSchema, { auth: "admin" }), method(object({
21132
21171
  channel: ChannelSchema,
21133
- 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()
21134
21175
  }), unknown(), {
21135
21176
  kind: "mutation",
21136
21177
  auth: "admin"
@@ -38134,6 +38175,8 @@ DEFAULT_NATIVE_LEASE_SETTINGS.budgetMb;
38134
38175
  DEFAULT_NATIVE_LEASE_SETTINGS.activityMs;
38135
38176
  DEFAULT_NATIVE_LEASE_SETTINGS.tileBudgetMb;
38136
38177
  DEFAULT_NATIVE_LEASE_SETTINGS.admission;
38178
+ var MB = 1024 * 1024;
38179
+ 1024 * MB, 3072 * MB;
38137
38180
  //#endregion
38138
38181
  //#region src/accessories/base.ts
38139
38182
  /**
@@ -40696,36 +40739,87 @@ function parseTwoWayAudioChannels(xml) {
40696
40739
  return out;
40697
40740
  }
40698
40741
  /**
40699
- * Idle watchdog for the alarm stream. Hikvision firmware emits a keep-alive
40700
- * heartbeat (typically a `videoloss`/`inactive` alert) roughly every ~5s even
40701
- * when nothing is happening, so a total absence of ANY bytes for this long
40702
- * means the pipe is dead either a half-open TCP connection (no FIN, no data)
40703
- * that would otherwise block `reader.read()` forever, or a stalled proxy/NAT
40704
- * conntrack entry between the hub and a camera on a different subnet. When it
40705
- * fires we cancel the reader, surfacing a clean stream-end that the reconnect
40706
- * path treats as a recoverable disconnect.
40707
- */
40708
- var ALARM_STREAM_IDLE_TIMEOUT_MS = 3e4;
40709
- /**
40710
- * Subscribe to the camera's alarm stream. Returns an `AbortController`
40711
- * `controller.abort()` tears the subscription down. Reconnect logic
40712
- * is the caller's responsibility (we keep the parser simple and let
40713
- * the device class own the lifecycle / backoff timing).
40714
- *
40715
- * Every terminal outcome that is NOT a deliberate `controller.abort()` is
40716
- * reported through `onError` exactly once, so the caller's reconnect logic
40717
- * always fires:
40718
- * - HTTP / boundary failure at subscribe time,
40719
- * - a thrown error while pumping,
40720
- * - AND a clean stream-end (`done`). Hikvision cameras recycle the
40721
- * alertStream HTTP connection periodically (firmware keep-alive limits,
40722
- * internal event-subsystem restarts, or an idle NAT/proxy hop closing the
40723
- * socket). A clean close used to fall through silently — `onError` never
40724
- * fired, the caller's `alarmController` stayed non-null, and the device
40725
- * never resubscribed so motion stopped permanently until the provider
40726
- * restarted. Treating clean-end as a recoverable disconnect closes that gap.
40727
- */
40728
- 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 = {}) {
40729
40823
  const controller = new AbortController();
40730
40824
  let settled = false;
40731
40825
  const fail = (err) => {
@@ -40752,7 +40846,7 @@ function subscribeAlarms(client, handlers, idleTimeoutMs = ALARM_STREAM_IDLE_TIM
40752
40846
  return;
40753
40847
  }
40754
40848
  handlers.onConnected?.();
40755
- await pumpAlarmStream(res.body, boundary, handlers, idleTimeoutMs);
40849
+ await pumpAlarmStream(res.body, boundary, handlers, options);
40756
40850
  fail(/* @__PURE__ */ new Error("alarm stream ended"));
40757
40851
  } catch (err) {
40758
40852
  fail(err);
@@ -40764,10 +40858,65 @@ function parseBoundary(contentType) {
40764
40858
  const m = /boundary\s*=\s*"?([^";\s]+)"?/i.exec(contentType);
40765
40859
  return m ? m[1].trim() : null;
40766
40860
  }
40767
- 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);
40768
40867
  const reader = stream.getReader();
40769
- 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;
40770
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
+ };
40771
40920
  let idleTimer = null;
40772
40921
  const armIdle = () => {
40773
40922
  if (idleTimeoutMs <= 0) return;
@@ -40784,16 +40933,57 @@ async function pumpAlarmStream(stream, boundary, handlers, idleTimeoutMs = ALARM
40784
40933
  armIdle();
40785
40934
  if (!value || value.byteLength === 0) continue;
40786
40935
  bufferedBytes = concat(bufferedBytes, value);
40787
- const parts = splitOnBoundary(bufferedBytes, dashBoundary);
40788
- bufferedBytes = parts.tail;
40789
- for (const block of parts.blocks) {
40790
- const parsed = parseMultipartBlock(block);
40791
- if (!parsed) continue;
40792
- if (parsed.contentType.startsWith("application/xml") || parsed.contentType.startsWith("text/xml") || looksLikeAlertXml(parsed.bodyText)) {
40793
- const ev = parseAlertXml(parsed.bodyText);
40794
- 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);
40795
40950
  }
40796
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;
40964
+ }
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
+ }
40797
40987
  }
40798
40988
  } finally {
40799
40989
  if (idleTimer) clearTimeout(idleTimer);
@@ -40808,17 +40998,42 @@ function concat(a, b) {
40808
40998
  out.set(b, a.byteLength);
40809
40999
  return out;
40810
41000
  }
40811
- function splitOnBoundary(buf, dashBoundary) {
41001
+ function splitOnBoundary(buf, dashBoundary, dropLeadingFragment) {
40812
41002
  const parts = Buffer.from(buf).toString("binary").split(dashBoundary);
40813
41003
  if (parts.length <= 1) return {
40814
41004
  blocks: [],
40815
- tail: buf
41005
+ tail: buf,
41006
+ sawBoundary: false
40816
41007
  };
40817
41008
  const tail = Buffer.from(parts.pop(), "binary");
40818
- parts.shift();
41009
+ if (dropLeadingFragment) parts.shift();
40819
41010
  return {
40820
41011
  blocks: parts.filter((p) => p.length > 0 && !p.startsWith("--")).map((p) => Buffer.from(p, "binary")),
40821
- tail
41012
+ tail,
41013
+ sawBoundary: true
41014
+ };
41015
+ }
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);
41029
+ if (headerEnd < 0) return null;
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)
40822
41037
  };
40823
41038
  }
40824
41039
  function looksLikeAlertXml(text) {
@@ -40833,10 +41048,16 @@ function parseMultipartBlock(block) {
40833
41048
  bodySlice = block.subarray(headerEnd + 4);
40834
41049
  } else {
40835
41050
  const raw = Buffer.from(block).toString("utf8");
40836
- if (!looksLikeAlertXml(raw)) return null;
41051
+ if (!looksLikeAlertXml(raw)) return {
41052
+ ok: false,
41053
+ reason: "no-header-separator-and-not-alert-xml"
41054
+ };
40837
41055
  return {
40838
- contentType: "",
40839
- bodyText: raw.replace(/\r?\n--$/m, "").trim()
41056
+ ok: true,
41057
+ block: {
41058
+ contentType: "",
41059
+ bodyText: raw.replace(/\r?\n--$/m, "").trim()
41060
+ }
40840
41061
  };
40841
41062
  }
40842
41063
  const headers = {};
@@ -40846,8 +41067,11 @@ function parseMultipartBlock(block) {
40846
41067
  headers[line.slice(0, idx).trim().toLowerCase()] = line.slice(idx + 1).trim();
40847
41068
  }
40848
41069
  return {
40849
- contentType: headers["content-type"] ?? "",
40850
- 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
+ }
40851
41075
  };
40852
41076
  }
40853
41077
  function findHeaderEnd(buf, from) {
@@ -44522,6 +44746,9 @@ var HikvisionCamera = class HikvisionCamera extends BaseDevice {
44522
44746
  this.alarmController = null;
44523
44747
  this.scheduleAlarmReconnect();
44524
44748
  }
44749
+ }, {
44750
+ logger: this.ctx.logger,
44751
+ deviceId: this.id
44525
44752
  });
44526
44753
  }
44527
44754
  scheduleAlarmReconnect() {
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-C0lyLd5U.mjs
10
+ //#region ../types/dist/event-category-XfKNtfCc.mjs
11
11
  var EventCategory = /* @__PURE__ */ function(EventCategory) {
12
12
  EventCategory["SystemBoot"] = "system.boot";
13
13
  EventCategory["SystemAddonsReady"] = "system.addons-ready";
@@ -46,6 +46,22 @@ var EventCategory = /* @__PURE__ */ function(EventCategory) {
46
46
  EventCategory["AddonInstalled"] = "addon.installed";
47
47
  EventCategory["AddonUninstalled"] = "addon.uninstalled";
48
48
  EventCategory["AddonCrashed"] = "addon.crashed";
49
+ /**
50
+ * A RUNNER the D6 crash circuit-breaker gave up on — terminal.
51
+ *
52
+ * `AddonCrashed` is the routine, self-healing fact ("it crashed; it is being
53
+ * respawned"); this is the one that never resolves itself. Emitted exactly
54
+ * once per trip, by `process-service.ts`, carrying the node, the runner, the
55
+ * addons it hosted and the crash count that tripped the breaker.
56
+ *
57
+ * It exists because a runner marked terminally `failed` used to be SILENT:
58
+ * on 2026-08-18 the `recorder` runner died of three unhandled ffmpeg spawn
59
+ * errors, the breaker stopped respawning it (correctly), `/health` kept
60
+ * returning 200, and fleet-wide recording was gone for 3h15 before the
61
+ * operator's phone told him. The Notification Center's system-event intake
62
+ * consumes this category and maps it to the `addon-crash-loop` kind.
63
+ */
64
+ EventCategory["AddonRunnerFailed"] = "addon.runner-failed";
49
65
  EventCategory["AddonError"] = "addon.error";
50
66
  EventCategory["AddonPageReady"] = "addon.page-ready";
51
67
  EventCategory["AddonWidgetReady"] = "addon.widget-ready";
@@ -14738,6 +14754,7 @@ var NcSystemEventKindSchema = _enum([
14738
14754
  "node-offline",
14739
14755
  "node-inference-unavailable",
14740
14756
  "detection-blind",
14757
+ "addon-crash-loop",
14741
14758
  "addon-update-available",
14742
14759
  "server-update-available",
14743
14760
  "alarm-triggered",
@@ -20475,20 +20492,6 @@ var VectorStatsResultSchema = object({
20475
20492
  exact: boolean()
20476
20493
  });
20477
20494
  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);
20478
- /**
20479
- * `videoclips` — the unified, navigable-clip surface for a camera.
20480
- *
20481
- * A device-scoped WRAPPER cap (like `pipeline-analytics`): exactly one active
20482
- * provider per device, substitutable. The DEFAULT provider (registered by
20483
- * `addon-post-analysis`, `defaultActive: true`) composes the analytics event
20484
- * log (markers + thumbnails) with the recorder's `getPlaybackManifest` — a clip
20485
- * is a time-WINDOW over existing footage, never a separate file. A camera that
20486
- * exposes NATIVE onboard clips (Reolink/Hikvision NVR) can later substitute the
20487
- * wrapper provider for its device and serve its own clip catalog + URLs.
20488
- *
20489
- * A `Clip` is purely time-based (subtree-blind): playback resolves segments by
20490
- * temporal overlap, so the API never decides `continuous` vs `events`.
20491
- */
20492
20495
  var ClipSchema = object({
20493
20496
  /** Opaque, provider-namespaced id. The default provider encodes the time
20494
20497
  * window so `getClipPlayback` is self-contained (no event re-query). */
@@ -20514,12 +20517,41 @@ var ClipSchema = object({
20514
20517
  * of the event that owns `kind` (object > motion > audio). Do not extract
20515
20518
  * a keyframe from the recorded segments. Other providers (onboard, HKSV)
20516
20519
  * mint their own stills.
20520
+ *
20521
+ * **VOUCHED, never fabricated.** A provider emits this only for an event it
20522
+ * has CONFIRMED owns at least one media row (its own, or its owning track's).
20523
+ * Absent is meaningful — "this visit has no event still" — never "we did not
20524
+ * look". Stamping it from a URL template made 35% of one camera's clips point
20525
+ * at a 404 (device 3836, 2026-08-17: 64 of 179 clips dead, 63 of them motion).
20526
+ * A read that FAILS drops the claim; it never invents it.
20517
20527
  */
20518
20528
  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. */
20529
+ /**
20530
+ * An instant INSIDE this visit's footage, hole-safe, where a recorded still
20531
+ * can be decoded. Present whenever the visit came from recorded availability;
20532
+ * absent on a per-event padded window (there is no footage to promise).
20533
+ *
20534
+ * This is not a thumbnail and not a second byte path: it is the argument to
20535
+ * the recorder's existing still route. The surface — never the provider —
20536
+ * decides whether to use it. It is the midpoint of the visit's LONGEST
20537
+ * contiguous range, not of the visit: a visit spans its holes by
20538
+ * construction, so a naive midpoint lands in dead air.
20539
+ */
20540
+ stillAtMs: number().optional(),
20541
+ /**
20542
+ * Analytics event ids that overlap this visit — a BOUNDED sample, newest
20543
+ * first within kind (object → motion → audio), capped at
20544
+ * {@link MAX_CLIP_EVENT_IDS}. Empty on footage-only clips.
20545
+ *
20546
+ * Bounded because it is not a payload the surface pages through: one visit on
20547
+ * device 615 carried 2 345 ids, and `eventIds` was 99% of a 153 KB
20548
+ * camera-day. Read {@link eventCount} for the true total.
20549
+ */
20522
20550
  eventIds: array(string()).optional(),
20551
+ /** How many analytics events actually overlap this visit. Differs from
20552
+ * `eventIds.length` exactly when the sample was capped — so a truncated
20553
+ * list is never mistaken for a quiet visit. */
20554
+ eventCount: number().int().nonnegative().optional(),
20523
20555
  /** Intra-visit footage holes (GOP rolls, discarded segments) still shorter
20524
20556
  * than `VISIT_MERGE_GAP_MS`. Playback concatenates around them; the timeline
20525
20557
  * bar keeps showing them via `recording.getAvailability`. */
@@ -20990,7 +21022,14 @@ var SearchResultSchema = object({
20990
21022
  });
20991
21023
  var AutoUpdateSettingsSchema = object({
20992
21024
  channel: ChannelSchema,
20993
- intervalSeconds: number()
21025
+ intervalSeconds: number(),
21026
+ /**
21027
+ * Cadence of the "an update exists" POLLER, in seconds. Independent of
21028
+ * `channel`: the poller runs while auto-apply is `off`, because being told
21029
+ * about a publish and installing it are different decisions. Clamped
21030
+ * server-side to 900 s … 604800 s; defaults to 21600 s (6 h).
21031
+ */
21032
+ updateCheckIntervalSeconds: number()
20994
21033
  });
20995
21034
  var AddonAutoUpdateSchema = ChannelWithInheritSchema;
20996
21035
  var RestartAddonResultSchema = unknown();
@@ -21131,7 +21170,9 @@ method(_void(), array(AddonListItemSchema).readonly()), method(object({
21131
21170
  auth: "admin"
21132
21171
  }), method(_void(), AutoUpdateSettingsSchema, { auth: "admin" }), method(object({
21133
21172
  channel: ChannelSchema,
21134
- intervalSeconds: number().min(300).max(86400).optional()
21173
+ intervalSeconds: number().min(300).max(86400).optional(),
21174
+ /** Availability-poll cadence; see AutoUpdateSettingsSchema. */
21175
+ updateCheckIntervalSeconds: number().min(900).max(604800).optional()
21135
21176
  }), unknown(), {
21136
21177
  kind: "mutation",
21137
21178
  auth: "admin"
@@ -38135,6 +38176,8 @@ DEFAULT_NATIVE_LEASE_SETTINGS.budgetMb;
38135
38176
  DEFAULT_NATIVE_LEASE_SETTINGS.activityMs;
38136
38177
  DEFAULT_NATIVE_LEASE_SETTINGS.tileBudgetMb;
38137
38178
  DEFAULT_NATIVE_LEASE_SETTINGS.admission;
38179
+ var MB = 1024 * 1024;
38180
+ 1024 * MB, 3072 * MB;
38138
38181
  //#endregion
38139
38182
  //#region src/accessories/base.ts
38140
38183
  /**
@@ -40697,36 +40740,87 @@ function parseTwoWayAudioChannels(xml) {
40697
40740
  return out;
40698
40741
  }
40699
40742
  /**
40700
- * Idle watchdog for the alarm stream. Hikvision firmware emits a keep-alive
40701
- * heartbeat (typically a `videoloss`/`inactive` alert) roughly every ~5s even
40702
- * when nothing is happening, so a total absence of ANY bytes for this long
40703
- * means the pipe is dead either a half-open TCP connection (no FIN, no data)
40704
- * that would otherwise block `reader.read()` forever, or a stalled proxy/NAT
40705
- * conntrack entry between the hub and a camera on a different subnet. When it
40706
- * fires we cancel the reader, surfacing a clean stream-end that the reconnect
40707
- * path treats as a recoverable disconnect.
40708
- */
40709
- var ALARM_STREAM_IDLE_TIMEOUT_MS = 3e4;
40710
- /**
40711
- * Subscribe to the camera's alarm stream. Returns an `AbortController`
40712
- * `controller.abort()` tears the subscription down. Reconnect logic
40713
- * is the caller's responsibility (we keep the parser simple and let
40714
- * the device class own the lifecycle / backoff timing).
40715
- *
40716
- * Every terminal outcome that is NOT a deliberate `controller.abort()` is
40717
- * reported through `onError` exactly once, so the caller's reconnect logic
40718
- * always fires:
40719
- * - HTTP / boundary failure at subscribe time,
40720
- * - a thrown error while pumping,
40721
- * - AND a clean stream-end (`done`). Hikvision cameras recycle the
40722
- * alertStream HTTP connection periodically (firmware keep-alive limits,
40723
- * internal event-subsystem restarts, or an idle NAT/proxy hop closing the
40724
- * socket). A clean close used to fall through silently — `onError` never
40725
- * fired, the caller's `alarmController` stayed non-null, and the device
40726
- * never resubscribed so motion stopped permanently until the provider
40727
- * restarted. Treating clean-end as a recoverable disconnect closes that gap.
40728
- */
40729
- function subscribeAlarms(client, handlers, idleTimeoutMs = ALARM_STREAM_IDLE_TIMEOUT_MS) {
40743
+ * Why the parser adopts the boundary it SEES rather than the one declared.
40744
+ *
40745
+ * Five cameras on `C-R-*` firmware (I91DN/I91DD V5.7.1, I91EV V5.8.10) have
40746
+ * never delivered one motion event here, while the same cameras deliver real
40747
+ * hardware VMD on the same endpoint to another NVR stack. The one dimension
40748
+ * where that stack is more permissive is the multipart boundary: it accepts a
40749
+ * part framed with the declared `--<boundary>` OR with the literal constant
40750
+ * `--boundary`, because "older hikvision send a boundary in the headers, but
40751
+ * then use a totally different constant boundary value".
40752
+ *
40753
+ * We take the general form instead of that special case: if the DECLARED
40754
+ * token has never once matched, adopt the token from the first `--…` line
40755
+ * actually on the wire and use it from then on. It subsumes the literal
40756
+ * `--boundary` case (that IS the observed token on those cameras) and it also
40757
+ * covers the next firmware that invents a third constant, which a hard-coded
40758
+ * fallback would not. The risk is bounded on both sides: adoption is only ever
40759
+ * attempted while the declared token has produced ZERO parts, the candidate
40760
+ * must occupy a whole line of RFC 2046 boundary characters, and the closing
40761
+ * delimiter (`--<token>--`) is rejected.
40762
+ */
40763
+ function detectWireBoundary(buf) {
40764
+ const text = Buffer.from(buf).toString("binary");
40765
+ const lineRe = /(?:^|\r?\n)--([0-9A-Za-z'()+_,./:=?-]{1,70})\r?\n/g;
40766
+ for (;;) {
40767
+ const m = lineRe.exec(text);
40768
+ if (!m) return null;
40769
+ const token = m[1];
40770
+ if (token.endsWith("--")) continue;
40771
+ return token;
40772
+ }
40773
+ }
40774
+ /**
40775
+ * First occurrence of each kind is logged immediately; further occurrences are
40776
+ * counted and folded into the next line after `intervalMs`. A stream that
40777
+ * drops every part therefore costs one line per minute per kind, not one per
40778
+ * chunk.
40779
+ */
40780
+ function createAlarmDiagnostics(logger, deviceId, intervalMs) {
40781
+ const lastLoggedAt = /* @__PURE__ */ new Map();
40782
+ const suppressed = /* @__PURE__ */ new Map();
40783
+ const report = (kind, message, meta, level = "warn") => {
40784
+ if (!logger) return;
40785
+ const now = Date.now();
40786
+ const last = lastLoggedAt.get(kind);
40787
+ if (last !== void 0 && now - last < intervalMs) {
40788
+ suppressed.set(kind, (suppressed.get(kind) ?? 0) + 1);
40789
+ return;
40790
+ }
40791
+ const since = suppressed.get(kind) ?? 0;
40792
+ suppressed.set(kind, 0);
40793
+ lastLoggedAt.set(kind, now);
40794
+ const extras = {
40795
+ ...deviceId === null ? {} : { tags: { deviceId } },
40796
+ meta: {
40797
+ kind,
40798
+ ...meta,
40799
+ ...since > 0 ? { suppressedSincePrevious: since } : {}
40800
+ }
40801
+ };
40802
+ if (level === "debug") logger.debug(message, extras);
40803
+ else logger.warn(message, extras);
40804
+ };
40805
+ return { report };
40806
+ }
40807
+ /** First line of a buffer, clipped — the single most useful field when asking
40808
+ * "what is this camera actually sending?". */
40809
+ function firstLineOf(buf, maxChars = 120) {
40810
+ const line = Buffer.from(buf.subarray(0, 1024)).toString("utf8").split(/\r?\n/, 1)[0] ?? "";
40811
+ return line.length > maxChars ? `${line.slice(0, maxChars)}…` : line;
40812
+ }
40813
+ /** First whole line that starts with `--` — i.e. the framing the camera really
40814
+ * uses. `null` when the buffer contains no such line. */
40815
+ function firstDashLineOf(buf, maxChars = 120) {
40816
+ const text = Buffer.from(buf.subarray(0, 4096)).toString("utf8");
40817
+ for (const line of text.split(/\r?\n/)) {
40818
+ if (!line.startsWith("--")) continue;
40819
+ return line.length > maxChars ? `${line.slice(0, maxChars)}…` : line;
40820
+ }
40821
+ return null;
40822
+ }
40823
+ function subscribeAlarms(client, handlers, options = {}) {
40730
40824
  const controller = new AbortController();
40731
40825
  let settled = false;
40732
40826
  const fail = (err) => {
@@ -40753,7 +40847,7 @@ function subscribeAlarms(client, handlers, idleTimeoutMs = ALARM_STREAM_IDLE_TIM
40753
40847
  return;
40754
40848
  }
40755
40849
  handlers.onConnected?.();
40756
- await pumpAlarmStream(res.body, boundary, handlers, idleTimeoutMs);
40850
+ await pumpAlarmStream(res.body, boundary, handlers, options);
40757
40851
  fail(/* @__PURE__ */ new Error("alarm stream ended"));
40758
40852
  } catch (err) {
40759
40853
  fail(err);
@@ -40765,10 +40859,65 @@ function parseBoundary(contentType) {
40765
40859
  const m = /boundary\s*=\s*"?([^";\s]+)"?/i.exec(contentType);
40766
40860
  return m ? m[1].trim() : null;
40767
40861
  }
40768
- async function pumpAlarmStream(stream, boundary, handlers, idleTimeoutMs = ALARM_STREAM_IDLE_TIMEOUT_MS) {
40862
+ async function pumpAlarmStream(stream, declaredBoundary, handlers, options) {
40863
+ const idleTimeoutMs = options.idleTimeoutMs ?? 3e4;
40864
+ const maxBufferBytes = options.maxBufferBytes ?? 1048576;
40865
+ const retainBytes = options.retainBytes ?? 65536;
40866
+ const unparsedWarnBytes = options.unparsedWarnBytes ?? 32768;
40867
+ const diagnostics = createAlarmDiagnostics(options.logger ?? null, options.deviceId ?? null, options.diagnosticIntervalMs ?? 6e4);
40769
40868
  const reader = stream.getReader();
40770
- const dashBoundary = `--${boundary}`;
40869
+ let activeBoundary = declaredBoundary;
40870
+ let activeDashBoundary = `--${activeBoundary}`;
40871
+ /** Has the ACTIVE token ever framed anything? Gates boundary adoption. */
40872
+ let matchedAnyBoundary = false;
40873
+ /**
40874
+ * Is the fragment that precedes the next boundary junk?
40875
+ *
40876
+ * True only at the head of the stream (RFC 1521 preamble), after we already
40877
+ * emitted the bytes sitting in the buffer, and after the overflow cap cut
40878
+ * the buffer mid-part. It used to be true unconditionally, which meant every
40879
+ * split after the first DISCARDED a completed part — on a stream that
40880
+ * delivers one part per TCP read, that is every event the camera ever sent.
40881
+ */
40882
+ let dropLeadingFragment = true;
40771
40883
  let bufferedBytes = new Uint8Array(0);
40884
+ const dispatchBlock = (block) => {
40885
+ const parsed = parseMultipartBlock(block);
40886
+ if (!parsed.ok) {
40887
+ diagnostics.report("part-unparseable", "hikvision alarm stream: multipart part dropped", {
40888
+ reason: parsed.reason,
40889
+ bytes: block.byteLength,
40890
+ firstLine: firstLineOf(block)
40891
+ });
40892
+ return;
40893
+ }
40894
+ const { contentType, bodyText } = parsed.block;
40895
+ if (!(contentType.startsWith("application/xml") || contentType.startsWith("text/xml") || looksLikeAlertXml(bodyText))) {
40896
+ if (contentType.startsWith("image/")) {
40897
+ diagnostics.report("part-image-ignored", "hikvision alarm stream: image part ignored (not forwarded yet)", {
40898
+ contentType,
40899
+ bytes: block.byteLength
40900
+ }, "debug");
40901
+ return;
40902
+ }
40903
+ diagnostics.report("part-not-alert-xml", "hikvision alarm stream: part dropped — neither an xml Content-Type nor alert-shaped XML", {
40904
+ contentType,
40905
+ bytes: block.byteLength,
40906
+ bodyPreview: bodyText.slice(0, 120)
40907
+ });
40908
+ return;
40909
+ }
40910
+ const ev = parseAlertXml(bodyText);
40911
+ if (!ev) {
40912
+ diagnostics.report("alert-xml-without-event-type", "hikvision alarm stream: alert part dropped — no <eventType> tag", {
40913
+ contentType,
40914
+ bytes: block.byteLength,
40915
+ bodyPreview: bodyText.slice(0, 120)
40916
+ });
40917
+ return;
40918
+ }
40919
+ handlers.onEvent(ev);
40920
+ };
40772
40921
  let idleTimer = null;
40773
40922
  const armIdle = () => {
40774
40923
  if (idleTimeoutMs <= 0) return;
@@ -40785,16 +40934,57 @@ async function pumpAlarmStream(stream, boundary, handlers, idleTimeoutMs = ALARM
40785
40934
  armIdle();
40786
40935
  if (!value || value.byteLength === 0) continue;
40787
40936
  bufferedBytes = concat(bufferedBytes, value);
40788
- const parts = splitOnBoundary(bufferedBytes, dashBoundary);
40789
- bufferedBytes = parts.tail;
40790
- for (const block of parts.blocks) {
40791
- const parsed = parseMultipartBlock(block);
40792
- if (!parsed) continue;
40793
- if (parsed.contentType.startsWith("application/xml") || parsed.contentType.startsWith("text/xml") || looksLikeAlertXml(parsed.bodyText)) {
40794
- const ev = parseAlertXml(parsed.bodyText);
40795
- if (ev) handlers.onEvent(ev);
40937
+ let split = splitOnBoundary(bufferedBytes, activeDashBoundary, dropLeadingFragment);
40938
+ if (!split.sawBoundary && !matchedAnyBoundary) {
40939
+ const observed = detectWireBoundary(bufferedBytes);
40940
+ if (observed !== null && observed !== activeBoundary) {
40941
+ diagnostics.report("boundary-adopted", "hikvision alarm stream: declared multipart boundary never matched — adopting the boundary seen on the wire", {
40942
+ declaredBoundary,
40943
+ observedBoundary: observed,
40944
+ bufferedBytes: bufferedBytes.byteLength,
40945
+ firstDashLine: firstDashLineOf(bufferedBytes),
40946
+ firstLine: firstLineOf(bufferedBytes)
40947
+ });
40948
+ activeBoundary = observed;
40949
+ activeDashBoundary = `--${observed}`;
40950
+ split = splitOnBoundary(bufferedBytes, activeDashBoundary, dropLeadingFragment);
40796
40951
  }
40797
40952
  }
40953
+ if (split.sawBoundary) {
40954
+ matchedAnyBoundary = true;
40955
+ dropLeadingFragment = false;
40956
+ }
40957
+ bufferedBytes = split.tail;
40958
+ for (const block of split.blocks) dispatchBlock(block);
40959
+ if (!dropLeadingFragment) {
40960
+ const early = takeCompletePart(bufferedBytes);
40961
+ if (early) {
40962
+ dispatchBlock(early.block);
40963
+ bufferedBytes = early.rest;
40964
+ dropLeadingFragment = true;
40965
+ }
40966
+ }
40967
+ 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", {
40968
+ declaredBoundary,
40969
+ activeBoundary,
40970
+ bufferedBytes: bufferedBytes.byteLength,
40971
+ firstDashLine: firstDashLineOf(bufferedBytes),
40972
+ firstLine: firstLineOf(bufferedBytes)
40973
+ });
40974
+ if (bufferedBytes.byteLength > maxBufferBytes) {
40975
+ diagnostics.report("buffer-overflow", "hikvision alarm stream: unparsed multipart buffer hit its cap — discarding the head", {
40976
+ declaredBoundary,
40977
+ activeBoundary,
40978
+ matchedAnyBoundary,
40979
+ bufferedBytes: bufferedBytes.byteLength,
40980
+ maxBufferBytes,
40981
+ retainBytes,
40982
+ firstDashLine: firstDashLineOf(bufferedBytes),
40983
+ firstLine: firstLineOf(bufferedBytes)
40984
+ });
40985
+ bufferedBytes = bufferedBytes.slice(bufferedBytes.byteLength - retainBytes);
40986
+ dropLeadingFragment = true;
40987
+ }
40798
40988
  }
40799
40989
  } finally {
40800
40990
  if (idleTimer) clearTimeout(idleTimer);
@@ -40809,17 +40999,42 @@ function concat(a, b) {
40809
40999
  out.set(b, a.byteLength);
40810
41000
  return out;
40811
41001
  }
40812
- function splitOnBoundary(buf, dashBoundary) {
41002
+ function splitOnBoundary(buf, dashBoundary, dropLeadingFragment) {
40813
41003
  const parts = Buffer.from(buf).toString("binary").split(dashBoundary);
40814
41004
  if (parts.length <= 1) return {
40815
41005
  blocks: [],
40816
- tail: buf
41006
+ tail: buf,
41007
+ sawBoundary: false
40817
41008
  };
40818
41009
  const tail = Buffer.from(parts.pop(), "binary");
40819
- parts.shift();
41010
+ if (dropLeadingFragment) parts.shift();
40820
41011
  return {
40821
41012
  blocks: parts.filter((p) => p.length > 0 && !p.startsWith("--")).map((p) => Buffer.from(p, "binary")),
40822
- tail
41013
+ tail,
41014
+ sawBoundary: true
41015
+ };
41016
+ }
41017
+ function parseContentLength(headerBlob) {
41018
+ const m = /^content-length\s*:\s*(\d+)\s*$/im.exec(headerBlob);
41019
+ if (!m) return null;
41020
+ const n = Number.parseInt(m[1], 10);
41021
+ return Number.isSafeInteger(n) && n >= 0 ? n : null;
41022
+ }
41023
+ /**
41024
+ * If `buf` (which must start immediately after a boundary) already holds a
41025
+ * whole part per its `Content-Length`, return it and the leftover bytes.
41026
+ * Firmware that omits `Content-Length` falls back to boundary-framed delivery.
41027
+ */
41028
+ function takeCompletePart(buf) {
41029
+ const headerEnd = findHeaderEnd(buf, 0);
41030
+ if (headerEnd < 0) return null;
41031
+ const contentLength = parseContentLength(Buffer.from(buf.subarray(0, headerEnd)).toString("utf8"));
41032
+ if (contentLength === null) return null;
41033
+ const end = headerEnd + 4 + contentLength;
41034
+ if (buf.byteLength < end) return null;
41035
+ return {
41036
+ block: buf.slice(0, end),
41037
+ rest: buf.slice(end)
40823
41038
  };
40824
41039
  }
40825
41040
  function looksLikeAlertXml(text) {
@@ -40834,10 +41049,16 @@ function parseMultipartBlock(block) {
40834
41049
  bodySlice = block.subarray(headerEnd + 4);
40835
41050
  } else {
40836
41051
  const raw = Buffer.from(block).toString("utf8");
40837
- if (!looksLikeAlertXml(raw)) return null;
41052
+ if (!looksLikeAlertXml(raw)) return {
41053
+ ok: false,
41054
+ reason: "no-header-separator-and-not-alert-xml"
41055
+ };
40838
41056
  return {
40839
- contentType: "",
40840
- bodyText: raw.replace(/\r?\n--$/m, "").trim()
41057
+ ok: true,
41058
+ block: {
41059
+ contentType: "",
41060
+ bodyText: raw.replace(/\r?\n--$/m, "").trim()
41061
+ }
40841
41062
  };
40842
41063
  }
40843
41064
  const headers = {};
@@ -40847,8 +41068,11 @@ function parseMultipartBlock(block) {
40847
41068
  headers[line.slice(0, idx).trim().toLowerCase()] = line.slice(idx + 1).trim();
40848
41069
  }
40849
41070
  return {
40850
- contentType: headers["content-type"] ?? "",
40851
- bodyText: Buffer.from(bodySlice).toString("utf8").replace(/\r?\n--$/m, "").trim()
41071
+ ok: true,
41072
+ block: {
41073
+ contentType: headers["content-type"] ?? "",
41074
+ bodyText: Buffer.from(bodySlice).toString("utf8").replace(/\r?\n--$/m, "").trim()
41075
+ }
40852
41076
  };
40853
41077
  }
40854
41078
  function findHeaderEnd(buf, from) {
@@ -44523,6 +44747,9 @@ var HikvisionCamera = class HikvisionCamera extends BaseDevice {
44523
44747
  this.alarmController = null;
44524
44748
  this.scheduleAlarmReconnect();
44525
44749
  }
44750
+ }, {
44751
+ logger: this.ctx.logger,
44752
+ deviceId: this.id
44526
44753
  });
44527
44754
  }
44528
44755
  scheduleAlarmReconnect() {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@camstack/addon-provider-hikvision",
3
- "version": "1.2.27",
3
+ "version": "1.2.28",
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",