@camstack/addon-provider-hikvision 1.2.46 → 1.2.49

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 +578 -24
  2. package/dist/addon.mjs +578 -24
  3. package/package.json +4 -1
package/dist/addon.js CHANGED
@@ -8501,6 +8501,112 @@ var TIMEZONES = [
8501
8501
  function findTimezone(id) {
8502
8502
  return TIMEZONES.find((tz) => tz.id === id);
8503
8503
  }
8504
+ /**
8505
+ * Distinct (device, family, variant) counters one instance will hold.
8506
+ *
8507
+ * A large fleet x the handful of families any single addon reports, with
8508
+ * slack. At ~200 B per counter this is a ~100 KB ceiling on a process that
8509
+ * already declares an RSS budget in the gigabytes.
8510
+ */
8511
+ var MAX_KEYS = 1024;
8512
+ /**
8513
+ * Where reasons past {@link MAX_REASONS_PER_KEY} go.
8514
+ *
8515
+ * They are FOLDED, never dropped: `attempts - succeeded` must always equal the
8516
+ * sum of the reason counts, or the ratio stops adding up.
8517
+ */
8518
+ var OVERFLOW_REASON = "other";
8519
+ /** `deviceId` + `family` + optional `variant`, flattened into the map key. */
8520
+ function counterKey(deviceId, family, variant) {
8521
+ return variant === void 0 ? `${deviceId}${family}` : `${deviceId}${family}${variant}`;
8522
+ }
8523
+ /**
8524
+ * A bounded set of per-camera, cumulative failure counters.
8525
+ *
8526
+ * One instance per contributing subsystem. `note` is O(1) and allocation-free
8527
+ * on the steady path; `snapshot` reads without mutating anything.
8528
+ */
8529
+ var FailureCounters = class {
8530
+ maxKeys;
8531
+ maxReasons;
8532
+ counters = /* @__PURE__ */ new Map();
8533
+ refused = 0;
8534
+ constructor(maxKeys = MAX_KEYS, maxReasons = 16) {
8535
+ this.maxKeys = maxKeys;
8536
+ this.maxReasons = maxReasons;
8537
+ }
8538
+ /**
8539
+ * Counters refused because {@link MAX_KEYS} was already held.
8540
+ *
8541
+ * Cumulative for the life of the instance: a bound that bit is a fact about
8542
+ * the deployment, and a surface that hid it would under-report a fleet
8543
+ * precisely when the fleet got large enough to matter.
8544
+ */
8545
+ get keysRefused() {
8546
+ return this.refused;
8547
+ }
8548
+ /** Counters currently held. */
8549
+ get size() {
8550
+ return this.counters.size;
8551
+ }
8552
+ /**
8553
+ * Fold one observation in.
8554
+ *
8555
+ * A non-positive or non-integer `deviceId` is REFUSED rather than bucketed:
8556
+ * see the module docblock — an entry that cannot name its camera is worse
8557
+ * than no entry.
8558
+ */
8559
+ note(observation, nowMs) {
8560
+ if (!Number.isInteger(observation.deviceId) || observation.deviceId <= 0) return;
8561
+ const key = counterKey(observation.deviceId, observation.family, observation.variant);
8562
+ let counter = this.counters.get(key);
8563
+ if (counter === void 0) {
8564
+ if (this.counters.size >= this.maxKeys) {
8565
+ this.refused += 1;
8566
+ return;
8567
+ }
8568
+ counter = {
8569
+ deviceId: observation.deviceId,
8570
+ family: observation.family,
8571
+ variant: observation.variant,
8572
+ sinceMs: nowMs,
8573
+ attempts: 0,
8574
+ succeeded: 0,
8575
+ reasons: /* @__PURE__ */ new Map()
8576
+ };
8577
+ this.counters.set(key, counter);
8578
+ }
8579
+ counter.attempts += 1;
8580
+ if (observation.reason === void 0) {
8581
+ counter.succeeded += 1;
8582
+ return;
8583
+ }
8584
+ const reason = counter.reasons.has(observation.reason) || counter.reasons.size < this.maxReasons ? observation.reason : OVERFLOW_REASON;
8585
+ counter.reasons.set(reason, (counter.reasons.get(reason) ?? 0) + 1);
8586
+ }
8587
+ /** Read every counter. Never mutates — see the module docblock. */
8588
+ snapshot(nowMs) {
8589
+ const out = [];
8590
+ for (const counter of this.counters.values()) out.push({
8591
+ deviceId: counter.deviceId,
8592
+ family: counter.family,
8593
+ ...counter.variant !== void 0 ? { variant: counter.variant } : {},
8594
+ sinceMs: counter.sinceMs,
8595
+ atMs: nowMs,
8596
+ attempts: counter.attempts,
8597
+ succeeded: counter.succeeded,
8598
+ reasons: [...counter.reasons.entries()].map(([reason, count]) => ({
8599
+ reason,
8600
+ count
8601
+ })).toSorted((a, b) => b.count - a.count)
8602
+ });
8603
+ return out;
8604
+ }
8605
+ /** Drop everything (host disposal). */
8606
+ clear() {
8607
+ this.counters.clear();
8608
+ }
8609
+ };
8504
8610
  var MODEL_FORMATS = [
8505
8611
  "onnx",
8506
8612
  "coreml",
@@ -13756,7 +13862,26 @@ var FailureContributionSchema = object({
13756
13862
  /** The loss, partitioned. Sums to `attempts - succeeded`. */
13757
13863
  reasons: array(FailureReasonCountSchema).readonly()
13758
13864
  });
13759
- method(_void(), array(FailureContributionSchema).readonly());
13865
+ var failureContributionCapability = {
13866
+ name: "failure-contribution",
13867
+ scope: "system",
13868
+ mode: "collection",
13869
+ internal: true,
13870
+ methods: {
13871
+ /**
13872
+ * This addon's per-camera failure counters, read live from bounded in-RAM
13873
+ * state it already keeps. Inert: no persistence, no sampling, no timer.
13874
+ *
13875
+ * READING NEVER RESETS. The counters are CUMULATIVE since `sinceMs`, and a
13876
+ * consumer that wants a rate differences two reads. A draining read would
13877
+ * make two operators with the page open each destroy half of the other's
13878
+ * numbers, and `load-contribution` already settled the same question the
13879
+ * same way for `cpuSeconds`.
13880
+ */
13881
+ list: method(_void(), array(FailureContributionSchema).readonly()) },
13882
+ /** In-process only — enumerated through `addons.listCapabilityProviders`. */
13883
+ mount: { kind: "skip" }
13884
+ };
13760
13885
  var LoadContributionSchema = object({
13761
13886
  role: _enum([
13762
13887
  "decode",
@@ -14064,6 +14189,50 @@ var NodeProcessSchema = object({
14064
14189
  /** Wall-clock uptime (seconds). Parsed from `ps etime`. */
14065
14190
  uptimeSec: number()
14066
14191
  });
14192
+ /**
14193
+ * One retained container-memory reading.
14194
+ *
14195
+ * `atMs` is the timestamp of the PROCESS snapshot taken in the same tick, not
14196
+ * a second clock: that is what makes "processes sum to X, container says Y"
14197
+ * subtractable per point rather than an eyeballed comparison of two series
14198
+ * sampled at different instants.
14199
+ *
14200
+ * A reduced window keeps the sample with the LARGEST `currentBytes` in each
14201
+ * bucket, WHOLE. Taking a per-field maximum would synthesise a row whose parts
14202
+ * never coexisted, and a mean would smear away the peak this exists to find.
14203
+ */
14204
+ var ContainerMemoryPointSchema = object({
14205
+ /** Which hierarchy answered, so a reading is never ambiguous. */
14206
+ source: _enum(["cgroup-v2", "cgroup-v1"]),
14207
+ /** `memory.current` (v2) / `memory.usage_in_bytes` (v1). Always known. */
14208
+ currentBytes: number(),
14209
+ /** The cgroup's ceiling. `null` = NO LIMIT — never a sentinel, never zero. */
14210
+ limitBytes: number().nullable(),
14211
+ /** Anonymous pages: the closest thing to "what the processes allocated". */
14212
+ anonBytes: number().nullable(),
14213
+ /** Page cache. Charged to the cgroup, owned by no process. */
14214
+ fileBytes: number().nullable(),
14215
+ /**
14216
+ * Shared memory — and the field that explained the largest single surprise.
14217
+ * The i915 driver backs GPU buffers with shmem, so an inference pool or a
14218
+ * hardware-decode session holding DRM objects is charged HERE and appears
14219
+ * nowhere in a `ps` scan.
14220
+ */
14221
+ shmemBytes: number().nullable(),
14222
+ /** Kernel slab charged to this cgroup. `null` on v1, which never publishes it. */
14223
+ slabBytes: number().nullable(),
14224
+ /**
14225
+ * Shrinkable i915 GEM object bytes, from debugfs.
14226
+ *
14227
+ * **Host-wide across every DRM client, NOT cgroup-scoped.** It is not a
14228
+ * component of `currentBytes` and must not be subtracted from it; it says
14229
+ * what put the shmem there, where `shmemBytes` only says how much.
14230
+ *
14231
+ * `null` wherever debugfs is not mounted — which is inside every camstack
14232
+ * container today — and on any node with no Intel GPU.
14233
+ */
14234
+ gpuShmemBytes: number().nullable()
14235
+ }).extend({ atMs: number() });
14067
14236
  var DumpHeapSnapshotInputSchema = object({
14068
14237
  /** The addon whose runner should dump a heap snapshot. */
14069
14238
  addonId: string() });
@@ -14127,6 +14296,21 @@ var NodeLoadSeriesSchema = object({
14127
14296
  /** One entry per function seen in the window, heaviest-first. */
14128
14297
  series: array(LoadFunctionSeriesSchema).readonly(),
14129
14298
  /**
14299
+ * The CONTAINER's memory over the same window, oldest-first.
14300
+ *
14301
+ * Sits next to `series` rather than in a method of its own because the whole
14302
+ * question is a subtraction: the per-process rows in `series` sum to one
14303
+ * number and this one is another, and an operator who has to issue two calls
14304
+ * to compare them will compare two different instants. Same reader, same
14305
+ * `sinceMs`, same `bucketMs`, same timestamps.
14306
+ *
14307
+ * **EMPTY means ABSENT, never zero.** A node with no cgroup — a developer
14308
+ * Mac, a bare-metal host, a container with the hierarchy hidden — reports no
14309
+ * points at all. A zero here would be indistinguishable from a healthy
14310
+ * container and is precisely the lie this field exists to avoid.
14311
+ */
14312
+ containerMemory: array(ContainerMemoryPointSchema).readonly(),
14313
+ /**
14130
14314
  * Width of one returned bucket, in ms. Equals the sampling cadence when no
14131
14315
  * reduction was needed — so a caller can always say what one point covers
14132
14316
  * without having to know whether it was reduced.
@@ -28059,10 +28243,10 @@ var rebootCapability = {
28059
28243
  * recording config. NOTE on events (source of truth, R5/C3): this cap carries
28060
28244
  * NO event surface — `getPlaybackManifest` returns playlist URLs only. Timeline
28061
28245
  * events (motion/object/audio) come from `pipelineAnalytics` (durable SQLite
28062
- * rows); the recorder's internal EventMap markers are ephemeral in-RAM
28063
- * annotations that are not exposed here and must not be treated as an event
28064
- * feed. Event<->footage joins are by time, padded with the shared `EVENT_PAD_MS`
28065
- * (`interfaces/recording-config.ts`).
28246
+ * rows) and are the ONLY event surface the recorder has none. The in-RAM
28247
+ * playback markers it used to build were deleted on 2026-08-29 because nothing
28248
+ * ever read them. Event<->footage joins are by time, padded with the shared
28249
+ * `EVENT_PAD_MS` (`interfaces/recording-config.ts`).
28066
28250
  */
28067
28251
  var RecordingStatusSchema = object({
28068
28252
  deviceId: number(),
@@ -43589,6 +43773,15 @@ var DEFAULT_BACKLOG_MS = 200;
43589
43773
  var MAX_BACKLOG_MS = 5e3;
43590
43774
  var MIN_BACKLOG_MS = 20;
43591
43775
  /**
43776
+ * The ONE place the operator's backlog request becomes the enforced bound.
43777
+ * `start()` sizes the byte window from it and `ability.maxBacklogMs` reports
43778
+ * it — a second clamp would let the number a caller reads drift from the
43779
+ * number the buffer honours.
43780
+ */
43781
+ function clampBacklogMs(requested) {
43782
+ return Math.max(MIN_BACKLOG_MS, Math.min(MAX_BACKLOG_MS, requested ?? DEFAULT_BACKLOG_MS));
43783
+ }
43784
+ /**
43592
43785
  * Fixed audioData PUT-body chunk size, in bytes. Encoded µ-law bytes are
43593
43786
  * accumulated and flushed to the sticky PUT in FIXED chunks of this size
43594
43787
  * (the sub-chunk remainder is held until the next flush, and the trailing
@@ -43657,6 +43850,36 @@ var HikvisionIntercomSession = class {
43657
43850
  get audioCodec() {
43658
43851
  return this.codec;
43659
43852
  }
43853
+ /**
43854
+ * Effective PCM backlog bound, in ms — the operator's value clamped to
43855
+ * [{@link MIN_BACKLOG_MS}, {@link MAX_BACKLOG_MS}], i.e. what the session is
43856
+ * actually enforcing rather than what it was asked for. Readable before
43857
+ * `start()` because the clamp is pure.
43858
+ */
43859
+ get backlogMs() {
43860
+ return clampBacklogMs(this.opts.maxBacklogMs);
43861
+ }
43862
+ /**
43863
+ * The firmware's talk-back format — `IntercomStatus.ability`.
43864
+ *
43865
+ * Every field is a value this session already resolved against the camera;
43866
+ * nothing here is a default standing in for a probe. It was declared,
43867
+ * mirrored into runtime state and written by NOBODY while these exact
43868
+ * values were in hand and only reaching a log line (D281).
43869
+ *
43870
+ * `duplex` is the one judgement call: ISAPI two-way audio is a single
43871
+ * `audioData` channel and the provider enforces one active session per
43872
+ * camera, so `half` is reported. `full` would be the dangerous direction —
43873
+ * a consumer that believes it may listen while speaking takes no lock.
43874
+ */
43875
+ get ability() {
43876
+ return {
43877
+ codecs: [this.codec],
43878
+ sampleRate: HIKVISION_INTERCOM_SAMPLE_RATE,
43879
+ duplex: "half",
43880
+ maxBacklogMs: this.backlogMs
43881
+ };
43882
+ }
43660
43883
  async start() {
43661
43884
  if (this.stream) return;
43662
43885
  const desiredChannel = this.opts.channelId ?? "1";
@@ -43715,7 +43938,7 @@ var HikvisionIntercomSession = class {
43715
43938
  });
43716
43939
  this.stop(reason).catch(() => {});
43717
43940
  });
43718
- const wantedBacklogMs = Math.max(MIN_BACKLOG_MS, Math.min(MAX_BACKLOG_MS, this.opts.maxBacklogMs ?? DEFAULT_BACKLOG_MS));
43941
+ const wantedBacklogMs = this.backlogMs;
43719
43942
  this.bytesPerSecond = HIKVISION_INTERCOM_SAMPLE_RATE * 2;
43720
43943
  this.maxBacklogBytes = Math.max(160, Math.floor(wantedBacklogMs / 1e3 * this.bytesPerSecond));
43721
43944
  this.stream = stream;
@@ -43897,6 +44120,90 @@ var HikvisionIntercomSession = class {
43897
44120
  }
43898
44121
  };
43899
44122
  //#endregion
44123
+ //#region src/intercom/intercom-failure-report.ts
44124
+ /**
44125
+ * Per-camera talk-back counters, published through `failure-contribution`.
44126
+ *
44127
+ * ## The number that was never divided
44128
+ *
44129
+ * The rate-mismatch drop had ONE warn line and no counter, so "how much
44130
+ * talk-back is this camera losing" was answerable only by grepping Loki and
44131
+ * hand-correlating timestamps — the exact cost `failure-contribution` exists to
44132
+ * remove. And a bare drop count could not have answered it either: 40 drops out
44133
+ * of 40 pushes and 40 out of 40 000 are opposite findings that produce
44134
+ * identical log volume.
44135
+ *
44136
+ * So EVERY `pushTalkAudio` outcome on a live talk session is noted from the one
44137
+ * place that decides it — the accepted ones too. {@link FailureCounters}
44138
+ * carries `attempts` as the denominator and `succeeded` as the numerator, and
44139
+ * the reasons partition the rest. A success counted somewhere else would drift
44140
+ * from the failures and turn the ratio into fiction.
44141
+ *
44142
+ * ## `variant` is the wire codec, and it is honest
44143
+ *
44144
+ * `failure-contribution` keeps `variant` for a second dimension WITHIN a
44145
+ * family, and here the useful one is the format the caller pushed: an operator
44146
+ * asking "why is 617 silent" needs to know whether HomeKit's Opus or Alexa's
44147
+ * raw PCM is the half that is failing. The provider is handed that value on
44148
+ * every call, so it is reported rather than guessed — absent, never invented.
44149
+ *
44150
+ * ## Process-wide, because a counter is
44151
+ *
44152
+ * One addon is one process (D2) and every camera this addon owns lives in it,
44153
+ * so the instance is module-scoped: the cameras note into it and the addon
44154
+ * registers ONE `failure-contribution` provider that reads it. `sinceMs` is the
44155
+ * incarnation marker — a respawned runner restarts from zero and says so.
44156
+ * Reading NEVER drains.
44157
+ */
44158
+ /** One `pushTalkAudio` call against an open talk session. */
44159
+ var FAMILY_INTERCOM_TALK = "intercom-talk";
44160
+ /** The push arrived with a sequence number at or below the last accepted one. */
44161
+ var REASON_TALK_OUT_OF_ORDER = "out-of-order";
44162
+ /** The payload decoded to zero bytes. */
44163
+ var REASON_TALK_EMPTY = "empty-frame";
44164
+ /** More than one channel — every camera here is mono-only. */
44165
+ var REASON_TALK_NOT_MONO = "not-mono";
44166
+ /** `s16le` push with no `sampleRate`; the rate is ambiguous, not assumed. */
44167
+ var REASON_TALK_NO_SAMPLE_RATE = "missing-sample-rate";
44168
+ /** The wire codec has no path onto this camera's talk channel. */
44169
+ var REASON_TALK_CODEC_UNSUPPORTED = "codec-unsupported";
44170
+ /** The Opus decode path threw or could not open its session. */
44171
+ var REASON_TALK_OPUS_FAILED = "opus-decode-failed";
44172
+ /**
44173
+ * The addon's talk-back counters. One instance per process; the export at the
44174
+ * bottom of this file IS that instance.
44175
+ */
44176
+ var IntercomFailureReport = class {
44177
+ now;
44178
+ counters;
44179
+ constructor(now = Date.now, counters = new FailureCounters()) {
44180
+ this.now = now;
44181
+ this.counters = counters;
44182
+ }
44183
+ /**
44184
+ * Note one `pushTalkAudio` outcome. `reason` absent = the frame reached the
44185
+ * camera's talk channel.
44186
+ */
44187
+ noteTalkFrame(deviceId, wireCodec, reason) {
44188
+ this.counters.note({
44189
+ deviceId,
44190
+ family: FAMILY_INTERCOM_TALK,
44191
+ variant: wireCodec,
44192
+ ...reason !== void 0 ? { reason } : {}
44193
+ }, this.now());
44194
+ }
44195
+ /** The `failure-contribution` provider's payload. Reads, never resets. */
44196
+ list() {
44197
+ return this.counters.snapshot(this.now());
44198
+ }
44199
+ /** Addon disposal. */
44200
+ clear() {
44201
+ this.counters.clear();
44202
+ }
44203
+ };
44204
+ /** The process-wide instance every camera in this addon notes into. */
44205
+ var intercomFailureReport = new IntercomFailureReport();
44206
+ //#endregion
43900
44207
  //#region src/intercom/intercom-orchestrator.ts
43901
44208
  var DEFAULT_OPUS_SAMPLE_RATE = 48e3;
43902
44209
  var DEFAULT_OPUS_CHANNELS = 1;
@@ -43914,6 +44221,14 @@ var IntercomOrchestrator = class {
43914
44221
  return this.session !== null && !this.session.closed;
43915
44222
  }
43916
44223
  /**
44224
+ * The live talk session's firmware ability, or `null` when no session is
44225
+ * open. Read by the camera at `startSession` so the WebRTC path writes
44226
+ * `IntercomStatus.ability` from the same source the raw-PCM path does.
44227
+ */
44228
+ get ability() {
44229
+ return this.session === null || this.session.closed ? null : this.session.talkSession.ability;
44230
+ }
44231
+ /**
43917
44232
  * Subscribe to session-close notifications. The listener fires exactly
43918
44233
  * once per session with the resolved `IntercomCloseReason` + stats, so
43919
44234
  * the camera layer can surface WHY a talk session ended (the prime
@@ -44175,6 +44490,193 @@ function errMsg$1(err) {
44175
44490
  return err instanceof Error ? err.message : String(err);
44176
44491
  }
44177
44492
  //#endregion
44493
+ //#region src/intercom/talk-pcm-transcoder.ts
44494
+ /** libav codec name of a linear little-endian 16-bit PCM decode session. */
44495
+ var TALK_PCM_CODEC = "pcm_s16le";
44496
+ /** The transcoder was already closed — the talk session ended under the push. */
44497
+ var REASON_PCM_CLOSED = "pcm-transcoder-closed";
44498
+ /** The caller's declared source rate is not a usable positive integer. */
44499
+ var REASON_PCM_BAD_RATE = "pcm-bad-source-rate";
44500
+ /** The frame is empty or holds half a sample — malformed, not convertible. */
44501
+ var REASON_PCM_ODD_BYTES = "pcm-odd-bytes";
44502
+ /** No `audio-codec` provider is mounted on this cluster. */
44503
+ var REASON_PCM_NO_CODEC_CAP = "pcm-audio-codec-unavailable";
44504
+ /** The codec cap refused to open a linear-PCM decode session. */
44505
+ var REASON_PCM_SESSION_OPEN_FAILED = "pcm-resample-session-failed";
44506
+ /** The push/pull round-trip through the codec cap threw. */
44507
+ var REASON_PCM_CONVERT_FAILED = "pcm-resample-failed";
44508
+ function errMessage(err) {
44509
+ return err instanceof Error ? err.message : String(err);
44510
+ }
44511
+ var TalkPcmTranscoder = class {
44512
+ opts;
44513
+ active = null;
44514
+ closed = false;
44515
+ constructor(opts) {
44516
+ this.opts = opts;
44517
+ }
44518
+ /** The open codec session, or `null` before the first converted frame. */
44519
+ get sessionId() {
44520
+ return this.active?.sessionId ?? null;
44521
+ }
44522
+ /**
44523
+ * Convert one frame to the camera's rate and hand every produced chunk to
44524
+ * `feed`.
44525
+ *
44526
+ * Returns `null` when the frame was converted and fed, or the REASON string
44527
+ * it was refused for — already logged, with nothing fed.
44528
+ */
44529
+ async feedResampled(frame) {
44530
+ if (this.closed) {
44531
+ this.refuse(REASON_PCM_CLOSED, {});
44532
+ return REASON_PCM_CLOSED;
44533
+ }
44534
+ const sourceSampleRate = frame.sourceSampleRate;
44535
+ if (!Number.isInteger(sourceSampleRate) || sourceSampleRate <= 0) {
44536
+ this.refuse(REASON_PCM_BAD_RATE, { sourceSampleRate });
44537
+ return REASON_PCM_BAD_RATE;
44538
+ }
44539
+ if (frame.pcm.length === 0 || (frame.pcm.length & 1) !== 0) {
44540
+ this.refuse(REASON_PCM_ODD_BYTES, { bytes: frame.pcm.length });
44541
+ return REASON_PCM_ODD_BYTES;
44542
+ }
44543
+ let api;
44544
+ try {
44545
+ api = this.opts.resolveAudioCodec();
44546
+ } catch (err) {
44547
+ this.refuse(REASON_PCM_NO_CODEC_CAP, { error: errMessage(err) });
44548
+ return REASON_PCM_NO_CODEC_CAP;
44549
+ }
44550
+ if (this.active !== null && this.active.sourceSampleRate !== sourceSampleRate) {
44551
+ const previous = this.active.sourceSampleRate;
44552
+ await this.disposeSession(api, "source-rate-changed");
44553
+ this.opts.logger.info("intercom: pcm resample source rate changed — session recreated", {
44554
+ tags: { deviceId: this.opts.deviceId },
44555
+ meta: {
44556
+ previousSourceSampleRate: previous,
44557
+ sourceSampleRate
44558
+ }
44559
+ });
44560
+ }
44561
+ if (this.active === null) try {
44562
+ const created = await api.createDecodeSession({
44563
+ codec: TALK_PCM_CODEC,
44564
+ sourceSampleRate,
44565
+ sourceChannels: 1,
44566
+ targetSampleRate: this.opts.targetSampleRate,
44567
+ targetChannels: 1,
44568
+ targetFormat: "s16le",
44569
+ tag: this.opts.tag
44570
+ });
44571
+ this.active = {
44572
+ sessionId: created.sessionId,
44573
+ nodeId: created.nodeId,
44574
+ sourceSampleRate
44575
+ };
44576
+ this.opts.logger.info("intercom: pcm resample session opened", {
44577
+ tags: { deviceId: this.opts.deviceId },
44578
+ meta: {
44579
+ codec: TALK_PCM_CODEC,
44580
+ codecSessionId: created.sessionId,
44581
+ codecNodeId: created.nodeId,
44582
+ sourceSampleRate,
44583
+ targetSampleRate: this.opts.targetSampleRate,
44584
+ tag: this.opts.tag
44585
+ }
44586
+ });
44587
+ } catch (err) {
44588
+ this.refuse(REASON_PCM_SESSION_OPEN_FAILED, {
44589
+ sourceSampleRate,
44590
+ targetSampleRate: this.opts.targetSampleRate,
44591
+ error: errMessage(err)
44592
+ });
44593
+ return REASON_PCM_SESSION_OPEN_FAILED;
44594
+ }
44595
+ const session = this.active;
44596
+ try {
44597
+ await api.pushEncodedFrame({
44598
+ sessionId: session.sessionId,
44599
+ nodeId: session.nodeId,
44600
+ data: new Uint8Array(frame.pcm.buffer, frame.pcm.byteOffset, frame.pcm.byteLength)
44601
+ });
44602
+ const chunks = await api.pullPcm({
44603
+ sessionId: session.sessionId,
44604
+ nodeId: session.nodeId,
44605
+ maxCount: 8
44606
+ });
44607
+ for (const chunk of chunks) {
44608
+ const out = Buffer.from(chunk.data.buffer, chunk.data.byteOffset, chunk.data.byteLength);
44609
+ if (out.length > 0) this.opts.feed(out);
44610
+ }
44611
+ return null;
44612
+ } catch (err) {
44613
+ this.refuse(REASON_PCM_CONVERT_FAILED, {
44614
+ codecSessionId: session.sessionId,
44615
+ sourceSampleRate,
44616
+ targetSampleRate: this.opts.targetSampleRate,
44617
+ error: errMessage(err)
44618
+ });
44619
+ await this.disposeSession(api, "convert-failed");
44620
+ return REASON_PCM_CONVERT_FAILED;
44621
+ }
44622
+ }
44623
+ /**
44624
+ * Close the codec session. Idempotent, and called from the provider's
44625
+ * `endTalkSession` so the session dies with the talk session it served.
44626
+ */
44627
+ async close() {
44628
+ this.closed = true;
44629
+ if (this.active === null) return;
44630
+ let api;
44631
+ try {
44632
+ api = this.opts.resolveAudioCodec();
44633
+ } catch (err) {
44634
+ this.opts.logger.debug("intercom: pcm resample close skipped — audio-codec gone", {
44635
+ tags: { deviceId: this.opts.deviceId },
44636
+ meta: {
44637
+ codecSessionId: this.active.sessionId,
44638
+ error: errMessage(err)
44639
+ }
44640
+ });
44641
+ this.active = null;
44642
+ return;
44643
+ }
44644
+ await this.disposeSession(api, "talk-session-ended");
44645
+ }
44646
+ /** Close + forget the current session. Never throws. */
44647
+ async disposeSession(api, why) {
44648
+ const session = this.active;
44649
+ this.active = null;
44650
+ if (session === null) return;
44651
+ try {
44652
+ await api.closeSession({
44653
+ sessionId: session.sessionId,
44654
+ nodeId: session.nodeId
44655
+ });
44656
+ } catch (err) {
44657
+ this.opts.logger.debug("intercom: pcm resample closeSession error (continuing)", {
44658
+ tags: { deviceId: this.opts.deviceId },
44659
+ meta: {
44660
+ codecSessionId: session.sessionId,
44661
+ why,
44662
+ error: errMessage(err)
44663
+ }
44664
+ });
44665
+ }
44666
+ }
44667
+ /** One warn per refused frame. A branch that drops work says so. */
44668
+ refuse(reason, meta) {
44669
+ this.opts.logger.warn("intercom: pcm talk frame refused — not converted, nothing fed", {
44670
+ tags: { deviceId: this.opts.deviceId },
44671
+ meta: {
44672
+ reason,
44673
+ targetSampleRate: this.opts.targetSampleRate,
44674
+ ...meta
44675
+ }
44676
+ });
44677
+ }
44678
+ };
44679
+ //#endregion
44178
44680
  //#region src/intercom/werift-intercom-peer.ts
44179
44681
  var _werift;
44180
44682
  async function loadWerift() {
@@ -45222,13 +45724,20 @@ var HikvisionCamera = class HikvisionCamera extends BaseDevice {
45222
45724
  * Called at the four points that open or close a session — and seeded at
45223
45725
  * registration, so the slice says `talking: false` from boot rather than
45224
45726
  * only after the first session.
45727
+ *
45728
+ * `ability` is STICKY: it is the firmware's negotiated format, learned when a
45729
+ * session opens and still true after it closes, so a caller reading between
45730
+ * sessions gets the last probed value rather than `null`. Passing it is what
45731
+ * changed — it used to be copied forward from `previous` at every one of the
45732
+ * four call sites and written by nobody, while `session.sampleRate` and
45733
+ * `session.audioCodec` were in hand and only reaching a log line.
45225
45734
  */
45226
- publishIntercomState(talking) {
45735
+ publishIntercomState(talking, ability) {
45227
45736
  const previous = this.getCapSlice(intercomCapability);
45228
45737
  this.setCapSlice(intercomCapability, {
45229
45738
  talking,
45230
45739
  lastSessionAt: talking ? Date.now() : previous?.lastSessionAt ?? null,
45231
- ability: previous?.ability ?? null
45740
+ ability: ability ?? previous?.ability ?? null
45232
45741
  });
45233
45742
  }
45234
45743
  registerIntercomIfSupported() {
@@ -45257,7 +45766,7 @@ var HikvisionCamera = class HikvisionCamera extends BaseDevice {
45257
45766
  });
45258
45767
  try {
45259
45768
  const opened = await this.intercomOrchestrator.start();
45260
- this.publishIntercomState(true);
45769
+ this.publishIntercomState(true, this.intercomOrchestrator.ability ?? void 0);
45261
45770
  return opened;
45262
45771
  } catch (err) {
45263
45772
  this.publishIntercomState(false);
@@ -45279,8 +45788,10 @@ var HikvisionCamera = class HikvisionCamera extends BaseDevice {
45279
45788
  if (deviceId !== this.id) throw new Error(`HikvisionCamera: intercom deviceId mismatch, expected ${this.id}, got ${deviceId}`);
45280
45789
  if (this.disabled) throw new Error("Hikvision intercom: device is disabled — re-enable it before opening a talk session");
45281
45790
  if (this.intercomRawSession) {
45282
- await this.intercomRawSession.session.stop().catch(() => {});
45791
+ const previous = this.intercomRawSession;
45283
45792
  this.intercomRawSession = null;
45793
+ await previous.pcmTranscode.close();
45794
+ await previous.session.stop().catch(() => {});
45284
45795
  }
45285
45796
  const session = new HikvisionIntercomSession({
45286
45797
  client: this.ensureClient(),
@@ -45296,9 +45807,17 @@ var HikvisionCamera = class HikvisionCamera extends BaseDevice {
45296
45807
  id,
45297
45808
  session,
45298
45809
  lastSequenceNumber: -1,
45299
- opusDecode: null
45810
+ opusDecode: null,
45811
+ pcmTranscode: new TalkPcmTranscoder({
45812
+ deviceId: this.id,
45813
+ logger: this.ctx.logger,
45814
+ resolveAudioCodec: () => this.resolveAudioCodecApi(),
45815
+ targetSampleRate: session.sampleRate,
45816
+ tag: `hikvision-intercom-pcm:${this.id}:${id}`,
45817
+ feed: (pcm) => session.feedPcm(pcm)
45818
+ })
45300
45819
  };
45301
- this.publishIntercomState(true);
45820
+ this.publishIntercomState(true, session.ability);
45302
45821
  this.ctx.logger.info("intercom talk session opened", {
45303
45822
  tags: { deviceId: this.id },
45304
45823
  meta: {
@@ -45311,13 +45830,24 @@ var HikvisionCamera = class HikvisionCamera extends BaseDevice {
45311
45830
  },
45312
45831
  pushTalkAudio: async ({ deviceId, audioBase64, codec, sampleRate, channels, sequenceNumber }) => {
45313
45832
  if (deviceId !== this.id) return { accepted: false };
45833
+ const wireCodec = codec ?? "s16le";
45834
+ const note = (reason) => {
45835
+ intercomFailureReport.noteTalkFrame(this.id, wireCodec, reason);
45836
+ };
45314
45837
  const active = this.intercomRawSession;
45315
45838
  if (!active || !active.session.isOpen) return { accepted: false };
45316
- if (sequenceNumber <= active.lastSequenceNumber) return { accepted: false };
45839
+ if (sequenceNumber <= active.lastSequenceNumber) {
45840
+ note(REASON_TALK_OUT_OF_ORDER);
45841
+ return { accepted: false };
45842
+ }
45317
45843
  const buf = Buffer.from(audioBase64, "base64");
45318
- if (buf.length === 0) return { accepted: false };
45844
+ if (buf.length === 0) {
45845
+ note(REASON_TALK_EMPTY);
45846
+ return { accepted: false };
45847
+ }
45319
45848
  const ch = channels ?? 1;
45320
45849
  if (ch !== 1) {
45850
+ note(REASON_TALK_NOT_MONO);
45321
45851
  this.ctx.logger.warn("intercom: dropping non-mono talk frame (Hikvision is mono-only)", {
45322
45852
  tags: { deviceId: this.id },
45323
45853
  meta: {
@@ -45327,9 +45857,9 @@ var HikvisionCamera = class HikvisionCamera extends BaseDevice {
45327
45857
  });
45328
45858
  return { accepted: false };
45329
45859
  }
45330
- const wireCodec = codec ?? "s16le";
45331
45860
  if (wireCodec === "g711ulaw" || wireCodec === "g711alaw") {
45332
45861
  if (wireCodec !== active.session.audioCodec) {
45862
+ note(REASON_TALK_CODEC_UNSUPPORTED);
45333
45863
  this.ctx.logger.warn("intercom: codec mismatch — wire codec is not what the camera negotiated, dropping frame", {
45334
45864
  tags: { deviceId: this.id },
45335
45865
  meta: {
@@ -45341,28 +45871,34 @@ var HikvisionCamera = class HikvisionCamera extends BaseDevice {
45341
45871
  }
45342
45872
  active.lastSequenceNumber = sequenceNumber;
45343
45873
  active.session.feedEncoded(buf);
45874
+ note();
45344
45875
  return { accepted: true };
45345
45876
  }
45346
45877
  if (wireCodec === "s16le") {
45347
45878
  if (!sampleRate) {
45879
+ note(REASON_TALK_NO_SAMPLE_RATE);
45348
45880
  this.ctx.logger.warn("intercom: s16le push with no sampleRate — dropping (rate is ambiguous)", { tags: { deviceId: this.id } });
45349
45881
  return { accepted: false };
45350
45882
  }
45351
45883
  if (sampleRate !== active.session.sampleRate) {
45352
- this.ctx.logger.warn("intercom: s16le sampleRate mismatch (PCM-only resample not implemented) — dropping", {
45353
- tags: { deviceId: this.id },
45354
- meta: {
45355
- wireRate: sampleRate,
45356
- cameraRate: active.session.sampleRate
45357
- }
45884
+ const refusal = await active.pcmTranscode.feedResampled({
45885
+ pcm: buf,
45886
+ sourceSampleRate: sampleRate
45358
45887
  });
45359
- return { accepted: false };
45888
+ if (refusal !== null) {
45889
+ note(refusal);
45890
+ return { accepted: false };
45891
+ }
45892
+ active.lastSequenceNumber = sequenceNumber;
45893
+ note();
45894
+ return { accepted: true };
45360
45895
  }
45361
45896
  active.lastSequenceNumber = sequenceNumber;
45362
45897
  active.session.feedPcm(buf);
45898
+ note();
45363
45899
  return { accepted: true };
45364
45900
  }
45365
- if (wireCodec === "opus") {
45901
+ if (wireCodec === "opus") try {
45366
45902
  if (!active.opusDecode) {
45367
45903
  const created = await this.resolveAudioCodecApi().createDecodeSession({
45368
45904
  codec: "opus",
@@ -45405,8 +45941,20 @@ var HikvisionCamera = class HikvisionCamera extends BaseDevice {
45405
45941
  const pcmBuf = Buffer.from(chunk.data.buffer, chunk.data.byteOffset, chunk.data.byteLength);
45406
45942
  if (pcmBuf.length > 0) active.session.feedPcm(pcmBuf);
45407
45943
  }
45944
+ note();
45408
45945
  return { accepted: true };
45946
+ } catch (err) {
45947
+ note(REASON_TALK_OPUS_FAILED);
45948
+ throw err;
45409
45949
  }
45950
+ note(REASON_TALK_CODEC_UNSUPPORTED);
45951
+ this.ctx.logger.warn("intercom: no path onto the talk channel for this wire codec", {
45952
+ tags: { deviceId: this.id },
45953
+ meta: {
45954
+ wireCodec,
45955
+ cameraCodec: active.session.audioCodec
45956
+ }
45957
+ });
45410
45958
  return { accepted: false };
45411
45959
  },
45412
45960
  endTalkSession: async ({ deviceId }) => {
@@ -45414,6 +45962,7 @@ var HikvisionCamera = class HikvisionCamera extends BaseDevice {
45414
45962
  const active = this.intercomRawSession;
45415
45963
  if (!active) return;
45416
45964
  this.intercomRawSession = null;
45965
+ await active.pcmTranscode.close();
45417
45966
  if (active.opusDecode) await this.resolveAudioCodecApi().closeSession({
45418
45967
  sessionId: active.opusDecode.sessionId,
45419
45968
  nodeId: active.opusDecode.nodeId
@@ -57616,7 +58165,12 @@ var HikvisionProviderAddon = class extends BaseDeviceProvider {
57616
58165
  throw new Error(`Hikvision: ${reason}`);
57617
58166
  }
57618
58167
  async onInitialize() {
57619
- return await super.onInitialize();
58168
+ const regs = await super.onInitialize();
58169
+ regs.push({
58170
+ capability: failureContributionCapability,
58171
+ provider: { list: () => intercomFailureReport.list() }
58172
+ });
58173
+ return regs;
57620
58174
  }
57621
58175
  async supportsDiscovery() {
57622
58176
  return true;