@camstack/addon-provider-hikvision 1.2.45 → 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 +656 -20
  2. package/dist/addon.mjs +656 -20
  3. package/package.json +4 -1
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",
@@ -13650,6 +13756,133 @@ method(LogEntrySchema, _void(), { kind: "mutation" }), method(object({
13650
13756
  limit: number().optional(),
13651
13757
  tags: record(string(), string()).optional()
13652
13758
  }), array(LogEntrySchema).readonly());
13759
+ /**
13760
+ * `failure-contribution` — the capability an addon reports its OWN losses
13761
+ * through, per camera, with the denominator attached. It stores nothing.
13762
+ *
13763
+ * ## The twin of `load-contribution`, and why it is a twin and not a field
13764
+ *
13765
+ * `load-contribution` answers *what did this camera COST*. This answers *what
13766
+ * did this camera LOSE*. The reporting discipline is identical and deliberately
13767
+ * copied: the contributor reports what it already knows, hub-main adds only
13768
+ * `addonId`, nothing needs global knowledge, and there is no central list for
13769
+ * somebody to forget to edit.
13770
+ *
13771
+ * They are not merged, because their invariants are opposites:
13772
+ *
13773
+ * - a `load-contribution` measurement is **absent, never zero** — a zero would
13774
+ * claim a camera cost nothing, which is a measurement nobody made;
13775
+ * - a `failure-contribution` zero is the **most valuable value on the
13776
+ * surface** — `attempts: 400, succeeded: 400` is the proof a fix landed,
13777
+ * and it is exactly what an absent entry cannot say.
13778
+ *
13779
+ * Putting a loss counter on a cost entry would also break the reconciliation
13780
+ * that gives `load-contribution` its point: contributions are subtracted from
13781
+ * `metrics.node-processes-snapshot` to find processes nobody claims. A failure
13782
+ * has no process.
13783
+ *
13784
+ * ## Why not a log line, since the counters already exist
13785
+ *
13786
+ * Several of these paths already counted themselves — `CaptureScheduler`'s
13787
+ * per-device window, `KeyFrameCaptureLog`, `bumpCropMetric`. Every one of them
13788
+ * ends in a log line, and a log line is the thing the operator asked to stop
13789
+ * needing: *"possiamo armare questi errori intanto? Così al prossimo giro
13790
+ * ricontrolliamo tutti questi punti"*. Reading them meant grepping Loki and
13791
+ * hand-correlating timestamps, which is how a 22% thumbnail gap and a 3-hour
13792
+ * media blackout were both diagnosed. The counters stay; this is where they can
13793
+ * be READ.
13794
+ *
13795
+ * ## The rate is served with its denominator or not at all
13796
+ *
13797
+ * Every entry carries `attempts` and `succeeded`. A miss count alone is
13798
+ * unreadable: on 2026-08-28 the enrichment-crop miss count read as "35x worse
13799
+ * than yesterday" and was **flat across twelve hours** once divided by the
13800
+ * successes on the same path. A surface that publishes only the numerator
13801
+ * reproduces that mistake on every read.
13802
+ *
13803
+ * ## Shape
13804
+ *
13805
+ * Copied from `load-contribution.cap.ts` (`mode: 'collection'`,
13806
+ * `internal: true`, `mount: { kind: 'skip' }`): no tRPC route of its own and no
13807
+ * generated hooks, while `addons.listCapabilityProviders` still enumerates it
13808
+ * and the hub's `CapabilityRegistry` still holds an RPC proxy per provider — so
13809
+ * a forked runner's entries reach hub-main over transport that already exists.
13810
+ * No new UDS message, no second registry (D3). The operator reads the assembled
13811
+ * result through `system.getFailureContributions`.
13812
+ */
13813
+ var FailureReasonCountSchema = object({
13814
+ /**
13815
+ * Why the attempt did not land, in the contributor's own vocabulary —
13816
+ * `worker-lease-gone`, `queue-overflow`, `timeout`, `empty-read`. The same
13817
+ * strings that already appear in this repo's logs and, where one exists, the
13818
+ * same string the per-track `previewMissReason` records (D276): a second
13819
+ * vocabulary for the same loss would make the row and the counter
13820
+ * un-joinable.
13821
+ */
13822
+ reason: string(),
13823
+ count: number().int().nonnegative()
13824
+ });
13825
+ var FailureContributionSchema = object({
13826
+ /**
13827
+ * The failing path — `enrichment-crop`, `inference`, `plate-ocr`,
13828
+ * `person-over-vehicle`. Free text, for the reason `load-contribution` keeps
13829
+ * `unit` free: the families are owned by different addons and a shared enum
13830
+ * is a central list that rots invisibly.
13831
+ */
13832
+ family: string(),
13833
+ /**
13834
+ * The NUMERIC device id — the same value every log line carries as
13835
+ * `tags.deviceId`. Never nullable and never absent: a contributor that
13836
+ * cannot name the camera must not emit the entry, because a fleet total
13837
+ * cannot answer the only question anybody asks of this surface.
13838
+ */
13839
+ deviceId: number().int().positive(),
13840
+ /**
13841
+ * A second dimension inside the family: the model / step id for an inference
13842
+ * timeout, so "which camera AND which model" is one read. Absent when the
13843
+ * family has a single variant.
13844
+ */
13845
+ variant: string().optional(),
13846
+ /**
13847
+ * Epoch ms this counter started — the INCARNATION MARKER. A consumer
13848
+ * differencing two reads must drop the interval when it changes, because the
13849
+ * counter restarted from zero in a respawned runner. Same discipline as
13850
+ * `LoadContribution.startedAtMs`.
13851
+ */
13852
+ sinceMs: number(),
13853
+ /** Epoch ms it was read. `atMs - sinceMs` is the interval this covers. */
13854
+ atMs: number(),
13855
+ /**
13856
+ * THE DENOMINATOR — every attempt on this path for this camera in the
13857
+ * window. A failure count published without it is the mistake this schema
13858
+ * exists to make impossible.
13859
+ */
13860
+ attempts: number().int().nonnegative(),
13861
+ /** Attempts that landed. `attempts - succeeded` is the loss. */
13862
+ succeeded: number().int().nonnegative(),
13863
+ /** The loss, partitioned. Sums to `attempts - succeeded`. */
13864
+ reasons: array(FailureReasonCountSchema).readonly()
13865
+ });
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
+ };
13653
13886
  var LoadContributionSchema = object({
13654
13887
  role: _enum([
13655
13888
  "decode",
@@ -18240,6 +18473,20 @@ var TrackSchema = object({
18240
18473
  * `=== true` and render nothing otherwise — never infer "no rider".
18241
18474
  */
18242
18475
  hasRider: boolean().optional(),
18476
+ /**
18477
+ * WHY this track ended without a NATIVE best-shot tile
18478
+ * ([D276](../decisions/adr-0276-a-stand-in-tile-is-provisional-and-a-close-says-why.md)) —
18479
+ * a composed token line (`no-key-frame capture=keyframe:native-missx4`,
18480
+ * `derive-returned-null tile=standin`, …) written at close and CLEARED by
18481
+ * the late-keyFrame upgrade when a native tile lands after all. The
18482
+ * operator-facing answer to "perché manca l'immagine?" on a track whose
18483
+ * tile is a face/plate stand-in, a raster crop, or an icon.
18484
+ *
18485
+ * **Absent ≠ "missed silently"**: a row written before the column, a hub
18486
+ * that predates the field, and every track whose tile landed native all
18487
+ * omit it. Render nothing when absent.
18488
+ */
18489
+ previewMissReason: string().optional(),
18243
18490
  ...TrackFlagFields,
18244
18491
  ...TrackRetrainFields
18245
18492
  });
@@ -29850,6 +30097,13 @@ var LoggingSettingsPatchSchema = object({
29850
30097
  * anyone but its owner.
29851
30098
  */
29852
30099
  var ReportedLoadContributionSchema = LoadContributionSchema.extend({ addonId: string() });
30100
+ /**
30101
+ * One per-camera failure counter, plus WHO reported it.
30102
+ *
30103
+ * Same rule as {@link ReportedLoadContributionSchema}: `addonId` is stamped by
30104
+ * the hub as it enumerates providers, never by the contributor.
30105
+ */
30106
+ var ReportedFailureContributionSchema = FailureContributionSchema.extend({ addonId: string() });
29853
30107
  var GetLoggingSettingsInputSchema = object({
29854
30108
  scopeNodeId: string().optional(),
29855
30109
  /**
@@ -29908,7 +30162,7 @@ method(_void(), FeatureManifestSchema), method(_void(), HealthStatusSchema), met
29908
30162
  }), method(_void(), SiteLocationStatusSchema, {
29909
30163
  kind: "mutation",
29910
30164
  auth: "admin"
29911
- }), method(_void(), RequestCensusStatusSchema, { auth: "admin" }), method(_void(), array(ReportedLoadContributionSchema).readonly(), { auth: "admin" }), method(GetLoggingSettingsInputSchema, LoggingSettingsStateSchema, { auth: "admin" }), method(SetLoggingSettingsInputSchema, LoggingSettingsStateSchema, {
30165
+ }), method(_void(), RequestCensusStatusSchema, { auth: "admin" }), method(_void(), array(ReportedLoadContributionSchema).readonly(), { auth: "admin" }), method(_void(), array(ReportedFailureContributionSchema).readonly(), { auth: "admin" }), method(GetLoggingSettingsInputSchema, LoggingSettingsStateSchema, { auth: "admin" }), method(SetLoggingSettingsInputSchema, LoggingSettingsStateSchema, {
29912
30166
  kind: "mutation",
29913
30167
  auth: "admin"
29914
30168
  });
@@ -34131,6 +34385,12 @@ Object.freeze({
34131
34385
  addonId: null,
34132
34386
  access: "create"
34133
34387
  },
34388
+ "failureContribution.list": {
34389
+ capName: "failure-contribution",
34390
+ capScope: "system",
34391
+ addonId: null,
34392
+ access: "view"
34393
+ },
34134
34394
  "fanControl.setDirection": {
34135
34395
  capName: "fan-control",
34136
34396
  capScope: "device",
@@ -37437,6 +37697,12 @@ Object.freeze({
37437
37697
  addonId: null,
37438
37698
  access: "create"
37439
37699
  },
37700
+ "system.getFailureContributions": {
37701
+ capName: "system",
37702
+ capScope: "system",
37703
+ addonId: null,
37704
+ access: "view"
37705
+ },
37440
37706
  "system.getLoadContributions": {
37441
37707
  capName: "system",
37442
37708
  capScope: "system",
@@ -43449,6 +43715,15 @@ var DEFAULT_BACKLOG_MS = 200;
43449
43715
  var MAX_BACKLOG_MS = 5e3;
43450
43716
  var MIN_BACKLOG_MS = 20;
43451
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
+ /**
43452
43727
  * Fixed audioData PUT-body chunk size, in bytes. Encoded µ-law bytes are
43453
43728
  * accumulated and flushed to the sticky PUT in FIXED chunks of this size
43454
43729
  * (the sub-chunk remainder is held until the next flush, and the trailing
@@ -43517,6 +43792,36 @@ var HikvisionIntercomSession = class {
43517
43792
  get audioCodec() {
43518
43793
  return this.codec;
43519
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
+ }
43520
43825
  async start() {
43521
43826
  if (this.stream) return;
43522
43827
  const desiredChannel = this.opts.channelId ?? "1";
@@ -43575,7 +43880,7 @@ var HikvisionIntercomSession = class {
43575
43880
  });
43576
43881
  this.stop(reason).catch(() => {});
43577
43882
  });
43578
- const wantedBacklogMs = Math.max(MIN_BACKLOG_MS, Math.min(MAX_BACKLOG_MS, this.opts.maxBacklogMs ?? DEFAULT_BACKLOG_MS));
43883
+ const wantedBacklogMs = this.backlogMs;
43579
43884
  this.bytesPerSecond = HIKVISION_INTERCOM_SAMPLE_RATE * 2;
43580
43885
  this.maxBacklogBytes = Math.max(160, Math.floor(wantedBacklogMs / 1e3 * this.bytesPerSecond));
43581
43886
  this.stream = stream;
@@ -43757,6 +44062,90 @@ var HikvisionIntercomSession = class {
43757
44062
  }
43758
44063
  };
43759
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
43760
44149
  //#region src/intercom/intercom-orchestrator.ts
43761
44150
  var DEFAULT_OPUS_SAMPLE_RATE = 48e3;
43762
44151
  var DEFAULT_OPUS_CHANNELS = 1;
@@ -43774,6 +44163,14 @@ var IntercomOrchestrator = class {
43774
44163
  return this.session !== null && !this.session.closed;
43775
44164
  }
43776
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
+ /**
43777
44174
  * Subscribe to session-close notifications. The listener fires exactly
43778
44175
  * once per session with the resolved `IntercomCloseReason` + stats, so
43779
44176
  * the camera layer can surface WHY a talk session ended (the prime
@@ -44035,6 +44432,193 @@ function errMsg$1(err) {
44035
44432
  return err instanceof Error ? err.message : String(err);
44036
44433
  }
44037
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
44038
44622
  //#region src/intercom/werift-intercom-peer.ts
44039
44623
  var _werift;
44040
44624
  async function loadWerift() {
@@ -45082,13 +45666,20 @@ var HikvisionCamera = class HikvisionCamera extends BaseDevice {
45082
45666
  * Called at the four points that open or close a session — and seeded at
45083
45667
  * registration, so the slice says `talking: false` from boot rather than
45084
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.
45085
45676
  */
45086
- publishIntercomState(talking) {
45677
+ publishIntercomState(talking, ability) {
45087
45678
  const previous = this.getCapSlice(intercomCapability);
45088
45679
  this.setCapSlice(intercomCapability, {
45089
45680
  talking,
45090
45681
  lastSessionAt: talking ? Date.now() : previous?.lastSessionAt ?? null,
45091
- ability: previous?.ability ?? null
45682
+ ability: ability ?? previous?.ability ?? null
45092
45683
  });
45093
45684
  }
45094
45685
  registerIntercomIfSupported() {
@@ -45117,7 +45708,7 @@ var HikvisionCamera = class HikvisionCamera extends BaseDevice {
45117
45708
  });
45118
45709
  try {
45119
45710
  const opened = await this.intercomOrchestrator.start();
45120
- this.publishIntercomState(true);
45711
+ this.publishIntercomState(true, this.intercomOrchestrator.ability ?? void 0);
45121
45712
  return opened;
45122
45713
  } catch (err) {
45123
45714
  this.publishIntercomState(false);
@@ -45139,8 +45730,10 @@ var HikvisionCamera = class HikvisionCamera extends BaseDevice {
45139
45730
  if (deviceId !== this.id) throw new Error(`HikvisionCamera: intercom deviceId mismatch, expected ${this.id}, got ${deviceId}`);
45140
45731
  if (this.disabled) throw new Error("Hikvision intercom: device is disabled — re-enable it before opening a talk session");
45141
45732
  if (this.intercomRawSession) {
45142
- await this.intercomRawSession.session.stop().catch(() => {});
45733
+ const previous = this.intercomRawSession;
45143
45734
  this.intercomRawSession = null;
45735
+ await previous.pcmTranscode.close();
45736
+ await previous.session.stop().catch(() => {});
45144
45737
  }
45145
45738
  const session = new HikvisionIntercomSession({
45146
45739
  client: this.ensureClient(),
@@ -45156,9 +45749,17 @@ var HikvisionCamera = class HikvisionCamera extends BaseDevice {
45156
45749
  id,
45157
45750
  session,
45158
45751
  lastSequenceNumber: -1,
45159
- 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
+ })
45160
45761
  };
45161
- this.publishIntercomState(true);
45762
+ this.publishIntercomState(true, session.ability);
45162
45763
  this.ctx.logger.info("intercom talk session opened", {
45163
45764
  tags: { deviceId: this.id },
45164
45765
  meta: {
@@ -45171,13 +45772,24 @@ var HikvisionCamera = class HikvisionCamera extends BaseDevice {
45171
45772
  },
45172
45773
  pushTalkAudio: async ({ deviceId, audioBase64, codec, sampleRate, channels, sequenceNumber }) => {
45173
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
+ };
45174
45779
  const active = this.intercomRawSession;
45175
45780
  if (!active || !active.session.isOpen) return { accepted: false };
45176
- if (sequenceNumber <= active.lastSequenceNumber) return { accepted: false };
45781
+ if (sequenceNumber <= active.lastSequenceNumber) {
45782
+ note(REASON_TALK_OUT_OF_ORDER);
45783
+ return { accepted: false };
45784
+ }
45177
45785
  const buf = Buffer.from(audioBase64, "base64");
45178
- if (buf.length === 0) return { accepted: false };
45786
+ if (buf.length === 0) {
45787
+ note(REASON_TALK_EMPTY);
45788
+ return { accepted: false };
45789
+ }
45179
45790
  const ch = channels ?? 1;
45180
45791
  if (ch !== 1) {
45792
+ note(REASON_TALK_NOT_MONO);
45181
45793
  this.ctx.logger.warn("intercom: dropping non-mono talk frame (Hikvision is mono-only)", {
45182
45794
  tags: { deviceId: this.id },
45183
45795
  meta: {
@@ -45187,9 +45799,9 @@ var HikvisionCamera = class HikvisionCamera extends BaseDevice {
45187
45799
  });
45188
45800
  return { accepted: false };
45189
45801
  }
45190
- const wireCodec = codec ?? "s16le";
45191
45802
  if (wireCodec === "g711ulaw" || wireCodec === "g711alaw") {
45192
45803
  if (wireCodec !== active.session.audioCodec) {
45804
+ note(REASON_TALK_CODEC_UNSUPPORTED);
45193
45805
  this.ctx.logger.warn("intercom: codec mismatch — wire codec is not what the camera negotiated, dropping frame", {
45194
45806
  tags: { deviceId: this.id },
45195
45807
  meta: {
@@ -45201,28 +45813,34 @@ var HikvisionCamera = class HikvisionCamera extends BaseDevice {
45201
45813
  }
45202
45814
  active.lastSequenceNumber = sequenceNumber;
45203
45815
  active.session.feedEncoded(buf);
45816
+ note();
45204
45817
  return { accepted: true };
45205
45818
  }
45206
45819
  if (wireCodec === "s16le") {
45207
45820
  if (!sampleRate) {
45821
+ note(REASON_TALK_NO_SAMPLE_RATE);
45208
45822
  this.ctx.logger.warn("intercom: s16le push with no sampleRate — dropping (rate is ambiguous)", { tags: { deviceId: this.id } });
45209
45823
  return { accepted: false };
45210
45824
  }
45211
45825
  if (sampleRate !== active.session.sampleRate) {
45212
- this.ctx.logger.warn("intercom: s16le sampleRate mismatch (PCM-only resample not implemented) — dropping", {
45213
- tags: { deviceId: this.id },
45214
- meta: {
45215
- wireRate: sampleRate,
45216
- cameraRate: active.session.sampleRate
45217
- }
45826
+ const refusal = await active.pcmTranscode.feedResampled({
45827
+ pcm: buf,
45828
+ sourceSampleRate: sampleRate
45218
45829
  });
45219
- 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 };
45220
45837
  }
45221
45838
  active.lastSequenceNumber = sequenceNumber;
45222
45839
  active.session.feedPcm(buf);
45840
+ note();
45223
45841
  return { accepted: true };
45224
45842
  }
45225
- if (wireCodec === "opus") {
45843
+ if (wireCodec === "opus") try {
45226
45844
  if (!active.opusDecode) {
45227
45845
  const created = await this.resolveAudioCodecApi().createDecodeSession({
45228
45846
  codec: "opus",
@@ -45265,8 +45883,20 @@ var HikvisionCamera = class HikvisionCamera extends BaseDevice {
45265
45883
  const pcmBuf = Buffer.from(chunk.data.buffer, chunk.data.byteOffset, chunk.data.byteLength);
45266
45884
  if (pcmBuf.length > 0) active.session.feedPcm(pcmBuf);
45267
45885
  }
45886
+ note();
45268
45887
  return { accepted: true };
45888
+ } catch (err) {
45889
+ note(REASON_TALK_OPUS_FAILED);
45890
+ throw err;
45269
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
+ });
45270
45900
  return { accepted: false };
45271
45901
  },
45272
45902
  endTalkSession: async ({ deviceId }) => {
@@ -45274,6 +45904,7 @@ var HikvisionCamera = class HikvisionCamera extends BaseDevice {
45274
45904
  const active = this.intercomRawSession;
45275
45905
  if (!active) return;
45276
45906
  this.intercomRawSession = null;
45907
+ await active.pcmTranscode.close();
45277
45908
  if (active.opusDecode) await this.resolveAudioCodecApi().closeSession({
45278
45909
  sessionId: active.opusDecode.sessionId,
45279
45910
  nodeId: active.opusDecode.nodeId
@@ -57476,7 +58107,12 @@ var HikvisionProviderAddon = class extends BaseDeviceProvider {
57476
58107
  throw new Error(`Hikvision: ${reason}`);
57477
58108
  }
57478
58109
  async onInitialize() {
57479
- 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;
57480
58116
  }
57481
58117
  async supportsDiscovery() {
57482
58118
  return true;