@camstack/addon-provider-hikvision 1.2.46 → 1.2.47

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 +515 -20
  2. package/dist/addon.mjs +515 -20
  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",
@@ -43589,6 +43714,15 @@ var DEFAULT_BACKLOG_MS = 200;
43589
43714
  var MAX_BACKLOG_MS = 5e3;
43590
43715
  var MIN_BACKLOG_MS = 20;
43591
43716
  /**
43717
+ * The ONE place the operator's backlog request becomes the enforced bound.
43718
+ * `start()` sizes the byte window from it and `ability.maxBacklogMs` reports
43719
+ * it — a second clamp would let the number a caller reads drift from the
43720
+ * number the buffer honours.
43721
+ */
43722
+ function clampBacklogMs(requested) {
43723
+ return Math.max(MIN_BACKLOG_MS, Math.min(MAX_BACKLOG_MS, requested ?? DEFAULT_BACKLOG_MS));
43724
+ }
43725
+ /**
43592
43726
  * Fixed audioData PUT-body chunk size, in bytes. Encoded µ-law bytes are
43593
43727
  * accumulated and flushed to the sticky PUT in FIXED chunks of this size
43594
43728
  * (the sub-chunk remainder is held until the next flush, and the trailing
@@ -43657,6 +43791,36 @@ var HikvisionIntercomSession = class {
43657
43791
  get audioCodec() {
43658
43792
  return this.codec;
43659
43793
  }
43794
+ /**
43795
+ * Effective PCM backlog bound, in ms — the operator's value clamped to
43796
+ * [{@link MIN_BACKLOG_MS}, {@link MAX_BACKLOG_MS}], i.e. what the session is
43797
+ * actually enforcing rather than what it was asked for. Readable before
43798
+ * `start()` because the clamp is pure.
43799
+ */
43800
+ get backlogMs() {
43801
+ return clampBacklogMs(this.opts.maxBacklogMs);
43802
+ }
43803
+ /**
43804
+ * The firmware's talk-back format — `IntercomStatus.ability`.
43805
+ *
43806
+ * Every field is a value this session already resolved against the camera;
43807
+ * nothing here is a default standing in for a probe. It was declared,
43808
+ * mirrored into runtime state and written by NOBODY while these exact
43809
+ * values were in hand and only reaching a log line (D281).
43810
+ *
43811
+ * `duplex` is the one judgement call: ISAPI two-way audio is a single
43812
+ * `audioData` channel and the provider enforces one active session per
43813
+ * camera, so `half` is reported. `full` would be the dangerous direction —
43814
+ * a consumer that believes it may listen while speaking takes no lock.
43815
+ */
43816
+ get ability() {
43817
+ return {
43818
+ codecs: [this.codec],
43819
+ sampleRate: HIKVISION_INTERCOM_SAMPLE_RATE,
43820
+ duplex: "half",
43821
+ maxBacklogMs: this.backlogMs
43822
+ };
43823
+ }
43660
43824
  async start() {
43661
43825
  if (this.stream) return;
43662
43826
  const desiredChannel = this.opts.channelId ?? "1";
@@ -43715,7 +43879,7 @@ var HikvisionIntercomSession = class {
43715
43879
  });
43716
43880
  this.stop(reason).catch(() => {});
43717
43881
  });
43718
- const wantedBacklogMs = Math.max(MIN_BACKLOG_MS, Math.min(MAX_BACKLOG_MS, this.opts.maxBacklogMs ?? DEFAULT_BACKLOG_MS));
43882
+ const wantedBacklogMs = this.backlogMs;
43719
43883
  this.bytesPerSecond = HIKVISION_INTERCOM_SAMPLE_RATE * 2;
43720
43884
  this.maxBacklogBytes = Math.max(160, Math.floor(wantedBacklogMs / 1e3 * this.bytesPerSecond));
43721
43885
  this.stream = stream;
@@ -43897,6 +44061,90 @@ var HikvisionIntercomSession = class {
43897
44061
  }
43898
44062
  };
43899
44063
  //#endregion
44064
+ //#region src/intercom/intercom-failure-report.ts
44065
+ /**
44066
+ * Per-camera talk-back counters, published through `failure-contribution`.
44067
+ *
44068
+ * ## The number that was never divided
44069
+ *
44070
+ * The rate-mismatch drop had ONE warn line and no counter, so "how much
44071
+ * talk-back is this camera losing" was answerable only by grepping Loki and
44072
+ * hand-correlating timestamps — the exact cost `failure-contribution` exists to
44073
+ * remove. And a bare drop count could not have answered it either: 40 drops out
44074
+ * of 40 pushes and 40 out of 40 000 are opposite findings that produce
44075
+ * identical log volume.
44076
+ *
44077
+ * So EVERY `pushTalkAudio` outcome on a live talk session is noted from the one
44078
+ * place that decides it — the accepted ones too. {@link FailureCounters}
44079
+ * carries `attempts` as the denominator and `succeeded` as the numerator, and
44080
+ * the reasons partition the rest. A success counted somewhere else would drift
44081
+ * from the failures and turn the ratio into fiction.
44082
+ *
44083
+ * ## `variant` is the wire codec, and it is honest
44084
+ *
44085
+ * `failure-contribution` keeps `variant` for a second dimension WITHIN a
44086
+ * family, and here the useful one is the format the caller pushed: an operator
44087
+ * asking "why is 617 silent" needs to know whether HomeKit's Opus or Alexa's
44088
+ * raw PCM is the half that is failing. The provider is handed that value on
44089
+ * every call, so it is reported rather than guessed — absent, never invented.
44090
+ *
44091
+ * ## Process-wide, because a counter is
44092
+ *
44093
+ * One addon is one process (D2) and every camera this addon owns lives in it,
44094
+ * so the instance is module-scoped: the cameras note into it and the addon
44095
+ * registers ONE `failure-contribution` provider that reads it. `sinceMs` is the
44096
+ * incarnation marker — a respawned runner restarts from zero and says so.
44097
+ * Reading NEVER drains.
44098
+ */
44099
+ /** One `pushTalkAudio` call against an open talk session. */
44100
+ var FAMILY_INTERCOM_TALK = "intercom-talk";
44101
+ /** The push arrived with a sequence number at or below the last accepted one. */
44102
+ var REASON_TALK_OUT_OF_ORDER = "out-of-order";
44103
+ /** The payload decoded to zero bytes. */
44104
+ var REASON_TALK_EMPTY = "empty-frame";
44105
+ /** More than one channel — every camera here is mono-only. */
44106
+ var REASON_TALK_NOT_MONO = "not-mono";
44107
+ /** `s16le` push with no `sampleRate`; the rate is ambiguous, not assumed. */
44108
+ var REASON_TALK_NO_SAMPLE_RATE = "missing-sample-rate";
44109
+ /** The wire codec has no path onto this camera's talk channel. */
44110
+ var REASON_TALK_CODEC_UNSUPPORTED = "codec-unsupported";
44111
+ /** The Opus decode path threw or could not open its session. */
44112
+ var REASON_TALK_OPUS_FAILED = "opus-decode-failed";
44113
+ /**
44114
+ * The addon's talk-back counters. One instance per process; the export at the
44115
+ * bottom of this file IS that instance.
44116
+ */
44117
+ var IntercomFailureReport = class {
44118
+ now;
44119
+ counters;
44120
+ constructor(now = Date.now, counters = new FailureCounters()) {
44121
+ this.now = now;
44122
+ this.counters = counters;
44123
+ }
44124
+ /**
44125
+ * Note one `pushTalkAudio` outcome. `reason` absent = the frame reached the
44126
+ * camera's talk channel.
44127
+ */
44128
+ noteTalkFrame(deviceId, wireCodec, reason) {
44129
+ this.counters.note({
44130
+ deviceId,
44131
+ family: FAMILY_INTERCOM_TALK,
44132
+ variant: wireCodec,
44133
+ ...reason !== void 0 ? { reason } : {}
44134
+ }, this.now());
44135
+ }
44136
+ /** The `failure-contribution` provider's payload. Reads, never resets. */
44137
+ list() {
44138
+ return this.counters.snapshot(this.now());
44139
+ }
44140
+ /** Addon disposal. */
44141
+ clear() {
44142
+ this.counters.clear();
44143
+ }
44144
+ };
44145
+ /** The process-wide instance every camera in this addon notes into. */
44146
+ var intercomFailureReport = new IntercomFailureReport();
44147
+ //#endregion
43900
44148
  //#region src/intercom/intercom-orchestrator.ts
43901
44149
  var DEFAULT_OPUS_SAMPLE_RATE = 48e3;
43902
44150
  var DEFAULT_OPUS_CHANNELS = 1;
@@ -43914,6 +44162,14 @@ var IntercomOrchestrator = class {
43914
44162
  return this.session !== null && !this.session.closed;
43915
44163
  }
43916
44164
  /**
44165
+ * The live talk session's firmware ability, or `null` when no session is
44166
+ * open. Read by the camera at `startSession` so the WebRTC path writes
44167
+ * `IntercomStatus.ability` from the same source the raw-PCM path does.
44168
+ */
44169
+ get ability() {
44170
+ return this.session === null || this.session.closed ? null : this.session.talkSession.ability;
44171
+ }
44172
+ /**
43917
44173
  * Subscribe to session-close notifications. The listener fires exactly
43918
44174
  * once per session with the resolved `IntercomCloseReason` + stats, so
43919
44175
  * the camera layer can surface WHY a talk session ended (the prime
@@ -44175,6 +44431,193 @@ function errMsg$1(err) {
44175
44431
  return err instanceof Error ? err.message : String(err);
44176
44432
  }
44177
44433
  //#endregion
44434
+ //#region src/intercom/talk-pcm-transcoder.ts
44435
+ /** libav codec name of a linear little-endian 16-bit PCM decode session. */
44436
+ var TALK_PCM_CODEC = "pcm_s16le";
44437
+ /** The transcoder was already closed — the talk session ended under the push. */
44438
+ var REASON_PCM_CLOSED = "pcm-transcoder-closed";
44439
+ /** The caller's declared source rate is not a usable positive integer. */
44440
+ var REASON_PCM_BAD_RATE = "pcm-bad-source-rate";
44441
+ /** The frame is empty or holds half a sample — malformed, not convertible. */
44442
+ var REASON_PCM_ODD_BYTES = "pcm-odd-bytes";
44443
+ /** No `audio-codec` provider is mounted on this cluster. */
44444
+ var REASON_PCM_NO_CODEC_CAP = "pcm-audio-codec-unavailable";
44445
+ /** The codec cap refused to open a linear-PCM decode session. */
44446
+ var REASON_PCM_SESSION_OPEN_FAILED = "pcm-resample-session-failed";
44447
+ /** The push/pull round-trip through the codec cap threw. */
44448
+ var REASON_PCM_CONVERT_FAILED = "pcm-resample-failed";
44449
+ function errMessage(err) {
44450
+ return err instanceof Error ? err.message : String(err);
44451
+ }
44452
+ var TalkPcmTranscoder = class {
44453
+ opts;
44454
+ active = null;
44455
+ closed = false;
44456
+ constructor(opts) {
44457
+ this.opts = opts;
44458
+ }
44459
+ /** The open codec session, or `null` before the first converted frame. */
44460
+ get sessionId() {
44461
+ return this.active?.sessionId ?? null;
44462
+ }
44463
+ /**
44464
+ * Convert one frame to the camera's rate and hand every produced chunk to
44465
+ * `feed`.
44466
+ *
44467
+ * Returns `null` when the frame was converted and fed, or the REASON string
44468
+ * it was refused for — already logged, with nothing fed.
44469
+ */
44470
+ async feedResampled(frame) {
44471
+ if (this.closed) {
44472
+ this.refuse(REASON_PCM_CLOSED, {});
44473
+ return REASON_PCM_CLOSED;
44474
+ }
44475
+ const sourceSampleRate = frame.sourceSampleRate;
44476
+ if (!Number.isInteger(sourceSampleRate) || sourceSampleRate <= 0) {
44477
+ this.refuse(REASON_PCM_BAD_RATE, { sourceSampleRate });
44478
+ return REASON_PCM_BAD_RATE;
44479
+ }
44480
+ if (frame.pcm.length === 0 || (frame.pcm.length & 1) !== 0) {
44481
+ this.refuse(REASON_PCM_ODD_BYTES, { bytes: frame.pcm.length });
44482
+ return REASON_PCM_ODD_BYTES;
44483
+ }
44484
+ let api;
44485
+ try {
44486
+ api = this.opts.resolveAudioCodec();
44487
+ } catch (err) {
44488
+ this.refuse(REASON_PCM_NO_CODEC_CAP, { error: errMessage(err) });
44489
+ return REASON_PCM_NO_CODEC_CAP;
44490
+ }
44491
+ if (this.active !== null && this.active.sourceSampleRate !== sourceSampleRate) {
44492
+ const previous = this.active.sourceSampleRate;
44493
+ await this.disposeSession(api, "source-rate-changed");
44494
+ this.opts.logger.info("intercom: pcm resample source rate changed — session recreated", {
44495
+ tags: { deviceId: this.opts.deviceId },
44496
+ meta: {
44497
+ previousSourceSampleRate: previous,
44498
+ sourceSampleRate
44499
+ }
44500
+ });
44501
+ }
44502
+ if (this.active === null) try {
44503
+ const created = await api.createDecodeSession({
44504
+ codec: TALK_PCM_CODEC,
44505
+ sourceSampleRate,
44506
+ sourceChannels: 1,
44507
+ targetSampleRate: this.opts.targetSampleRate,
44508
+ targetChannels: 1,
44509
+ targetFormat: "s16le",
44510
+ tag: this.opts.tag
44511
+ });
44512
+ this.active = {
44513
+ sessionId: created.sessionId,
44514
+ nodeId: created.nodeId,
44515
+ sourceSampleRate
44516
+ };
44517
+ this.opts.logger.info("intercom: pcm resample session opened", {
44518
+ tags: { deviceId: this.opts.deviceId },
44519
+ meta: {
44520
+ codec: TALK_PCM_CODEC,
44521
+ codecSessionId: created.sessionId,
44522
+ codecNodeId: created.nodeId,
44523
+ sourceSampleRate,
44524
+ targetSampleRate: this.opts.targetSampleRate,
44525
+ tag: this.opts.tag
44526
+ }
44527
+ });
44528
+ } catch (err) {
44529
+ this.refuse(REASON_PCM_SESSION_OPEN_FAILED, {
44530
+ sourceSampleRate,
44531
+ targetSampleRate: this.opts.targetSampleRate,
44532
+ error: errMessage(err)
44533
+ });
44534
+ return REASON_PCM_SESSION_OPEN_FAILED;
44535
+ }
44536
+ const session = this.active;
44537
+ try {
44538
+ await api.pushEncodedFrame({
44539
+ sessionId: session.sessionId,
44540
+ nodeId: session.nodeId,
44541
+ data: new Uint8Array(frame.pcm.buffer, frame.pcm.byteOffset, frame.pcm.byteLength)
44542
+ });
44543
+ const chunks = await api.pullPcm({
44544
+ sessionId: session.sessionId,
44545
+ nodeId: session.nodeId,
44546
+ maxCount: 8
44547
+ });
44548
+ for (const chunk of chunks) {
44549
+ const out = Buffer.from(chunk.data.buffer, chunk.data.byteOffset, chunk.data.byteLength);
44550
+ if (out.length > 0) this.opts.feed(out);
44551
+ }
44552
+ return null;
44553
+ } catch (err) {
44554
+ this.refuse(REASON_PCM_CONVERT_FAILED, {
44555
+ codecSessionId: session.sessionId,
44556
+ sourceSampleRate,
44557
+ targetSampleRate: this.opts.targetSampleRate,
44558
+ error: errMessage(err)
44559
+ });
44560
+ await this.disposeSession(api, "convert-failed");
44561
+ return REASON_PCM_CONVERT_FAILED;
44562
+ }
44563
+ }
44564
+ /**
44565
+ * Close the codec session. Idempotent, and called from the provider's
44566
+ * `endTalkSession` so the session dies with the talk session it served.
44567
+ */
44568
+ async close() {
44569
+ this.closed = true;
44570
+ if (this.active === null) return;
44571
+ let api;
44572
+ try {
44573
+ api = this.opts.resolveAudioCodec();
44574
+ } catch (err) {
44575
+ this.opts.logger.debug("intercom: pcm resample close skipped — audio-codec gone", {
44576
+ tags: { deviceId: this.opts.deviceId },
44577
+ meta: {
44578
+ codecSessionId: this.active.sessionId,
44579
+ error: errMessage(err)
44580
+ }
44581
+ });
44582
+ this.active = null;
44583
+ return;
44584
+ }
44585
+ await this.disposeSession(api, "talk-session-ended");
44586
+ }
44587
+ /** Close + forget the current session. Never throws. */
44588
+ async disposeSession(api, why) {
44589
+ const session = this.active;
44590
+ this.active = null;
44591
+ if (session === null) return;
44592
+ try {
44593
+ await api.closeSession({
44594
+ sessionId: session.sessionId,
44595
+ nodeId: session.nodeId
44596
+ });
44597
+ } catch (err) {
44598
+ this.opts.logger.debug("intercom: pcm resample closeSession error (continuing)", {
44599
+ tags: { deviceId: this.opts.deviceId },
44600
+ meta: {
44601
+ codecSessionId: session.sessionId,
44602
+ why,
44603
+ error: errMessage(err)
44604
+ }
44605
+ });
44606
+ }
44607
+ }
44608
+ /** One warn per refused frame. A branch that drops work says so. */
44609
+ refuse(reason, meta) {
44610
+ this.opts.logger.warn("intercom: pcm talk frame refused — not converted, nothing fed", {
44611
+ tags: { deviceId: this.opts.deviceId },
44612
+ meta: {
44613
+ reason,
44614
+ targetSampleRate: this.opts.targetSampleRate,
44615
+ ...meta
44616
+ }
44617
+ });
44618
+ }
44619
+ };
44620
+ //#endregion
44178
44621
  //#region src/intercom/werift-intercom-peer.ts
44179
44622
  var _werift;
44180
44623
  async function loadWerift() {
@@ -45222,13 +45665,20 @@ var HikvisionCamera = class HikvisionCamera extends BaseDevice {
45222
45665
  * Called at the four points that open or close a session — and seeded at
45223
45666
  * registration, so the slice says `talking: false` from boot rather than
45224
45667
  * only after the first session.
45668
+ *
45669
+ * `ability` is STICKY: it is the firmware's negotiated format, learned when a
45670
+ * session opens and still true after it closes, so a caller reading between
45671
+ * sessions gets the last probed value rather than `null`. Passing it is what
45672
+ * changed — it used to be copied forward from `previous` at every one of the
45673
+ * four call sites and written by nobody, while `session.sampleRate` and
45674
+ * `session.audioCodec` were in hand and only reaching a log line.
45225
45675
  */
45226
- publishIntercomState(talking) {
45676
+ publishIntercomState(talking, ability) {
45227
45677
  const previous = this.getCapSlice(intercomCapability);
45228
45678
  this.setCapSlice(intercomCapability, {
45229
45679
  talking,
45230
45680
  lastSessionAt: talking ? Date.now() : previous?.lastSessionAt ?? null,
45231
- ability: previous?.ability ?? null
45681
+ ability: ability ?? previous?.ability ?? null
45232
45682
  });
45233
45683
  }
45234
45684
  registerIntercomIfSupported() {
@@ -45257,7 +45707,7 @@ var HikvisionCamera = class HikvisionCamera extends BaseDevice {
45257
45707
  });
45258
45708
  try {
45259
45709
  const opened = await this.intercomOrchestrator.start();
45260
- this.publishIntercomState(true);
45710
+ this.publishIntercomState(true, this.intercomOrchestrator.ability ?? void 0);
45261
45711
  return opened;
45262
45712
  } catch (err) {
45263
45713
  this.publishIntercomState(false);
@@ -45279,8 +45729,10 @@ var HikvisionCamera = class HikvisionCamera extends BaseDevice {
45279
45729
  if (deviceId !== this.id) throw new Error(`HikvisionCamera: intercom deviceId mismatch, expected ${this.id}, got ${deviceId}`);
45280
45730
  if (this.disabled) throw new Error("Hikvision intercom: device is disabled — re-enable it before opening a talk session");
45281
45731
  if (this.intercomRawSession) {
45282
- await this.intercomRawSession.session.stop().catch(() => {});
45732
+ const previous = this.intercomRawSession;
45283
45733
  this.intercomRawSession = null;
45734
+ await previous.pcmTranscode.close();
45735
+ await previous.session.stop().catch(() => {});
45284
45736
  }
45285
45737
  const session = new HikvisionIntercomSession({
45286
45738
  client: this.ensureClient(),
@@ -45296,9 +45748,17 @@ var HikvisionCamera = class HikvisionCamera extends BaseDevice {
45296
45748
  id,
45297
45749
  session,
45298
45750
  lastSequenceNumber: -1,
45299
- opusDecode: null
45751
+ opusDecode: null,
45752
+ pcmTranscode: new TalkPcmTranscoder({
45753
+ deviceId: this.id,
45754
+ logger: this.ctx.logger,
45755
+ resolveAudioCodec: () => this.resolveAudioCodecApi(),
45756
+ targetSampleRate: session.sampleRate,
45757
+ tag: `hikvision-intercom-pcm:${this.id}:${id}`,
45758
+ feed: (pcm) => session.feedPcm(pcm)
45759
+ })
45300
45760
  };
45301
- this.publishIntercomState(true);
45761
+ this.publishIntercomState(true, session.ability);
45302
45762
  this.ctx.logger.info("intercom talk session opened", {
45303
45763
  tags: { deviceId: this.id },
45304
45764
  meta: {
@@ -45311,13 +45771,24 @@ var HikvisionCamera = class HikvisionCamera extends BaseDevice {
45311
45771
  },
45312
45772
  pushTalkAudio: async ({ deviceId, audioBase64, codec, sampleRate, channels, sequenceNumber }) => {
45313
45773
  if (deviceId !== this.id) return { accepted: false };
45774
+ const wireCodec = codec ?? "s16le";
45775
+ const note = (reason) => {
45776
+ intercomFailureReport.noteTalkFrame(this.id, wireCodec, reason);
45777
+ };
45314
45778
  const active = this.intercomRawSession;
45315
45779
  if (!active || !active.session.isOpen) return { accepted: false };
45316
- if (sequenceNumber <= active.lastSequenceNumber) return { accepted: false };
45780
+ if (sequenceNumber <= active.lastSequenceNumber) {
45781
+ note(REASON_TALK_OUT_OF_ORDER);
45782
+ return { accepted: false };
45783
+ }
45317
45784
  const buf = Buffer.from(audioBase64, "base64");
45318
- if (buf.length === 0) return { accepted: false };
45785
+ if (buf.length === 0) {
45786
+ note(REASON_TALK_EMPTY);
45787
+ return { accepted: false };
45788
+ }
45319
45789
  const ch = channels ?? 1;
45320
45790
  if (ch !== 1) {
45791
+ note(REASON_TALK_NOT_MONO);
45321
45792
  this.ctx.logger.warn("intercom: dropping non-mono talk frame (Hikvision is mono-only)", {
45322
45793
  tags: { deviceId: this.id },
45323
45794
  meta: {
@@ -45327,9 +45798,9 @@ var HikvisionCamera = class HikvisionCamera extends BaseDevice {
45327
45798
  });
45328
45799
  return { accepted: false };
45329
45800
  }
45330
- const wireCodec = codec ?? "s16le";
45331
45801
  if (wireCodec === "g711ulaw" || wireCodec === "g711alaw") {
45332
45802
  if (wireCodec !== active.session.audioCodec) {
45803
+ note(REASON_TALK_CODEC_UNSUPPORTED);
45333
45804
  this.ctx.logger.warn("intercom: codec mismatch — wire codec is not what the camera negotiated, dropping frame", {
45334
45805
  tags: { deviceId: this.id },
45335
45806
  meta: {
@@ -45341,28 +45812,34 @@ var HikvisionCamera = class HikvisionCamera extends BaseDevice {
45341
45812
  }
45342
45813
  active.lastSequenceNumber = sequenceNumber;
45343
45814
  active.session.feedEncoded(buf);
45815
+ note();
45344
45816
  return { accepted: true };
45345
45817
  }
45346
45818
  if (wireCodec === "s16le") {
45347
45819
  if (!sampleRate) {
45820
+ note(REASON_TALK_NO_SAMPLE_RATE);
45348
45821
  this.ctx.logger.warn("intercom: s16le push with no sampleRate — dropping (rate is ambiguous)", { tags: { deviceId: this.id } });
45349
45822
  return { accepted: false };
45350
45823
  }
45351
45824
  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
- }
45825
+ const refusal = await active.pcmTranscode.feedResampled({
45826
+ pcm: buf,
45827
+ sourceSampleRate: sampleRate
45358
45828
  });
45359
- return { accepted: false };
45829
+ if (refusal !== null) {
45830
+ note(refusal);
45831
+ return { accepted: false };
45832
+ }
45833
+ active.lastSequenceNumber = sequenceNumber;
45834
+ note();
45835
+ return { accepted: true };
45360
45836
  }
45361
45837
  active.lastSequenceNumber = sequenceNumber;
45362
45838
  active.session.feedPcm(buf);
45839
+ note();
45363
45840
  return { accepted: true };
45364
45841
  }
45365
- if (wireCodec === "opus") {
45842
+ if (wireCodec === "opus") try {
45366
45843
  if (!active.opusDecode) {
45367
45844
  const created = await this.resolveAudioCodecApi().createDecodeSession({
45368
45845
  codec: "opus",
@@ -45405,8 +45882,20 @@ var HikvisionCamera = class HikvisionCamera extends BaseDevice {
45405
45882
  const pcmBuf = Buffer.from(chunk.data.buffer, chunk.data.byteOffset, chunk.data.byteLength);
45406
45883
  if (pcmBuf.length > 0) active.session.feedPcm(pcmBuf);
45407
45884
  }
45885
+ note();
45408
45886
  return { accepted: true };
45887
+ } catch (err) {
45888
+ note(REASON_TALK_OPUS_FAILED);
45889
+ throw err;
45409
45890
  }
45891
+ note(REASON_TALK_CODEC_UNSUPPORTED);
45892
+ this.ctx.logger.warn("intercom: no path onto the talk channel for this wire codec", {
45893
+ tags: { deviceId: this.id },
45894
+ meta: {
45895
+ wireCodec,
45896
+ cameraCodec: active.session.audioCodec
45897
+ }
45898
+ });
45410
45899
  return { accepted: false };
45411
45900
  },
45412
45901
  endTalkSession: async ({ deviceId }) => {
@@ -45414,6 +45903,7 @@ var HikvisionCamera = class HikvisionCamera extends BaseDevice {
45414
45903
  const active = this.intercomRawSession;
45415
45904
  if (!active) return;
45416
45905
  this.intercomRawSession = null;
45906
+ await active.pcmTranscode.close();
45417
45907
  if (active.opusDecode) await this.resolveAudioCodecApi().closeSession({
45418
45908
  sessionId: active.opusDecode.sessionId,
45419
45909
  nodeId: active.opusDecode.nodeId
@@ -57616,7 +58106,12 @@ var HikvisionProviderAddon = class extends BaseDeviceProvider {
57616
58106
  throw new Error(`Hikvision: ${reason}`);
57617
58107
  }
57618
58108
  async onInitialize() {
57619
- return await super.onInitialize();
58109
+ const regs = await super.onInitialize();
58110
+ regs.push({
58111
+ capability: failureContributionCapability,
58112
+ provider: { list: () => intercomFailureReport.list() }
58113
+ });
58114
+ return regs;
57620
58115
  }
57621
58116
  async supportsDiscovery() {
57622
58117
  return true;
package/dist/addon.mjs CHANGED
@@ -8502,6 +8502,112 @@ var TIMEZONES = [
8502
8502
  function findTimezone(id) {
8503
8503
  return TIMEZONES.find((tz) => tz.id === id);
8504
8504
  }
8505
+ /**
8506
+ * Distinct (device, family, variant) counters one instance will hold.
8507
+ *
8508
+ * A large fleet x the handful of families any single addon reports, with
8509
+ * slack. At ~200 B per counter this is a ~100 KB ceiling on a process that
8510
+ * already declares an RSS budget in the gigabytes.
8511
+ */
8512
+ var MAX_KEYS = 1024;
8513
+ /**
8514
+ * Where reasons past {@link MAX_REASONS_PER_KEY} go.
8515
+ *
8516
+ * They are FOLDED, never dropped: `attempts - succeeded` must always equal the
8517
+ * sum of the reason counts, or the ratio stops adding up.
8518
+ */
8519
+ var OVERFLOW_REASON = "other";
8520
+ /** `deviceId` + `family` + optional `variant`, flattened into the map key. */
8521
+ function counterKey(deviceId, family, variant) {
8522
+ return variant === void 0 ? `${deviceId}${family}` : `${deviceId}${family}${variant}`;
8523
+ }
8524
+ /**
8525
+ * A bounded set of per-camera, cumulative failure counters.
8526
+ *
8527
+ * One instance per contributing subsystem. `note` is O(1) and allocation-free
8528
+ * on the steady path; `snapshot` reads without mutating anything.
8529
+ */
8530
+ var FailureCounters = class {
8531
+ maxKeys;
8532
+ maxReasons;
8533
+ counters = /* @__PURE__ */ new Map();
8534
+ refused = 0;
8535
+ constructor(maxKeys = MAX_KEYS, maxReasons = 16) {
8536
+ this.maxKeys = maxKeys;
8537
+ this.maxReasons = maxReasons;
8538
+ }
8539
+ /**
8540
+ * Counters refused because {@link MAX_KEYS} was already held.
8541
+ *
8542
+ * Cumulative for the life of the instance: a bound that bit is a fact about
8543
+ * the deployment, and a surface that hid it would under-report a fleet
8544
+ * precisely when the fleet got large enough to matter.
8545
+ */
8546
+ get keysRefused() {
8547
+ return this.refused;
8548
+ }
8549
+ /** Counters currently held. */
8550
+ get size() {
8551
+ return this.counters.size;
8552
+ }
8553
+ /**
8554
+ * Fold one observation in.
8555
+ *
8556
+ * A non-positive or non-integer `deviceId` is REFUSED rather than bucketed:
8557
+ * see the module docblock — an entry that cannot name its camera is worse
8558
+ * than no entry.
8559
+ */
8560
+ note(observation, nowMs) {
8561
+ if (!Number.isInteger(observation.deviceId) || observation.deviceId <= 0) return;
8562
+ const key = counterKey(observation.deviceId, observation.family, observation.variant);
8563
+ let counter = this.counters.get(key);
8564
+ if (counter === void 0) {
8565
+ if (this.counters.size >= this.maxKeys) {
8566
+ this.refused += 1;
8567
+ return;
8568
+ }
8569
+ counter = {
8570
+ deviceId: observation.deviceId,
8571
+ family: observation.family,
8572
+ variant: observation.variant,
8573
+ sinceMs: nowMs,
8574
+ attempts: 0,
8575
+ succeeded: 0,
8576
+ reasons: /* @__PURE__ */ new Map()
8577
+ };
8578
+ this.counters.set(key, counter);
8579
+ }
8580
+ counter.attempts += 1;
8581
+ if (observation.reason === void 0) {
8582
+ counter.succeeded += 1;
8583
+ return;
8584
+ }
8585
+ const reason = counter.reasons.has(observation.reason) || counter.reasons.size < this.maxReasons ? observation.reason : OVERFLOW_REASON;
8586
+ counter.reasons.set(reason, (counter.reasons.get(reason) ?? 0) + 1);
8587
+ }
8588
+ /** Read every counter. Never mutates — see the module docblock. */
8589
+ snapshot(nowMs) {
8590
+ const out = [];
8591
+ for (const counter of this.counters.values()) out.push({
8592
+ deviceId: counter.deviceId,
8593
+ family: counter.family,
8594
+ ...counter.variant !== void 0 ? { variant: counter.variant } : {},
8595
+ sinceMs: counter.sinceMs,
8596
+ atMs: nowMs,
8597
+ attempts: counter.attempts,
8598
+ succeeded: counter.succeeded,
8599
+ reasons: [...counter.reasons.entries()].map(([reason, count]) => ({
8600
+ reason,
8601
+ count
8602
+ })).toSorted((a, b) => b.count - a.count)
8603
+ });
8604
+ return out;
8605
+ }
8606
+ /** Drop everything (host disposal). */
8607
+ clear() {
8608
+ this.counters.clear();
8609
+ }
8610
+ };
8505
8611
  var MODEL_FORMATS = [
8506
8612
  "onnx",
8507
8613
  "coreml",
@@ -13757,7 +13863,26 @@ var FailureContributionSchema = object({
13757
13863
  /** The loss, partitioned. Sums to `attempts - succeeded`. */
13758
13864
  reasons: array(FailureReasonCountSchema).readonly()
13759
13865
  });
13760
- method(_void(), array(FailureContributionSchema).readonly());
13866
+ var failureContributionCapability = {
13867
+ name: "failure-contribution",
13868
+ scope: "system",
13869
+ mode: "collection",
13870
+ internal: true,
13871
+ methods: {
13872
+ /**
13873
+ * This addon's per-camera failure counters, read live from bounded in-RAM
13874
+ * state it already keeps. Inert: no persistence, no sampling, no timer.
13875
+ *
13876
+ * READING NEVER RESETS. The counters are CUMULATIVE since `sinceMs`, and a
13877
+ * consumer that wants a rate differences two reads. A draining read would
13878
+ * make two operators with the page open each destroy half of the other's
13879
+ * numbers, and `load-contribution` already settled the same question the
13880
+ * same way for `cpuSeconds`.
13881
+ */
13882
+ list: method(_void(), array(FailureContributionSchema).readonly()) },
13883
+ /** In-process only — enumerated through `addons.listCapabilityProviders`. */
13884
+ mount: { kind: "skip" }
13885
+ };
13761
13886
  var LoadContributionSchema = object({
13762
13887
  role: _enum([
13763
13888
  "decode",
@@ -43590,6 +43715,15 @@ var DEFAULT_BACKLOG_MS = 200;
43590
43715
  var MAX_BACKLOG_MS = 5e3;
43591
43716
  var MIN_BACKLOG_MS = 20;
43592
43717
  /**
43718
+ * The ONE place the operator's backlog request becomes the enforced bound.
43719
+ * `start()` sizes the byte window from it and `ability.maxBacklogMs` reports
43720
+ * it — a second clamp would let the number a caller reads drift from the
43721
+ * number the buffer honours.
43722
+ */
43723
+ function clampBacklogMs(requested) {
43724
+ return Math.max(MIN_BACKLOG_MS, Math.min(MAX_BACKLOG_MS, requested ?? DEFAULT_BACKLOG_MS));
43725
+ }
43726
+ /**
43593
43727
  * Fixed audioData PUT-body chunk size, in bytes. Encoded µ-law bytes are
43594
43728
  * accumulated and flushed to the sticky PUT in FIXED chunks of this size
43595
43729
  * (the sub-chunk remainder is held until the next flush, and the trailing
@@ -43658,6 +43792,36 @@ var HikvisionIntercomSession = class {
43658
43792
  get audioCodec() {
43659
43793
  return this.codec;
43660
43794
  }
43795
+ /**
43796
+ * Effective PCM backlog bound, in ms — the operator's value clamped to
43797
+ * [{@link MIN_BACKLOG_MS}, {@link MAX_BACKLOG_MS}], i.e. what the session is
43798
+ * actually enforcing rather than what it was asked for. Readable before
43799
+ * `start()` because the clamp is pure.
43800
+ */
43801
+ get backlogMs() {
43802
+ return clampBacklogMs(this.opts.maxBacklogMs);
43803
+ }
43804
+ /**
43805
+ * The firmware's talk-back format — `IntercomStatus.ability`.
43806
+ *
43807
+ * Every field is a value this session already resolved against the camera;
43808
+ * nothing here is a default standing in for a probe. It was declared,
43809
+ * mirrored into runtime state and written by NOBODY while these exact
43810
+ * values were in hand and only reaching a log line (D281).
43811
+ *
43812
+ * `duplex` is the one judgement call: ISAPI two-way audio is a single
43813
+ * `audioData` channel and the provider enforces one active session per
43814
+ * camera, so `half` is reported. `full` would be the dangerous direction —
43815
+ * a consumer that believes it may listen while speaking takes no lock.
43816
+ */
43817
+ get ability() {
43818
+ return {
43819
+ codecs: [this.codec],
43820
+ sampleRate: HIKVISION_INTERCOM_SAMPLE_RATE,
43821
+ duplex: "half",
43822
+ maxBacklogMs: this.backlogMs
43823
+ };
43824
+ }
43661
43825
  async start() {
43662
43826
  if (this.stream) return;
43663
43827
  const desiredChannel = this.opts.channelId ?? "1";
@@ -43716,7 +43880,7 @@ var HikvisionIntercomSession = class {
43716
43880
  });
43717
43881
  this.stop(reason).catch(() => {});
43718
43882
  });
43719
- const wantedBacklogMs = Math.max(MIN_BACKLOG_MS, Math.min(MAX_BACKLOG_MS, this.opts.maxBacklogMs ?? DEFAULT_BACKLOG_MS));
43883
+ const wantedBacklogMs = this.backlogMs;
43720
43884
  this.bytesPerSecond = HIKVISION_INTERCOM_SAMPLE_RATE * 2;
43721
43885
  this.maxBacklogBytes = Math.max(160, Math.floor(wantedBacklogMs / 1e3 * this.bytesPerSecond));
43722
43886
  this.stream = stream;
@@ -43898,6 +44062,90 @@ var HikvisionIntercomSession = class {
43898
44062
  }
43899
44063
  };
43900
44064
  //#endregion
44065
+ //#region src/intercom/intercom-failure-report.ts
44066
+ /**
44067
+ * Per-camera talk-back counters, published through `failure-contribution`.
44068
+ *
44069
+ * ## The number that was never divided
44070
+ *
44071
+ * The rate-mismatch drop had ONE warn line and no counter, so "how much
44072
+ * talk-back is this camera losing" was answerable only by grepping Loki and
44073
+ * hand-correlating timestamps — the exact cost `failure-contribution` exists to
44074
+ * remove. And a bare drop count could not have answered it either: 40 drops out
44075
+ * of 40 pushes and 40 out of 40 000 are opposite findings that produce
44076
+ * identical log volume.
44077
+ *
44078
+ * So EVERY `pushTalkAudio` outcome on a live talk session is noted from the one
44079
+ * place that decides it — the accepted ones too. {@link FailureCounters}
44080
+ * carries `attempts` as the denominator and `succeeded` as the numerator, and
44081
+ * the reasons partition the rest. A success counted somewhere else would drift
44082
+ * from the failures and turn the ratio into fiction.
44083
+ *
44084
+ * ## `variant` is the wire codec, and it is honest
44085
+ *
44086
+ * `failure-contribution` keeps `variant` for a second dimension WITHIN a
44087
+ * family, and here the useful one is the format the caller pushed: an operator
44088
+ * asking "why is 617 silent" needs to know whether HomeKit's Opus or Alexa's
44089
+ * raw PCM is the half that is failing. The provider is handed that value on
44090
+ * every call, so it is reported rather than guessed — absent, never invented.
44091
+ *
44092
+ * ## Process-wide, because a counter is
44093
+ *
44094
+ * One addon is one process (D2) and every camera this addon owns lives in it,
44095
+ * so the instance is module-scoped: the cameras note into it and the addon
44096
+ * registers ONE `failure-contribution` provider that reads it. `sinceMs` is the
44097
+ * incarnation marker — a respawned runner restarts from zero and says so.
44098
+ * Reading NEVER drains.
44099
+ */
44100
+ /** One `pushTalkAudio` call against an open talk session. */
44101
+ var FAMILY_INTERCOM_TALK = "intercom-talk";
44102
+ /** The push arrived with a sequence number at or below the last accepted one. */
44103
+ var REASON_TALK_OUT_OF_ORDER = "out-of-order";
44104
+ /** The payload decoded to zero bytes. */
44105
+ var REASON_TALK_EMPTY = "empty-frame";
44106
+ /** More than one channel — every camera here is mono-only. */
44107
+ var REASON_TALK_NOT_MONO = "not-mono";
44108
+ /** `s16le` push with no `sampleRate`; the rate is ambiguous, not assumed. */
44109
+ var REASON_TALK_NO_SAMPLE_RATE = "missing-sample-rate";
44110
+ /** The wire codec has no path onto this camera's talk channel. */
44111
+ var REASON_TALK_CODEC_UNSUPPORTED = "codec-unsupported";
44112
+ /** The Opus decode path threw or could not open its session. */
44113
+ var REASON_TALK_OPUS_FAILED = "opus-decode-failed";
44114
+ /**
44115
+ * The addon's talk-back counters. One instance per process; the export at the
44116
+ * bottom of this file IS that instance.
44117
+ */
44118
+ var IntercomFailureReport = class {
44119
+ now;
44120
+ counters;
44121
+ constructor(now = Date.now, counters = new FailureCounters()) {
44122
+ this.now = now;
44123
+ this.counters = counters;
44124
+ }
44125
+ /**
44126
+ * Note one `pushTalkAudio` outcome. `reason` absent = the frame reached the
44127
+ * camera's talk channel.
44128
+ */
44129
+ noteTalkFrame(deviceId, wireCodec, reason) {
44130
+ this.counters.note({
44131
+ deviceId,
44132
+ family: FAMILY_INTERCOM_TALK,
44133
+ variant: wireCodec,
44134
+ ...reason !== void 0 ? { reason } : {}
44135
+ }, this.now());
44136
+ }
44137
+ /** The `failure-contribution` provider's payload. Reads, never resets. */
44138
+ list() {
44139
+ return this.counters.snapshot(this.now());
44140
+ }
44141
+ /** Addon disposal. */
44142
+ clear() {
44143
+ this.counters.clear();
44144
+ }
44145
+ };
44146
+ /** The process-wide instance every camera in this addon notes into. */
44147
+ var intercomFailureReport = new IntercomFailureReport();
44148
+ //#endregion
43901
44149
  //#region src/intercom/intercom-orchestrator.ts
43902
44150
  var DEFAULT_OPUS_SAMPLE_RATE = 48e3;
43903
44151
  var DEFAULT_OPUS_CHANNELS = 1;
@@ -43915,6 +44163,14 @@ var IntercomOrchestrator = class {
43915
44163
  return this.session !== null && !this.session.closed;
43916
44164
  }
43917
44165
  /**
44166
+ * The live talk session's firmware ability, or `null` when no session is
44167
+ * open. Read by the camera at `startSession` so the WebRTC path writes
44168
+ * `IntercomStatus.ability` from the same source the raw-PCM path does.
44169
+ */
44170
+ get ability() {
44171
+ return this.session === null || this.session.closed ? null : this.session.talkSession.ability;
44172
+ }
44173
+ /**
43918
44174
  * Subscribe to session-close notifications. The listener fires exactly
43919
44175
  * once per session with the resolved `IntercomCloseReason` + stats, so
43920
44176
  * the camera layer can surface WHY a talk session ended (the prime
@@ -44176,6 +44432,193 @@ function errMsg$1(err) {
44176
44432
  return err instanceof Error ? err.message : String(err);
44177
44433
  }
44178
44434
  //#endregion
44435
+ //#region src/intercom/talk-pcm-transcoder.ts
44436
+ /** libav codec name of a linear little-endian 16-bit PCM decode session. */
44437
+ var TALK_PCM_CODEC = "pcm_s16le";
44438
+ /** The transcoder was already closed — the talk session ended under the push. */
44439
+ var REASON_PCM_CLOSED = "pcm-transcoder-closed";
44440
+ /** The caller's declared source rate is not a usable positive integer. */
44441
+ var REASON_PCM_BAD_RATE = "pcm-bad-source-rate";
44442
+ /** The frame is empty or holds half a sample — malformed, not convertible. */
44443
+ var REASON_PCM_ODD_BYTES = "pcm-odd-bytes";
44444
+ /** No `audio-codec` provider is mounted on this cluster. */
44445
+ var REASON_PCM_NO_CODEC_CAP = "pcm-audio-codec-unavailable";
44446
+ /** The codec cap refused to open a linear-PCM decode session. */
44447
+ var REASON_PCM_SESSION_OPEN_FAILED = "pcm-resample-session-failed";
44448
+ /** The push/pull round-trip through the codec cap threw. */
44449
+ var REASON_PCM_CONVERT_FAILED = "pcm-resample-failed";
44450
+ function errMessage(err) {
44451
+ return err instanceof Error ? err.message : String(err);
44452
+ }
44453
+ var TalkPcmTranscoder = class {
44454
+ opts;
44455
+ active = null;
44456
+ closed = false;
44457
+ constructor(opts) {
44458
+ this.opts = opts;
44459
+ }
44460
+ /** The open codec session, or `null` before the first converted frame. */
44461
+ get sessionId() {
44462
+ return this.active?.sessionId ?? null;
44463
+ }
44464
+ /**
44465
+ * Convert one frame to the camera's rate and hand every produced chunk to
44466
+ * `feed`.
44467
+ *
44468
+ * Returns `null` when the frame was converted and fed, or the REASON string
44469
+ * it was refused for — already logged, with nothing fed.
44470
+ */
44471
+ async feedResampled(frame) {
44472
+ if (this.closed) {
44473
+ this.refuse(REASON_PCM_CLOSED, {});
44474
+ return REASON_PCM_CLOSED;
44475
+ }
44476
+ const sourceSampleRate = frame.sourceSampleRate;
44477
+ if (!Number.isInteger(sourceSampleRate) || sourceSampleRate <= 0) {
44478
+ this.refuse(REASON_PCM_BAD_RATE, { sourceSampleRate });
44479
+ return REASON_PCM_BAD_RATE;
44480
+ }
44481
+ if (frame.pcm.length === 0 || (frame.pcm.length & 1) !== 0) {
44482
+ this.refuse(REASON_PCM_ODD_BYTES, { bytes: frame.pcm.length });
44483
+ return REASON_PCM_ODD_BYTES;
44484
+ }
44485
+ let api;
44486
+ try {
44487
+ api = this.opts.resolveAudioCodec();
44488
+ } catch (err) {
44489
+ this.refuse(REASON_PCM_NO_CODEC_CAP, { error: errMessage(err) });
44490
+ return REASON_PCM_NO_CODEC_CAP;
44491
+ }
44492
+ if (this.active !== null && this.active.sourceSampleRate !== sourceSampleRate) {
44493
+ const previous = this.active.sourceSampleRate;
44494
+ await this.disposeSession(api, "source-rate-changed");
44495
+ this.opts.logger.info("intercom: pcm resample source rate changed — session recreated", {
44496
+ tags: { deviceId: this.opts.deviceId },
44497
+ meta: {
44498
+ previousSourceSampleRate: previous,
44499
+ sourceSampleRate
44500
+ }
44501
+ });
44502
+ }
44503
+ if (this.active === null) try {
44504
+ const created = await api.createDecodeSession({
44505
+ codec: TALK_PCM_CODEC,
44506
+ sourceSampleRate,
44507
+ sourceChannels: 1,
44508
+ targetSampleRate: this.opts.targetSampleRate,
44509
+ targetChannels: 1,
44510
+ targetFormat: "s16le",
44511
+ tag: this.opts.tag
44512
+ });
44513
+ this.active = {
44514
+ sessionId: created.sessionId,
44515
+ nodeId: created.nodeId,
44516
+ sourceSampleRate
44517
+ };
44518
+ this.opts.logger.info("intercom: pcm resample session opened", {
44519
+ tags: { deviceId: this.opts.deviceId },
44520
+ meta: {
44521
+ codec: TALK_PCM_CODEC,
44522
+ codecSessionId: created.sessionId,
44523
+ codecNodeId: created.nodeId,
44524
+ sourceSampleRate,
44525
+ targetSampleRate: this.opts.targetSampleRate,
44526
+ tag: this.opts.tag
44527
+ }
44528
+ });
44529
+ } catch (err) {
44530
+ this.refuse(REASON_PCM_SESSION_OPEN_FAILED, {
44531
+ sourceSampleRate,
44532
+ targetSampleRate: this.opts.targetSampleRate,
44533
+ error: errMessage(err)
44534
+ });
44535
+ return REASON_PCM_SESSION_OPEN_FAILED;
44536
+ }
44537
+ const session = this.active;
44538
+ try {
44539
+ await api.pushEncodedFrame({
44540
+ sessionId: session.sessionId,
44541
+ nodeId: session.nodeId,
44542
+ data: new Uint8Array(frame.pcm.buffer, frame.pcm.byteOffset, frame.pcm.byteLength)
44543
+ });
44544
+ const chunks = await api.pullPcm({
44545
+ sessionId: session.sessionId,
44546
+ nodeId: session.nodeId,
44547
+ maxCount: 8
44548
+ });
44549
+ for (const chunk of chunks) {
44550
+ const out = Buffer.from(chunk.data.buffer, chunk.data.byteOffset, chunk.data.byteLength);
44551
+ if (out.length > 0) this.opts.feed(out);
44552
+ }
44553
+ return null;
44554
+ } catch (err) {
44555
+ this.refuse(REASON_PCM_CONVERT_FAILED, {
44556
+ codecSessionId: session.sessionId,
44557
+ sourceSampleRate,
44558
+ targetSampleRate: this.opts.targetSampleRate,
44559
+ error: errMessage(err)
44560
+ });
44561
+ await this.disposeSession(api, "convert-failed");
44562
+ return REASON_PCM_CONVERT_FAILED;
44563
+ }
44564
+ }
44565
+ /**
44566
+ * Close the codec session. Idempotent, and called from the provider's
44567
+ * `endTalkSession` so the session dies with the talk session it served.
44568
+ */
44569
+ async close() {
44570
+ this.closed = true;
44571
+ if (this.active === null) return;
44572
+ let api;
44573
+ try {
44574
+ api = this.opts.resolveAudioCodec();
44575
+ } catch (err) {
44576
+ this.opts.logger.debug("intercom: pcm resample close skipped — audio-codec gone", {
44577
+ tags: { deviceId: this.opts.deviceId },
44578
+ meta: {
44579
+ codecSessionId: this.active.sessionId,
44580
+ error: errMessage(err)
44581
+ }
44582
+ });
44583
+ this.active = null;
44584
+ return;
44585
+ }
44586
+ await this.disposeSession(api, "talk-session-ended");
44587
+ }
44588
+ /** Close + forget the current session. Never throws. */
44589
+ async disposeSession(api, why) {
44590
+ const session = this.active;
44591
+ this.active = null;
44592
+ if (session === null) return;
44593
+ try {
44594
+ await api.closeSession({
44595
+ sessionId: session.sessionId,
44596
+ nodeId: session.nodeId
44597
+ });
44598
+ } catch (err) {
44599
+ this.opts.logger.debug("intercom: pcm resample closeSession error (continuing)", {
44600
+ tags: { deviceId: this.opts.deviceId },
44601
+ meta: {
44602
+ codecSessionId: session.sessionId,
44603
+ why,
44604
+ error: errMessage(err)
44605
+ }
44606
+ });
44607
+ }
44608
+ }
44609
+ /** One warn per refused frame. A branch that drops work says so. */
44610
+ refuse(reason, meta) {
44611
+ this.opts.logger.warn("intercom: pcm talk frame refused — not converted, nothing fed", {
44612
+ tags: { deviceId: this.opts.deviceId },
44613
+ meta: {
44614
+ reason,
44615
+ targetSampleRate: this.opts.targetSampleRate,
44616
+ ...meta
44617
+ }
44618
+ });
44619
+ }
44620
+ };
44621
+ //#endregion
44179
44622
  //#region src/intercom/werift-intercom-peer.ts
44180
44623
  var _werift;
44181
44624
  async function loadWerift() {
@@ -45223,13 +45666,20 @@ var HikvisionCamera = class HikvisionCamera extends BaseDevice {
45223
45666
  * Called at the four points that open or close a session — and seeded at
45224
45667
  * registration, so the slice says `talking: false` from boot rather than
45225
45668
  * only after the first session.
45669
+ *
45670
+ * `ability` is STICKY: it is the firmware's negotiated format, learned when a
45671
+ * session opens and still true after it closes, so a caller reading between
45672
+ * sessions gets the last probed value rather than `null`. Passing it is what
45673
+ * changed — it used to be copied forward from `previous` at every one of the
45674
+ * four call sites and written by nobody, while `session.sampleRate` and
45675
+ * `session.audioCodec` were in hand and only reaching a log line.
45226
45676
  */
45227
- publishIntercomState(talking) {
45677
+ publishIntercomState(talking, ability) {
45228
45678
  const previous = this.getCapSlice(intercomCapability);
45229
45679
  this.setCapSlice(intercomCapability, {
45230
45680
  talking,
45231
45681
  lastSessionAt: talking ? Date.now() : previous?.lastSessionAt ?? null,
45232
- ability: previous?.ability ?? null
45682
+ ability: ability ?? previous?.ability ?? null
45233
45683
  });
45234
45684
  }
45235
45685
  registerIntercomIfSupported() {
@@ -45258,7 +45708,7 @@ var HikvisionCamera = class HikvisionCamera extends BaseDevice {
45258
45708
  });
45259
45709
  try {
45260
45710
  const opened = await this.intercomOrchestrator.start();
45261
- this.publishIntercomState(true);
45711
+ this.publishIntercomState(true, this.intercomOrchestrator.ability ?? void 0);
45262
45712
  return opened;
45263
45713
  } catch (err) {
45264
45714
  this.publishIntercomState(false);
@@ -45280,8 +45730,10 @@ var HikvisionCamera = class HikvisionCamera extends BaseDevice {
45280
45730
  if (deviceId !== this.id) throw new Error(`HikvisionCamera: intercom deviceId mismatch, expected ${this.id}, got ${deviceId}`);
45281
45731
  if (this.disabled) throw new Error("Hikvision intercom: device is disabled — re-enable it before opening a talk session");
45282
45732
  if (this.intercomRawSession) {
45283
- await this.intercomRawSession.session.stop().catch(() => {});
45733
+ const previous = this.intercomRawSession;
45284
45734
  this.intercomRawSession = null;
45735
+ await previous.pcmTranscode.close();
45736
+ await previous.session.stop().catch(() => {});
45285
45737
  }
45286
45738
  const session = new HikvisionIntercomSession({
45287
45739
  client: this.ensureClient(),
@@ -45297,9 +45749,17 @@ var HikvisionCamera = class HikvisionCamera extends BaseDevice {
45297
45749
  id,
45298
45750
  session,
45299
45751
  lastSequenceNumber: -1,
45300
- opusDecode: null
45752
+ opusDecode: null,
45753
+ pcmTranscode: new TalkPcmTranscoder({
45754
+ deviceId: this.id,
45755
+ logger: this.ctx.logger,
45756
+ resolveAudioCodec: () => this.resolveAudioCodecApi(),
45757
+ targetSampleRate: session.sampleRate,
45758
+ tag: `hikvision-intercom-pcm:${this.id}:${id}`,
45759
+ feed: (pcm) => session.feedPcm(pcm)
45760
+ })
45301
45761
  };
45302
- this.publishIntercomState(true);
45762
+ this.publishIntercomState(true, session.ability);
45303
45763
  this.ctx.logger.info("intercom talk session opened", {
45304
45764
  tags: { deviceId: this.id },
45305
45765
  meta: {
@@ -45312,13 +45772,24 @@ var HikvisionCamera = class HikvisionCamera extends BaseDevice {
45312
45772
  },
45313
45773
  pushTalkAudio: async ({ deviceId, audioBase64, codec, sampleRate, channels, sequenceNumber }) => {
45314
45774
  if (deviceId !== this.id) return { accepted: false };
45775
+ const wireCodec = codec ?? "s16le";
45776
+ const note = (reason) => {
45777
+ intercomFailureReport.noteTalkFrame(this.id, wireCodec, reason);
45778
+ };
45315
45779
  const active = this.intercomRawSession;
45316
45780
  if (!active || !active.session.isOpen) return { accepted: false };
45317
- if (sequenceNumber <= active.lastSequenceNumber) return { accepted: false };
45781
+ if (sequenceNumber <= active.lastSequenceNumber) {
45782
+ note(REASON_TALK_OUT_OF_ORDER);
45783
+ return { accepted: false };
45784
+ }
45318
45785
  const buf = Buffer.from(audioBase64, "base64");
45319
- if (buf.length === 0) return { accepted: false };
45786
+ if (buf.length === 0) {
45787
+ note(REASON_TALK_EMPTY);
45788
+ return { accepted: false };
45789
+ }
45320
45790
  const ch = channels ?? 1;
45321
45791
  if (ch !== 1) {
45792
+ note(REASON_TALK_NOT_MONO);
45322
45793
  this.ctx.logger.warn("intercom: dropping non-mono talk frame (Hikvision is mono-only)", {
45323
45794
  tags: { deviceId: this.id },
45324
45795
  meta: {
@@ -45328,9 +45799,9 @@ var HikvisionCamera = class HikvisionCamera extends BaseDevice {
45328
45799
  });
45329
45800
  return { accepted: false };
45330
45801
  }
45331
- const wireCodec = codec ?? "s16le";
45332
45802
  if (wireCodec === "g711ulaw" || wireCodec === "g711alaw") {
45333
45803
  if (wireCodec !== active.session.audioCodec) {
45804
+ note(REASON_TALK_CODEC_UNSUPPORTED);
45334
45805
  this.ctx.logger.warn("intercom: codec mismatch — wire codec is not what the camera negotiated, dropping frame", {
45335
45806
  tags: { deviceId: this.id },
45336
45807
  meta: {
@@ -45342,28 +45813,34 @@ var HikvisionCamera = class HikvisionCamera extends BaseDevice {
45342
45813
  }
45343
45814
  active.lastSequenceNumber = sequenceNumber;
45344
45815
  active.session.feedEncoded(buf);
45816
+ note();
45345
45817
  return { accepted: true };
45346
45818
  }
45347
45819
  if (wireCodec === "s16le") {
45348
45820
  if (!sampleRate) {
45821
+ note(REASON_TALK_NO_SAMPLE_RATE);
45349
45822
  this.ctx.logger.warn("intercom: s16le push with no sampleRate — dropping (rate is ambiguous)", { tags: { deviceId: this.id } });
45350
45823
  return { accepted: false };
45351
45824
  }
45352
45825
  if (sampleRate !== active.session.sampleRate) {
45353
- this.ctx.logger.warn("intercom: s16le sampleRate mismatch (PCM-only resample not implemented) — dropping", {
45354
- tags: { deviceId: this.id },
45355
- meta: {
45356
- wireRate: sampleRate,
45357
- cameraRate: active.session.sampleRate
45358
- }
45826
+ const refusal = await active.pcmTranscode.feedResampled({
45827
+ pcm: buf,
45828
+ sourceSampleRate: sampleRate
45359
45829
  });
45360
- return { accepted: false };
45830
+ if (refusal !== null) {
45831
+ note(refusal);
45832
+ return { accepted: false };
45833
+ }
45834
+ active.lastSequenceNumber = sequenceNumber;
45835
+ note();
45836
+ return { accepted: true };
45361
45837
  }
45362
45838
  active.lastSequenceNumber = sequenceNumber;
45363
45839
  active.session.feedPcm(buf);
45840
+ note();
45364
45841
  return { accepted: true };
45365
45842
  }
45366
- if (wireCodec === "opus") {
45843
+ if (wireCodec === "opus") try {
45367
45844
  if (!active.opusDecode) {
45368
45845
  const created = await this.resolveAudioCodecApi().createDecodeSession({
45369
45846
  codec: "opus",
@@ -45406,8 +45883,20 @@ var HikvisionCamera = class HikvisionCamera extends BaseDevice {
45406
45883
  const pcmBuf = Buffer.from(chunk.data.buffer, chunk.data.byteOffset, chunk.data.byteLength);
45407
45884
  if (pcmBuf.length > 0) active.session.feedPcm(pcmBuf);
45408
45885
  }
45886
+ note();
45409
45887
  return { accepted: true };
45888
+ } catch (err) {
45889
+ note(REASON_TALK_OPUS_FAILED);
45890
+ throw err;
45410
45891
  }
45892
+ note(REASON_TALK_CODEC_UNSUPPORTED);
45893
+ this.ctx.logger.warn("intercom: no path onto the talk channel for this wire codec", {
45894
+ tags: { deviceId: this.id },
45895
+ meta: {
45896
+ wireCodec,
45897
+ cameraCodec: active.session.audioCodec
45898
+ }
45899
+ });
45411
45900
  return { accepted: false };
45412
45901
  },
45413
45902
  endTalkSession: async ({ deviceId }) => {
@@ -45415,6 +45904,7 @@ var HikvisionCamera = class HikvisionCamera extends BaseDevice {
45415
45904
  const active = this.intercomRawSession;
45416
45905
  if (!active) return;
45417
45906
  this.intercomRawSession = null;
45907
+ await active.pcmTranscode.close();
45418
45908
  if (active.opusDecode) await this.resolveAudioCodecApi().closeSession({
45419
45909
  sessionId: active.opusDecode.sessionId,
45420
45910
  nodeId: active.opusDecode.nodeId
@@ -57617,7 +58107,12 @@ var HikvisionProviderAddon = class extends BaseDeviceProvider {
57617
58107
  throw new Error(`Hikvision: ${reason}`);
57618
58108
  }
57619
58109
  async onInitialize() {
57620
- return await super.onInitialize();
58110
+ const regs = await super.onInitialize();
58111
+ regs.push({
58112
+ capability: failureContributionCapability,
58113
+ provider: { list: () => intercomFailureReport.list() }
58114
+ });
58115
+ return regs;
57621
58116
  }
57622
58117
  async supportsDiscovery() {
57623
58118
  return true;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@camstack/addon-provider-hikvision",
3
- "version": "1.2.46",
3
+ "version": "1.2.47",
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",
@@ -63,6 +63,9 @@
63
63
  },
64
64
  {
65
65
  "name": "image-settings"
66
+ },
67
+ {
68
+ "name": "failure-contribution"
66
69
  }
67
70
  ]
68
71
  }