@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.
- package/dist/addon.js +297 -70
- package/dist/addon.mjs +297 -70
- 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-
|
|
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
|
-
/**
|
|
20519
|
-
*
|
|
20520
|
-
*
|
|
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
|
-
*
|
|
40700
|
-
*
|
|
40701
|
-
*
|
|
40702
|
-
*
|
|
40703
|
-
*
|
|
40704
|
-
*
|
|
40705
|
-
*
|
|
40706
|
-
*
|
|
40707
|
-
|
|
40708
|
-
|
|
40709
|
-
|
|
40710
|
-
*
|
|
40711
|
-
*
|
|
40712
|
-
*
|
|
40713
|
-
* the
|
|
40714
|
-
*
|
|
40715
|
-
*
|
|
40716
|
-
*
|
|
40717
|
-
*
|
|
40718
|
-
|
|
40719
|
-
|
|
40720
|
-
|
|
40721
|
-
|
|
40722
|
-
|
|
40723
|
-
|
|
40724
|
-
|
|
40725
|
-
|
|
40726
|
-
|
|
40727
|
-
|
|
40728
|
-
|
|
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,
|
|
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,
|
|
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
|
-
|
|
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
|
-
|
|
40788
|
-
|
|
40789
|
-
|
|
40790
|
-
|
|
40791
|
-
|
|
40792
|
-
|
|
40793
|
-
|
|
40794
|
-
|
|
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
|
|
41051
|
+
if (!looksLikeAlertXml(raw)) return {
|
|
41052
|
+
ok: false,
|
|
41053
|
+
reason: "no-header-separator-and-not-alert-xml"
|
|
41054
|
+
};
|
|
40837
41055
|
return {
|
|
40838
|
-
|
|
40839
|
-
|
|
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
|
-
|
|
40850
|
-
|
|
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-
|
|
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
|
-
/**
|
|
20520
|
-
*
|
|
20521
|
-
*
|
|
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
|
-
*
|
|
40701
|
-
*
|
|
40702
|
-
*
|
|
40703
|
-
*
|
|
40704
|
-
*
|
|
40705
|
-
*
|
|
40706
|
-
*
|
|
40707
|
-
*
|
|
40708
|
-
|
|
40709
|
-
|
|
40710
|
-
|
|
40711
|
-
*
|
|
40712
|
-
*
|
|
40713
|
-
*
|
|
40714
|
-
* the
|
|
40715
|
-
*
|
|
40716
|
-
*
|
|
40717
|
-
*
|
|
40718
|
-
*
|
|
40719
|
-
|
|
40720
|
-
|
|
40721
|
-
|
|
40722
|
-
|
|
40723
|
-
|
|
40724
|
-
|
|
40725
|
-
|
|
40726
|
-
|
|
40727
|
-
|
|
40728
|
-
|
|
40729
|
-
|
|
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,
|
|
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,
|
|
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
|
-
|
|
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
|
-
|
|
40789
|
-
|
|
40790
|
-
|
|
40791
|
-
|
|
40792
|
-
|
|
40793
|
-
|
|
40794
|
-
|
|
40795
|
-
|
|
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
|
|
41052
|
+
if (!looksLikeAlertXml(raw)) return {
|
|
41053
|
+
ok: false,
|
|
41054
|
+
reason: "no-header-separator-and-not-alert-xml"
|
|
41055
|
+
};
|
|
40838
41056
|
return {
|
|
40839
|
-
|
|
40840
|
-
|
|
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
|
-
|
|
40851
|
-
|
|
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.
|
|
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",
|