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