@camstack/addon-provider-reolink 1.2.61 → 1.2.62

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 +509 -19
  2. package/dist/addon.mjs +509 -19
  3. package/package.json +4 -1
package/dist/addon.js CHANGED
@@ -8796,6 +8796,112 @@ var TIMEZONES = [
8796
8796
  function findTimezone(id) {
8797
8797
  return TIMEZONES.find((tz) => tz.id === id);
8798
8798
  }
8799
+ /**
8800
+ * Distinct (device, family, variant) counters one instance will hold.
8801
+ *
8802
+ * A large fleet x the handful of families any single addon reports, with
8803
+ * slack. At ~200 B per counter this is a ~100 KB ceiling on a process that
8804
+ * already declares an RSS budget in the gigabytes.
8805
+ */
8806
+ var MAX_KEYS = 1024;
8807
+ /**
8808
+ * Where reasons past {@link MAX_REASONS_PER_KEY} go.
8809
+ *
8810
+ * They are FOLDED, never dropped: `attempts - succeeded` must always equal the
8811
+ * sum of the reason counts, or the ratio stops adding up.
8812
+ */
8813
+ var OVERFLOW_REASON = "other";
8814
+ /** `deviceId` + `family` + optional `variant`, flattened into the map key. */
8815
+ function counterKey(deviceId, family, variant) {
8816
+ return variant === void 0 ? `${deviceId}${family}` : `${deviceId}${family}${variant}`;
8817
+ }
8818
+ /**
8819
+ * A bounded set of per-camera, cumulative failure counters.
8820
+ *
8821
+ * One instance per contributing subsystem. `note` is O(1) and allocation-free
8822
+ * on the steady path; `snapshot` reads without mutating anything.
8823
+ */
8824
+ var FailureCounters = class {
8825
+ maxKeys;
8826
+ maxReasons;
8827
+ counters = /* @__PURE__ */ new Map();
8828
+ refused = 0;
8829
+ constructor(maxKeys = MAX_KEYS, maxReasons = 16) {
8830
+ this.maxKeys = maxKeys;
8831
+ this.maxReasons = maxReasons;
8832
+ }
8833
+ /**
8834
+ * Counters refused because {@link MAX_KEYS} was already held.
8835
+ *
8836
+ * Cumulative for the life of the instance: a bound that bit is a fact about
8837
+ * the deployment, and a surface that hid it would under-report a fleet
8838
+ * precisely when the fleet got large enough to matter.
8839
+ */
8840
+ get keysRefused() {
8841
+ return this.refused;
8842
+ }
8843
+ /** Counters currently held. */
8844
+ get size() {
8845
+ return this.counters.size;
8846
+ }
8847
+ /**
8848
+ * Fold one observation in.
8849
+ *
8850
+ * A non-positive or non-integer `deviceId` is REFUSED rather than bucketed:
8851
+ * see the module docblock — an entry that cannot name its camera is worse
8852
+ * than no entry.
8853
+ */
8854
+ note(observation, nowMs) {
8855
+ if (!Number.isInteger(observation.deviceId) || observation.deviceId <= 0) return;
8856
+ const key = counterKey(observation.deviceId, observation.family, observation.variant);
8857
+ let counter = this.counters.get(key);
8858
+ if (counter === void 0) {
8859
+ if (this.counters.size >= this.maxKeys) {
8860
+ this.refused += 1;
8861
+ return;
8862
+ }
8863
+ counter = {
8864
+ deviceId: observation.deviceId,
8865
+ family: observation.family,
8866
+ variant: observation.variant,
8867
+ sinceMs: nowMs,
8868
+ attempts: 0,
8869
+ succeeded: 0,
8870
+ reasons: /* @__PURE__ */ new Map()
8871
+ };
8872
+ this.counters.set(key, counter);
8873
+ }
8874
+ counter.attempts += 1;
8875
+ if (observation.reason === void 0) {
8876
+ counter.succeeded += 1;
8877
+ return;
8878
+ }
8879
+ const reason = counter.reasons.has(observation.reason) || counter.reasons.size < this.maxReasons ? observation.reason : OVERFLOW_REASON;
8880
+ counter.reasons.set(reason, (counter.reasons.get(reason) ?? 0) + 1);
8881
+ }
8882
+ /** Read every counter. Never mutates — see the module docblock. */
8883
+ snapshot(nowMs) {
8884
+ const out = [];
8885
+ for (const counter of this.counters.values()) out.push({
8886
+ deviceId: counter.deviceId,
8887
+ family: counter.family,
8888
+ ...counter.variant !== void 0 ? { variant: counter.variant } : {},
8889
+ sinceMs: counter.sinceMs,
8890
+ atMs: nowMs,
8891
+ attempts: counter.attempts,
8892
+ succeeded: counter.succeeded,
8893
+ reasons: [...counter.reasons.entries()].map(([reason, count]) => ({
8894
+ reason,
8895
+ count
8896
+ })).toSorted((a, b) => b.count - a.count)
8897
+ });
8898
+ return out;
8899
+ }
8900
+ /** Drop everything (host disposal). */
8901
+ clear() {
8902
+ this.counters.clear();
8903
+ }
8904
+ };
8799
8905
  var MODEL_FORMATS = [
8800
8906
  "onnx",
8801
8907
  "coreml",
@@ -14072,7 +14178,26 @@ var FailureContributionSchema = object({
14072
14178
  /** The loss, partitioned. Sums to `attempts - succeeded`. */
14073
14179
  reasons: array(FailureReasonCountSchema).readonly()
14074
14180
  });
14075
- method(_void(), array(FailureContributionSchema).readonly());
14181
+ var failureContributionCapability = {
14182
+ name: "failure-contribution",
14183
+ scope: "system",
14184
+ mode: "collection",
14185
+ internal: true,
14186
+ methods: {
14187
+ /**
14188
+ * This addon's per-camera failure counters, read live from bounded in-RAM
14189
+ * state it already keeps. Inert: no persistence, no sampling, no timer.
14190
+ *
14191
+ * READING NEVER RESETS. The counters are CUMULATIVE since `sinceMs`, and a
14192
+ * consumer that wants a rate differences two reads. A draining read would
14193
+ * make two operators with the page open each destroy half of the other's
14194
+ * numbers, and `load-contribution` already settled the same question the
14195
+ * same way for `cpuSeconds`.
14196
+ */
14197
+ list: method(_void(), array(FailureContributionSchema).readonly()) },
14198
+ /** In-process only — enumerated through `addons.listCapabilityProviders`. */
14199
+ mount: { kind: "skip" }
14200
+ };
14076
14201
  var LoadContributionSchema = object({
14077
14202
  role: _enum([
14078
14203
  "decode",
@@ -229034,6 +229159,90 @@ function buildInitialStatus(config) {
229034
229159
  };
229035
229160
  }
229036
229161
  //#endregion
229162
+ //#region src/intercom-failure-report.ts
229163
+ /**
229164
+ * Per-camera talk-back counters, published through `failure-contribution`.
229165
+ *
229166
+ * ## The number that was never divided
229167
+ *
229168
+ * The rate-mismatch drop had ONE warn line and no counter, so "how much
229169
+ * talk-back is this camera losing" was answerable only by grepping Loki and
229170
+ * hand-correlating timestamps — the exact cost `failure-contribution` exists to
229171
+ * remove. And a bare drop count could not have answered it either: 40 drops out
229172
+ * of 40 pushes and 40 out of 40 000 are opposite findings that produce
229173
+ * identical log volume.
229174
+ *
229175
+ * So EVERY `pushTalkAudio` outcome on a live talk session is noted from the one
229176
+ * place that decides it — the accepted ones too. {@link FailureCounters}
229177
+ * carries `attempts` as the denominator and `succeeded` as the numerator, and
229178
+ * the reasons partition the rest. A success counted somewhere else would drift
229179
+ * from the failures and turn the ratio into fiction.
229180
+ *
229181
+ * ## `variant` is the wire codec, and it is honest
229182
+ *
229183
+ * `failure-contribution` keeps `variant` for a second dimension WITHIN a
229184
+ * family, and here the useful one is the format the caller pushed: an operator
229185
+ * asking "why is 617 silent" needs to know whether HomeKit's Opus or Alexa's
229186
+ * raw PCM is the half that is failing. The provider is handed that value on
229187
+ * every call, so it is reported rather than guessed — absent, never invented.
229188
+ *
229189
+ * ## Process-wide, because a counter is
229190
+ *
229191
+ * One addon is one process (D2) and every camera this addon owns lives in it,
229192
+ * so the instance is module-scoped: the cameras note into it and the addon
229193
+ * registers ONE `failure-contribution` provider that reads it. `sinceMs` is the
229194
+ * incarnation marker — a respawned runner restarts from zero and says so.
229195
+ * Reading NEVER drains.
229196
+ */
229197
+ /** One `pushTalkAudio` call against an open talk session. */
229198
+ var FAMILY_INTERCOM_TALK = "intercom-talk";
229199
+ /** The push arrived with a sequence number at or below the last accepted one. */
229200
+ var REASON_TALK_OUT_OF_ORDER = "out-of-order";
229201
+ /** The payload decoded to zero bytes. */
229202
+ var REASON_TALK_EMPTY = "empty-frame";
229203
+ /** More than one channel — every camera here is mono-only. */
229204
+ var REASON_TALK_NOT_MONO = "not-mono";
229205
+ /** `s16le` push with no `sampleRate`; the rate is ambiguous, not assumed. */
229206
+ var REASON_TALK_NO_SAMPLE_RATE = "missing-sample-rate";
229207
+ /** The wire codec has no path onto this camera's talk channel. */
229208
+ var REASON_TALK_CODEC_UNSUPPORTED = "codec-unsupported";
229209
+ /** The Opus decode path threw or could not open its session. */
229210
+ var REASON_TALK_OPUS_FAILED = "opus-decode-failed";
229211
+ /**
229212
+ * The addon's talk-back counters. One instance per process; the export at the
229213
+ * bottom of this file IS that instance.
229214
+ */
229215
+ var IntercomFailureReport = class {
229216
+ now;
229217
+ counters;
229218
+ constructor(now = Date.now, counters = new FailureCounters()) {
229219
+ this.now = now;
229220
+ this.counters = counters;
229221
+ }
229222
+ /**
229223
+ * Note one `pushTalkAudio` outcome. `reason` absent = the frame reached the
229224
+ * camera's talk channel.
229225
+ */
229226
+ noteTalkFrame(deviceId, wireCodec, reason) {
229227
+ this.counters.note({
229228
+ deviceId,
229229
+ family: FAMILY_INTERCOM_TALK,
229230
+ variant: wireCodec,
229231
+ ...reason !== void 0 ? { reason } : {}
229232
+ }, this.now());
229233
+ }
229234
+ /** The `failure-contribution` provider's payload. Reads, never resets. */
229235
+ list() {
229236
+ return this.counters.snapshot(this.now());
229237
+ }
229238
+ /** Addon disposal. */
229239
+ clear() {
229240
+ this.counters.clear();
229241
+ }
229242
+ };
229243
+ /** The process-wide instance every camera in this addon notes into. */
229244
+ var intercomFailureReport = new IntercomFailureReport();
229245
+ //#endregion
229037
229246
  //#region src/log-channels.ts
229038
229247
  /**
229039
229248
  * The diagnostic log CHANNELS `provider-reolink` declares.
@@ -230867,6 +231076,15 @@ function encodeImaAdpcm(pcm, blockSizeBytes) {
230867
231076
  var DEFAULT_BACKLOG_MS = 120;
230868
231077
  var MAX_BACKLOG_MS = 5e3;
230869
231078
  var MIN_BACKLOG_MS = 20;
231079
+ /**
231080
+ * The ONE place the operator's backlog request becomes the enforced bound.
231081
+ * `start()` sizes the byte window from it and `ability.maxBacklogMs` reports
231082
+ * it — a second clamp would let the number a caller reads drift from the
231083
+ * number the buffer honours.
231084
+ */
231085
+ function clampBacklogMs(requested) {
231086
+ return Math.max(MIN_BACKLOG_MS, Math.min(MAX_BACKLOG_MS, requested ?? DEFAULT_BACKLOG_MS));
231087
+ }
230870
231088
  var DEFAULT_BLOCKS_PER_PAYLOAD = 1;
230871
231089
  var DEFAULT_GAIN = 1;
230872
231090
  var MIN_GAIN = .1;
@@ -230894,6 +231112,36 @@ var ReolinkIntercomSession = class {
230894
231112
  if (!this.session) throw new Error("ReolinkIntercomSession.sampleRate read before start()");
230895
231113
  return this.session.info.audioConfig.sampleRate;
230896
231114
  }
231115
+ /**
231116
+ * Effective PCM backlog bound, in ms — the operator's value clamped to
231117
+ * [{@link MIN_BACKLOG_MS}, {@link MAX_BACKLOG_MS}], i.e. what the session
231118
+ * is actually enforcing rather than what it was asked for. Readable before
231119
+ * `start()` because the clamp is pure.
231120
+ */
231121
+ get backlogMs() {
231122
+ return clampBacklogMs(this.opts.maxBacklogMs);
231123
+ }
231124
+ /**
231125
+ * The firmware's talk-back format — `IntercomStatus.ability`. Throws before
231126
+ * `start()`, like `sampleRate`, because the rate is the camera's answer and
231127
+ * not a default.
231128
+ *
231129
+ * The field was declared, mirrored into runtime state and written by NOBODY
231130
+ * while these values were in hand and only reaching a log line (D281).
231131
+ *
231132
+ * `duplex` is the one judgement call: the Baichuan talk channel is a single
231133
+ * dedicated session and this provider enforces one at a time per camera, so
231134
+ * `half` is reported. `full` would be the dangerous direction — a consumer
231135
+ * that believes it may listen while speaking takes no lock.
231136
+ */
231137
+ get ability() {
231138
+ return {
231139
+ codecs: ["adpcm-ima"],
231140
+ sampleRate: this.sampleRate,
231141
+ duplex: "half",
231142
+ maxBacklogMs: this.backlogMs
231143
+ };
231144
+ }
230897
231145
  async start() {
230898
231146
  if (this.session) return;
230899
231147
  this.outputGain = clampGain(this.opts.outputGain);
@@ -230920,7 +231168,7 @@ var ReolinkIntercomSession = class {
230920
231168
  } catch {}
230921
231169
  throw new Error(`Reolink talk session reported invalid sampleRate: ${sampleRate}`);
230922
231170
  }
230923
- const wantedBacklogMs = Math.max(MIN_BACKLOG_MS, Math.min(MAX_BACKLOG_MS, this.opts.maxBacklogMs ?? DEFAULT_BACKLOG_MS));
231171
+ const wantedBacklogMs = this.backlogMs;
230924
231172
  this.maxBacklogBytes = Math.max(this.bytesPerBlock, Math.floor(wantedBacklogMs / 1e3 * sampleRate * 2));
230925
231173
  this.session = session;
230926
231174
  this.pcmBuffer = Buffer.alloc(0);
@@ -231048,6 +231296,14 @@ var IntercomOrchestrator = class {
231048
231296
  return this.session !== null && !this.session.closed;
231049
231297
  }
231050
231298
  /**
231299
+ * The live talk session's firmware ability, or `null` when no session is
231300
+ * open. Read by the camera at `startSession` so the WebRTC path writes
231301
+ * `IntercomStatus.ability` from the same source the raw-PCM path does.
231302
+ */
231303
+ get ability() {
231304
+ return this.session === null || this.session.closed ? null : this.session.talkSession.ability;
231305
+ }
231306
+ /**
231051
231307
  * Open a fresh WebRTC peer + audio-codec decode session + Reolink
231052
231308
  * talk session, wire them, return the SDP offer. Throws (and tears
231053
231309
  * down everything it had spun up) on any failure — the cap router
@@ -231265,6 +231521,193 @@ function errMsg$1(err) {
231265
231521
  return err instanceof Error ? err.message : String(err);
231266
231522
  }
231267
231523
  //#endregion
231524
+ //#region src/talk-pcm-transcoder.ts
231525
+ /** libav codec name of a linear little-endian 16-bit PCM decode session. */
231526
+ var TALK_PCM_CODEC = "pcm_s16le";
231527
+ /** The transcoder was already closed — the talk session ended under the push. */
231528
+ var REASON_PCM_CLOSED = "pcm-transcoder-closed";
231529
+ /** The caller's declared source rate is not a usable positive integer. */
231530
+ var REASON_PCM_BAD_RATE = "pcm-bad-source-rate";
231531
+ /** The frame is empty or holds half a sample — malformed, not convertible. */
231532
+ var REASON_PCM_ODD_BYTES = "pcm-odd-bytes";
231533
+ /** No `audio-codec` provider is mounted on this cluster. */
231534
+ var REASON_PCM_NO_CODEC_CAP = "pcm-audio-codec-unavailable";
231535
+ /** The codec cap refused to open a linear-PCM decode session. */
231536
+ var REASON_PCM_SESSION_OPEN_FAILED = "pcm-resample-session-failed";
231537
+ /** The push/pull round-trip through the codec cap threw. */
231538
+ var REASON_PCM_CONVERT_FAILED = "pcm-resample-failed";
231539
+ function errMessage(err) {
231540
+ return err instanceof Error ? err.message : String(err);
231541
+ }
231542
+ var TalkPcmTranscoder = class {
231543
+ opts;
231544
+ active = null;
231545
+ closed = false;
231546
+ constructor(opts) {
231547
+ this.opts = opts;
231548
+ }
231549
+ /** The open codec session, or `null` before the first converted frame. */
231550
+ get sessionId() {
231551
+ return this.active?.sessionId ?? null;
231552
+ }
231553
+ /**
231554
+ * Convert one frame to the camera's rate and hand every produced chunk to
231555
+ * `feed`.
231556
+ *
231557
+ * Returns `null` when the frame was converted and fed, or the REASON string
231558
+ * it was refused for — already logged, with nothing fed.
231559
+ */
231560
+ async feedResampled(frame) {
231561
+ if (this.closed) {
231562
+ this.refuse(REASON_PCM_CLOSED, {});
231563
+ return REASON_PCM_CLOSED;
231564
+ }
231565
+ const sourceSampleRate = frame.sourceSampleRate;
231566
+ if (!Number.isInteger(sourceSampleRate) || sourceSampleRate <= 0) {
231567
+ this.refuse(REASON_PCM_BAD_RATE, { sourceSampleRate });
231568
+ return REASON_PCM_BAD_RATE;
231569
+ }
231570
+ if (frame.pcm.length === 0 || (frame.pcm.length & 1) !== 0) {
231571
+ this.refuse(REASON_PCM_ODD_BYTES, { bytes: frame.pcm.length });
231572
+ return REASON_PCM_ODD_BYTES;
231573
+ }
231574
+ let api;
231575
+ try {
231576
+ api = this.opts.resolveAudioCodec();
231577
+ } catch (err) {
231578
+ this.refuse(REASON_PCM_NO_CODEC_CAP, { error: errMessage(err) });
231579
+ return REASON_PCM_NO_CODEC_CAP;
231580
+ }
231581
+ if (this.active !== null && this.active.sourceSampleRate !== sourceSampleRate) {
231582
+ const previous = this.active.sourceSampleRate;
231583
+ await this.disposeSession(api, "source-rate-changed");
231584
+ this.opts.logger.info("intercom: pcm resample source rate changed — session recreated", {
231585
+ tags: { deviceId: this.opts.deviceId },
231586
+ meta: {
231587
+ previousSourceSampleRate: previous,
231588
+ sourceSampleRate
231589
+ }
231590
+ });
231591
+ }
231592
+ if (this.active === null) try {
231593
+ const created = await api.createDecodeSession({
231594
+ codec: TALK_PCM_CODEC,
231595
+ sourceSampleRate,
231596
+ sourceChannels: 1,
231597
+ targetSampleRate: this.opts.targetSampleRate,
231598
+ targetChannels: 1,
231599
+ targetFormat: "s16le",
231600
+ tag: this.opts.tag
231601
+ });
231602
+ this.active = {
231603
+ sessionId: created.sessionId,
231604
+ nodeId: created.nodeId,
231605
+ sourceSampleRate
231606
+ };
231607
+ this.opts.logger.info("intercom: pcm resample session opened", {
231608
+ tags: { deviceId: this.opts.deviceId },
231609
+ meta: {
231610
+ codec: TALK_PCM_CODEC,
231611
+ codecSessionId: created.sessionId,
231612
+ codecNodeId: created.nodeId,
231613
+ sourceSampleRate,
231614
+ targetSampleRate: this.opts.targetSampleRate,
231615
+ tag: this.opts.tag
231616
+ }
231617
+ });
231618
+ } catch (err) {
231619
+ this.refuse(REASON_PCM_SESSION_OPEN_FAILED, {
231620
+ sourceSampleRate,
231621
+ targetSampleRate: this.opts.targetSampleRate,
231622
+ error: errMessage(err)
231623
+ });
231624
+ return REASON_PCM_SESSION_OPEN_FAILED;
231625
+ }
231626
+ const session = this.active;
231627
+ try {
231628
+ await api.pushEncodedFrame({
231629
+ sessionId: session.sessionId,
231630
+ nodeId: session.nodeId,
231631
+ data: new Uint8Array(frame.pcm.buffer, frame.pcm.byteOffset, frame.pcm.byteLength)
231632
+ });
231633
+ const chunks = await api.pullPcm({
231634
+ sessionId: session.sessionId,
231635
+ nodeId: session.nodeId,
231636
+ maxCount: 8
231637
+ });
231638
+ for (const chunk of chunks) {
231639
+ const out = Buffer.from(chunk.data.buffer, chunk.data.byteOffset, chunk.data.byteLength);
231640
+ if (out.length > 0) this.opts.feed(out);
231641
+ }
231642
+ return null;
231643
+ } catch (err) {
231644
+ this.refuse(REASON_PCM_CONVERT_FAILED, {
231645
+ codecSessionId: session.sessionId,
231646
+ sourceSampleRate,
231647
+ targetSampleRate: this.opts.targetSampleRate,
231648
+ error: errMessage(err)
231649
+ });
231650
+ await this.disposeSession(api, "convert-failed");
231651
+ return REASON_PCM_CONVERT_FAILED;
231652
+ }
231653
+ }
231654
+ /**
231655
+ * Close the codec session. Idempotent, and called from the provider's
231656
+ * `endTalkSession` so the session dies with the talk session it served.
231657
+ */
231658
+ async close() {
231659
+ this.closed = true;
231660
+ if (this.active === null) return;
231661
+ let api;
231662
+ try {
231663
+ api = this.opts.resolveAudioCodec();
231664
+ } catch (err) {
231665
+ this.opts.logger.debug("intercom: pcm resample close skipped — audio-codec gone", {
231666
+ tags: { deviceId: this.opts.deviceId },
231667
+ meta: {
231668
+ codecSessionId: this.active.sessionId,
231669
+ error: errMessage(err)
231670
+ }
231671
+ });
231672
+ this.active = null;
231673
+ return;
231674
+ }
231675
+ await this.disposeSession(api, "talk-session-ended");
231676
+ }
231677
+ /** Close + forget the current session. Never throws. */
231678
+ async disposeSession(api, why) {
231679
+ const session = this.active;
231680
+ this.active = null;
231681
+ if (session === null) return;
231682
+ try {
231683
+ await api.closeSession({
231684
+ sessionId: session.sessionId,
231685
+ nodeId: session.nodeId
231686
+ });
231687
+ } catch (err) {
231688
+ this.opts.logger.debug("intercom: pcm resample closeSession error (continuing)", {
231689
+ tags: { deviceId: this.opts.deviceId },
231690
+ meta: {
231691
+ codecSessionId: session.sessionId,
231692
+ why,
231693
+ error: errMessage(err)
231694
+ }
231695
+ });
231696
+ }
231697
+ }
231698
+ /** One warn per refused frame. A branch that drops work says so. */
231699
+ refuse(reason, meta) {
231700
+ this.opts.logger.warn("intercom: pcm talk frame refused — not converted, nothing fed", {
231701
+ tags: { deviceId: this.opts.deviceId },
231702
+ meta: {
231703
+ reason,
231704
+ targetSampleRate: this.opts.targetSampleRate,
231705
+ ...meta
231706
+ }
231707
+ });
231708
+ }
231709
+ };
231710
+ //#endregion
231268
231711
  //#region src/intercom-webrtc-peer.ts
231269
231712
  var _werift;
231270
231713
  /**
@@ -234534,13 +234977,20 @@ var ReolinkCamera = class ReolinkCamera extends BaseDevice {
234534
234977
  * Called at the four points that open or close a session — and seeded at
234535
234978
  * registration, so the slice says `talking: false` from boot rather than
234536
234979
  * only after the first session.
234980
+ *
234981
+ * `ability` is STICKY: it is the firmware's negotiated format, learned when a
234982
+ * session opens and still true after it closes, so a caller reading between
234983
+ * sessions gets the last probed value rather than `null`. Passing it is what
234984
+ * changed — it used to be copied forward from `previous` at every one of the
234985
+ * four call sites and written by nobody, while `session.sampleRate` was in
234986
+ * hand and only reaching a log line (D281).
234537
234987
  */
234538
- publishIntercomState(talking) {
234988
+ publishIntercomState(talking, ability) {
234539
234989
  const previous = this.getCapSlice(intercomCapability);
234540
234990
  this.setCapSlice(intercomCapability, {
234541
234991
  talking,
234542
234992
  lastSessionAt: talking ? Date.now() : previous?.lastSessionAt ?? null,
234543
- ability: previous?.ability ?? null
234993
+ ability: ability ?? previous?.ability ?? null
234544
234994
  });
234545
234995
  }
234546
234996
  registerIntercomIfSupported() {
@@ -234579,7 +235029,7 @@ var ReolinkCamera = class ReolinkCamera extends BaseDevice {
234579
235029
  });
234580
235030
  try {
234581
235031
  const opened = await this.intercomOrchestrator.start();
234582
- this.publishIntercomState(true);
235032
+ this.publishIntercomState(true, this.intercomOrchestrator.ability ?? void 0);
234583
235033
  return opened;
234584
235034
  } catch (err) {
234585
235035
  this.publishIntercomState(false);
@@ -234601,8 +235051,10 @@ var ReolinkCamera = class ReolinkCamera extends BaseDevice {
234601
235051
  if (deviceId !== this.id) throw new Error(`ReolinkCamera: intercom deviceId mismatch, expected ${this.id}, got ${deviceId}`);
234602
235052
  if (this.disabled) throw new Error("Reolink intercom: device is disabled — re-enable it before opening a talk session");
234603
235053
  if (this.intercomRawSession) {
234604
- await this.intercomRawSession.session.stop().catch(() => {});
235054
+ const previous = this.intercomRawSession;
234605
235055
  this.intercomRawSession = null;
235056
+ await previous.pcmTranscode.close();
235057
+ await previous.session.stop().catch(() => {});
234606
235058
  }
234607
235059
  const api = await this.ensureApi();
234608
235060
  if (this.isBattery) await this.wakeForIntercom(api);
@@ -234624,9 +235076,17 @@ var ReolinkCamera = class ReolinkCamera extends BaseDevice {
234624
235076
  id,
234625
235077
  session,
234626
235078
  lastSequenceNumber: -1,
234627
- opusDecode: null
235079
+ opusDecode: null,
235080
+ pcmTranscode: new TalkPcmTranscoder({
235081
+ deviceId: this.id,
235082
+ logger: this.ctx.logger,
235083
+ resolveAudioCodec: () => this.resolveAudioCodecApi(),
235084
+ targetSampleRate: session.sampleRate,
235085
+ tag: `reolink-intercom-pcm:${this.id}:${id}`,
235086
+ feed: (pcm) => session.feedPcm(pcm)
235087
+ })
234628
235088
  };
234629
- this.publishIntercomState(true);
235089
+ this.publishIntercomState(true, session.ability);
234630
235090
  this.ctx.logger.info("intercom talk session opened", {
234631
235091
  tags: { deviceId: this.id },
234632
235092
  meta: {
@@ -234638,13 +235098,24 @@ var ReolinkCamera = class ReolinkCamera extends BaseDevice {
234638
235098
  },
234639
235099
  pushTalkAudio: async ({ deviceId, audioBase64, codec, sampleRate, channels, sequenceNumber }) => {
234640
235100
  if (deviceId !== this.id) return { accepted: false };
235101
+ const wireCodec = codec ?? "s16le";
235102
+ const note = (reason) => {
235103
+ intercomFailureReport.noteTalkFrame(this.id, wireCodec, reason);
235104
+ };
234641
235105
  const active = this.intercomRawSession;
234642
235106
  if (!active || !active.session.isOpen) return { accepted: false };
234643
- if (sequenceNumber <= active.lastSequenceNumber) return { accepted: false };
235107
+ if (sequenceNumber <= active.lastSequenceNumber) {
235108
+ note(REASON_TALK_OUT_OF_ORDER);
235109
+ return { accepted: false };
235110
+ }
234644
235111
  const buf = Buffer.from(audioBase64, "base64");
234645
- if (buf.length === 0) return { accepted: false };
235112
+ if (buf.length === 0) {
235113
+ note(REASON_TALK_EMPTY);
235114
+ return { accepted: false };
235115
+ }
234646
235116
  const ch = channels ?? 1;
234647
235117
  if (ch !== 1) {
235118
+ note(REASON_TALK_NOT_MONO);
234648
235119
  this.ctx.logger.warn("intercom: dropping non-mono talk frame (Reolink is mono-only)", {
234649
235120
  tags: { deviceId: this.id },
234650
235121
  meta: {
@@ -234654,8 +235125,8 @@ var ReolinkCamera = class ReolinkCamera extends BaseDevice {
234654
235125
  });
234655
235126
  return { accepted: false };
234656
235127
  }
234657
- const wireCodec = codec ?? "s16le";
234658
235128
  if (wireCodec === "g711ulaw" || wireCodec === "g711alaw") {
235129
+ note(REASON_TALK_CODEC_UNSUPPORTED);
234659
235130
  this.ctx.logger.warn("intercom: g711 passthrough not supported on Reolink (camera codec is ADPCM) — dropping frame", {
234660
235131
  tags: { deviceId: this.id },
234661
235132
  meta: { wireCodec }
@@ -234664,24 +235135,29 @@ var ReolinkCamera = class ReolinkCamera extends BaseDevice {
234664
235135
  }
234665
235136
  if (wireCodec === "s16le") {
234666
235137
  if (!sampleRate) {
235138
+ note(REASON_TALK_NO_SAMPLE_RATE);
234667
235139
  this.ctx.logger.warn("intercom: s16le push with no sampleRate — dropping (rate is ambiguous)", { tags: { deviceId: this.id } });
234668
235140
  return { accepted: false };
234669
235141
  }
234670
235142
  if (sampleRate !== active.session.sampleRate) {
234671
- this.ctx.logger.warn("intercom: s16le sampleRate mismatch (PCM-only resample not implemented) — dropping", {
234672
- tags: { deviceId: this.id },
234673
- meta: {
234674
- wireRate: sampleRate,
234675
- cameraRate: active.session.sampleRate
234676
- }
235143
+ const refusal = await active.pcmTranscode.feedResampled({
235144
+ pcm: buf,
235145
+ sourceSampleRate: sampleRate
234677
235146
  });
234678
- return { accepted: false };
235147
+ if (refusal !== null) {
235148
+ note(refusal);
235149
+ return { accepted: false };
235150
+ }
235151
+ active.lastSequenceNumber = sequenceNumber;
235152
+ note();
235153
+ return { accepted: true };
234679
235154
  }
234680
235155
  active.lastSequenceNumber = sequenceNumber;
234681
235156
  active.session.feedPcm(buf);
235157
+ note();
234682
235158
  return { accepted: true };
234683
235159
  }
234684
- if (wireCodec === "opus") {
235160
+ if (wireCodec === "opus") try {
234685
235161
  if (!active.opusDecode) {
234686
235162
  const created = await this.resolveAudioCodecApi().createDecodeSession({
234687
235163
  codec: "opus",
@@ -234724,8 +235200,17 @@ var ReolinkCamera = class ReolinkCamera extends BaseDevice {
234724
235200
  const pcmBuf = Buffer.from(chunk.data.buffer, chunk.data.byteOffset, chunk.data.byteLength);
234725
235201
  if (pcmBuf.length > 0) active.session.feedPcm(pcmBuf);
234726
235202
  }
235203
+ note();
234727
235204
  return { accepted: true };
235205
+ } catch (err) {
235206
+ note(REASON_TALK_OPUS_FAILED);
235207
+ throw err;
234728
235208
  }
235209
+ note(REASON_TALK_CODEC_UNSUPPORTED);
235210
+ this.ctx.logger.warn("intercom: no path onto the talk channel for this wire codec", {
235211
+ tags: { deviceId: this.id },
235212
+ meta: { wireCodec }
235213
+ });
234729
235214
  return { accepted: false };
234730
235215
  },
234731
235216
  endTalkSession: async ({ deviceId }) => {
@@ -234733,6 +235218,7 @@ var ReolinkCamera = class ReolinkCamera extends BaseDevice {
234733
235218
  const active = this.intercomRawSession;
234734
235219
  if (!active) return;
234735
235220
  this.intercomRawSession = null;
235221
+ await active.pcmTranscode.close();
234736
235222
  if (active.opusDecode) await this.resolveAudioCodecApi().closeSession({
234737
235223
  sessionId: active.opusDecode.sessionId,
234738
235224
  nodeId: active.opusDecode.nodeId
@@ -241606,6 +242092,10 @@ var ReolinkProviderAddon = class extends BaseDeviceProvider {
241606
242092
  capability: logChannelsCapability,
241607
242093
  provider: this.logChannels
241608
242094
  });
242095
+ regs.push({
242096
+ capability: failureContributionCapability,
242097
+ provider: { list: () => intercomFailureReport.list() }
242098
+ });
241609
242099
  this.subscribe({ category: EventCategory.StreamBrokerOnRequestStreamSourceRefresh }, (event) => {
241610
242100
  const data = event.data;
241611
242101
  const deviceId = typeof data.deviceId === "number" ? data.deviceId : null;
package/dist/addon.mjs CHANGED
@@ -8791,6 +8791,112 @@ var TIMEZONES = [
8791
8791
  function findTimezone(id) {
8792
8792
  return TIMEZONES.find((tz) => tz.id === id);
8793
8793
  }
8794
+ /**
8795
+ * Distinct (device, family, variant) counters one instance will hold.
8796
+ *
8797
+ * A large fleet x the handful of families any single addon reports, with
8798
+ * slack. At ~200 B per counter this is a ~100 KB ceiling on a process that
8799
+ * already declares an RSS budget in the gigabytes.
8800
+ */
8801
+ var MAX_KEYS = 1024;
8802
+ /**
8803
+ * Where reasons past {@link MAX_REASONS_PER_KEY} go.
8804
+ *
8805
+ * They are FOLDED, never dropped: `attempts - succeeded` must always equal the
8806
+ * sum of the reason counts, or the ratio stops adding up.
8807
+ */
8808
+ var OVERFLOW_REASON = "other";
8809
+ /** `deviceId` + `family` + optional `variant`, flattened into the map key. */
8810
+ function counterKey(deviceId, family, variant) {
8811
+ return variant === void 0 ? `${deviceId}${family}` : `${deviceId}${family}${variant}`;
8812
+ }
8813
+ /**
8814
+ * A bounded set of per-camera, cumulative failure counters.
8815
+ *
8816
+ * One instance per contributing subsystem. `note` is O(1) and allocation-free
8817
+ * on the steady path; `snapshot` reads without mutating anything.
8818
+ */
8819
+ var FailureCounters = class {
8820
+ maxKeys;
8821
+ maxReasons;
8822
+ counters = /* @__PURE__ */ new Map();
8823
+ refused = 0;
8824
+ constructor(maxKeys = MAX_KEYS, maxReasons = 16) {
8825
+ this.maxKeys = maxKeys;
8826
+ this.maxReasons = maxReasons;
8827
+ }
8828
+ /**
8829
+ * Counters refused because {@link MAX_KEYS} was already held.
8830
+ *
8831
+ * Cumulative for the life of the instance: a bound that bit is a fact about
8832
+ * the deployment, and a surface that hid it would under-report a fleet
8833
+ * precisely when the fleet got large enough to matter.
8834
+ */
8835
+ get keysRefused() {
8836
+ return this.refused;
8837
+ }
8838
+ /** Counters currently held. */
8839
+ get size() {
8840
+ return this.counters.size;
8841
+ }
8842
+ /**
8843
+ * Fold one observation in.
8844
+ *
8845
+ * A non-positive or non-integer `deviceId` is REFUSED rather than bucketed:
8846
+ * see the module docblock — an entry that cannot name its camera is worse
8847
+ * than no entry.
8848
+ */
8849
+ note(observation, nowMs) {
8850
+ if (!Number.isInteger(observation.deviceId) || observation.deviceId <= 0) return;
8851
+ const key = counterKey(observation.deviceId, observation.family, observation.variant);
8852
+ let counter = this.counters.get(key);
8853
+ if (counter === void 0) {
8854
+ if (this.counters.size >= this.maxKeys) {
8855
+ this.refused += 1;
8856
+ return;
8857
+ }
8858
+ counter = {
8859
+ deviceId: observation.deviceId,
8860
+ family: observation.family,
8861
+ variant: observation.variant,
8862
+ sinceMs: nowMs,
8863
+ attempts: 0,
8864
+ succeeded: 0,
8865
+ reasons: /* @__PURE__ */ new Map()
8866
+ };
8867
+ this.counters.set(key, counter);
8868
+ }
8869
+ counter.attempts += 1;
8870
+ if (observation.reason === void 0) {
8871
+ counter.succeeded += 1;
8872
+ return;
8873
+ }
8874
+ const reason = counter.reasons.has(observation.reason) || counter.reasons.size < this.maxReasons ? observation.reason : OVERFLOW_REASON;
8875
+ counter.reasons.set(reason, (counter.reasons.get(reason) ?? 0) + 1);
8876
+ }
8877
+ /** Read every counter. Never mutates — see the module docblock. */
8878
+ snapshot(nowMs) {
8879
+ const out = [];
8880
+ for (const counter of this.counters.values()) out.push({
8881
+ deviceId: counter.deviceId,
8882
+ family: counter.family,
8883
+ ...counter.variant !== void 0 ? { variant: counter.variant } : {},
8884
+ sinceMs: counter.sinceMs,
8885
+ atMs: nowMs,
8886
+ attempts: counter.attempts,
8887
+ succeeded: counter.succeeded,
8888
+ reasons: [...counter.reasons.entries()].map(([reason, count]) => ({
8889
+ reason,
8890
+ count
8891
+ })).toSorted((a, b) => b.count - a.count)
8892
+ });
8893
+ return out;
8894
+ }
8895
+ /** Drop everything (host disposal). */
8896
+ clear() {
8897
+ this.counters.clear();
8898
+ }
8899
+ };
8794
8900
  var MODEL_FORMATS = [
8795
8901
  "onnx",
8796
8902
  "coreml",
@@ -14067,7 +14173,26 @@ var FailureContributionSchema = object({
14067
14173
  /** The loss, partitioned. Sums to `attempts - succeeded`. */
14068
14174
  reasons: array(FailureReasonCountSchema).readonly()
14069
14175
  });
14070
- method(_void(), array(FailureContributionSchema).readonly());
14176
+ var failureContributionCapability = {
14177
+ name: "failure-contribution",
14178
+ scope: "system",
14179
+ mode: "collection",
14180
+ internal: true,
14181
+ methods: {
14182
+ /**
14183
+ * This addon's per-camera failure counters, read live from bounded in-RAM
14184
+ * state it already keeps. Inert: no persistence, no sampling, no timer.
14185
+ *
14186
+ * READING NEVER RESETS. The counters are CUMULATIVE since `sinceMs`, and a
14187
+ * consumer that wants a rate differences two reads. A draining read would
14188
+ * make two operators with the page open each destroy half of the other's
14189
+ * numbers, and `load-contribution` already settled the same question the
14190
+ * same way for `cpuSeconds`.
14191
+ */
14192
+ list: method(_void(), array(FailureContributionSchema).readonly()) },
14193
+ /** In-process only — enumerated through `addons.listCapabilityProviders`. */
14194
+ mount: { kind: "skip" }
14195
+ };
14071
14196
  var LoadContributionSchema = object({
14072
14197
  role: _enum([
14073
14198
  "decode",
@@ -229014,6 +229139,90 @@ function buildInitialStatus(config) {
229014
229139
  };
229015
229140
  }
229016
229141
  //#endregion
229142
+ //#region src/intercom-failure-report.ts
229143
+ /**
229144
+ * Per-camera talk-back counters, published through `failure-contribution`.
229145
+ *
229146
+ * ## The number that was never divided
229147
+ *
229148
+ * The rate-mismatch drop had ONE warn line and no counter, so "how much
229149
+ * talk-back is this camera losing" was answerable only by grepping Loki and
229150
+ * hand-correlating timestamps — the exact cost `failure-contribution` exists to
229151
+ * remove. And a bare drop count could not have answered it either: 40 drops out
229152
+ * of 40 pushes and 40 out of 40 000 are opposite findings that produce
229153
+ * identical log volume.
229154
+ *
229155
+ * So EVERY `pushTalkAudio` outcome on a live talk session is noted from the one
229156
+ * place that decides it — the accepted ones too. {@link FailureCounters}
229157
+ * carries `attempts` as the denominator and `succeeded` as the numerator, and
229158
+ * the reasons partition the rest. A success counted somewhere else would drift
229159
+ * from the failures and turn the ratio into fiction.
229160
+ *
229161
+ * ## `variant` is the wire codec, and it is honest
229162
+ *
229163
+ * `failure-contribution` keeps `variant` for a second dimension WITHIN a
229164
+ * family, and here the useful one is the format the caller pushed: an operator
229165
+ * asking "why is 617 silent" needs to know whether HomeKit's Opus or Alexa's
229166
+ * raw PCM is the half that is failing. The provider is handed that value on
229167
+ * every call, so it is reported rather than guessed — absent, never invented.
229168
+ *
229169
+ * ## Process-wide, because a counter is
229170
+ *
229171
+ * One addon is one process (D2) and every camera this addon owns lives in it,
229172
+ * so the instance is module-scoped: the cameras note into it and the addon
229173
+ * registers ONE `failure-contribution` provider that reads it. `sinceMs` is the
229174
+ * incarnation marker — a respawned runner restarts from zero and says so.
229175
+ * Reading NEVER drains.
229176
+ */
229177
+ /** One `pushTalkAudio` call against an open talk session. */
229178
+ var FAMILY_INTERCOM_TALK = "intercom-talk";
229179
+ /** The push arrived with a sequence number at or below the last accepted one. */
229180
+ var REASON_TALK_OUT_OF_ORDER = "out-of-order";
229181
+ /** The payload decoded to zero bytes. */
229182
+ var REASON_TALK_EMPTY = "empty-frame";
229183
+ /** More than one channel — every camera here is mono-only. */
229184
+ var REASON_TALK_NOT_MONO = "not-mono";
229185
+ /** `s16le` push with no `sampleRate`; the rate is ambiguous, not assumed. */
229186
+ var REASON_TALK_NO_SAMPLE_RATE = "missing-sample-rate";
229187
+ /** The wire codec has no path onto this camera's talk channel. */
229188
+ var REASON_TALK_CODEC_UNSUPPORTED = "codec-unsupported";
229189
+ /** The Opus decode path threw or could not open its session. */
229190
+ var REASON_TALK_OPUS_FAILED = "opus-decode-failed";
229191
+ /**
229192
+ * The addon's talk-back counters. One instance per process; the export at the
229193
+ * bottom of this file IS that instance.
229194
+ */
229195
+ var IntercomFailureReport = class {
229196
+ now;
229197
+ counters;
229198
+ constructor(now = Date.now, counters = new FailureCounters()) {
229199
+ this.now = now;
229200
+ this.counters = counters;
229201
+ }
229202
+ /**
229203
+ * Note one `pushTalkAudio` outcome. `reason` absent = the frame reached the
229204
+ * camera's talk channel.
229205
+ */
229206
+ noteTalkFrame(deviceId, wireCodec, reason) {
229207
+ this.counters.note({
229208
+ deviceId,
229209
+ family: FAMILY_INTERCOM_TALK,
229210
+ variant: wireCodec,
229211
+ ...reason !== void 0 ? { reason } : {}
229212
+ }, this.now());
229213
+ }
229214
+ /** The `failure-contribution` provider's payload. Reads, never resets. */
229215
+ list() {
229216
+ return this.counters.snapshot(this.now());
229217
+ }
229218
+ /** Addon disposal. */
229219
+ clear() {
229220
+ this.counters.clear();
229221
+ }
229222
+ };
229223
+ /** The process-wide instance every camera in this addon notes into. */
229224
+ var intercomFailureReport = new IntercomFailureReport();
229225
+ //#endregion
229017
229226
  //#region src/log-channels.ts
229018
229227
  /**
229019
229228
  * The diagnostic log CHANNELS `provider-reolink` declares.
@@ -230847,6 +231056,15 @@ function encodeImaAdpcm(pcm, blockSizeBytes) {
230847
231056
  var DEFAULT_BACKLOG_MS = 120;
230848
231057
  var MAX_BACKLOG_MS = 5e3;
230849
231058
  var MIN_BACKLOG_MS = 20;
231059
+ /**
231060
+ * The ONE place the operator's backlog request becomes the enforced bound.
231061
+ * `start()` sizes the byte window from it and `ability.maxBacklogMs` reports
231062
+ * it — a second clamp would let the number a caller reads drift from the
231063
+ * number the buffer honours.
231064
+ */
231065
+ function clampBacklogMs(requested) {
231066
+ return Math.max(MIN_BACKLOG_MS, Math.min(MAX_BACKLOG_MS, requested ?? DEFAULT_BACKLOG_MS));
231067
+ }
230850
231068
  var DEFAULT_BLOCKS_PER_PAYLOAD = 1;
230851
231069
  var DEFAULT_GAIN = 1;
230852
231070
  var MIN_GAIN = .1;
@@ -230874,6 +231092,36 @@ var ReolinkIntercomSession = class {
230874
231092
  if (!this.session) throw new Error("ReolinkIntercomSession.sampleRate read before start()");
230875
231093
  return this.session.info.audioConfig.sampleRate;
230876
231094
  }
231095
+ /**
231096
+ * Effective PCM backlog bound, in ms — the operator's value clamped to
231097
+ * [{@link MIN_BACKLOG_MS}, {@link MAX_BACKLOG_MS}], i.e. what the session
231098
+ * is actually enforcing rather than what it was asked for. Readable before
231099
+ * `start()` because the clamp is pure.
231100
+ */
231101
+ get backlogMs() {
231102
+ return clampBacklogMs(this.opts.maxBacklogMs);
231103
+ }
231104
+ /**
231105
+ * The firmware's talk-back format — `IntercomStatus.ability`. Throws before
231106
+ * `start()`, like `sampleRate`, because the rate is the camera's answer and
231107
+ * not a default.
231108
+ *
231109
+ * The field was declared, mirrored into runtime state and written by NOBODY
231110
+ * while these values were in hand and only reaching a log line (D281).
231111
+ *
231112
+ * `duplex` is the one judgement call: the Baichuan talk channel is a single
231113
+ * dedicated session and this provider enforces one at a time per camera, so
231114
+ * `half` is reported. `full` would be the dangerous direction — a consumer
231115
+ * that believes it may listen while speaking takes no lock.
231116
+ */
231117
+ get ability() {
231118
+ return {
231119
+ codecs: ["adpcm-ima"],
231120
+ sampleRate: this.sampleRate,
231121
+ duplex: "half",
231122
+ maxBacklogMs: this.backlogMs
231123
+ };
231124
+ }
230877
231125
  async start() {
230878
231126
  if (this.session) return;
230879
231127
  this.outputGain = clampGain(this.opts.outputGain);
@@ -230900,7 +231148,7 @@ var ReolinkIntercomSession = class {
230900
231148
  } catch {}
230901
231149
  throw new Error(`Reolink talk session reported invalid sampleRate: ${sampleRate}`);
230902
231150
  }
230903
- const wantedBacklogMs = Math.max(MIN_BACKLOG_MS, Math.min(MAX_BACKLOG_MS, this.opts.maxBacklogMs ?? DEFAULT_BACKLOG_MS));
231151
+ const wantedBacklogMs = this.backlogMs;
230904
231152
  this.maxBacklogBytes = Math.max(this.bytesPerBlock, Math.floor(wantedBacklogMs / 1e3 * sampleRate * 2));
230905
231153
  this.session = session;
230906
231154
  this.pcmBuffer = Buffer.alloc(0);
@@ -231028,6 +231276,14 @@ var IntercomOrchestrator = class {
231028
231276
  return this.session !== null && !this.session.closed;
231029
231277
  }
231030
231278
  /**
231279
+ * The live talk session's firmware ability, or `null` when no session is
231280
+ * open. Read by the camera at `startSession` so the WebRTC path writes
231281
+ * `IntercomStatus.ability` from the same source the raw-PCM path does.
231282
+ */
231283
+ get ability() {
231284
+ return this.session === null || this.session.closed ? null : this.session.talkSession.ability;
231285
+ }
231286
+ /**
231031
231287
  * Open a fresh WebRTC peer + audio-codec decode session + Reolink
231032
231288
  * talk session, wire them, return the SDP offer. Throws (and tears
231033
231289
  * down everything it had spun up) on any failure — the cap router
@@ -231245,6 +231501,193 @@ function errMsg$1(err) {
231245
231501
  return err instanceof Error ? err.message : String(err);
231246
231502
  }
231247
231503
  //#endregion
231504
+ //#region src/talk-pcm-transcoder.ts
231505
+ /** libav codec name of a linear little-endian 16-bit PCM decode session. */
231506
+ var TALK_PCM_CODEC = "pcm_s16le";
231507
+ /** The transcoder was already closed — the talk session ended under the push. */
231508
+ var REASON_PCM_CLOSED = "pcm-transcoder-closed";
231509
+ /** The caller's declared source rate is not a usable positive integer. */
231510
+ var REASON_PCM_BAD_RATE = "pcm-bad-source-rate";
231511
+ /** The frame is empty or holds half a sample — malformed, not convertible. */
231512
+ var REASON_PCM_ODD_BYTES = "pcm-odd-bytes";
231513
+ /** No `audio-codec` provider is mounted on this cluster. */
231514
+ var REASON_PCM_NO_CODEC_CAP = "pcm-audio-codec-unavailable";
231515
+ /** The codec cap refused to open a linear-PCM decode session. */
231516
+ var REASON_PCM_SESSION_OPEN_FAILED = "pcm-resample-session-failed";
231517
+ /** The push/pull round-trip through the codec cap threw. */
231518
+ var REASON_PCM_CONVERT_FAILED = "pcm-resample-failed";
231519
+ function errMessage(err) {
231520
+ return err instanceof Error ? err.message : String(err);
231521
+ }
231522
+ var TalkPcmTranscoder = class {
231523
+ opts;
231524
+ active = null;
231525
+ closed = false;
231526
+ constructor(opts) {
231527
+ this.opts = opts;
231528
+ }
231529
+ /** The open codec session, or `null` before the first converted frame. */
231530
+ get sessionId() {
231531
+ return this.active?.sessionId ?? null;
231532
+ }
231533
+ /**
231534
+ * Convert one frame to the camera's rate and hand every produced chunk to
231535
+ * `feed`.
231536
+ *
231537
+ * Returns `null` when the frame was converted and fed, or the REASON string
231538
+ * it was refused for — already logged, with nothing fed.
231539
+ */
231540
+ async feedResampled(frame) {
231541
+ if (this.closed) {
231542
+ this.refuse(REASON_PCM_CLOSED, {});
231543
+ return REASON_PCM_CLOSED;
231544
+ }
231545
+ const sourceSampleRate = frame.sourceSampleRate;
231546
+ if (!Number.isInteger(sourceSampleRate) || sourceSampleRate <= 0) {
231547
+ this.refuse(REASON_PCM_BAD_RATE, { sourceSampleRate });
231548
+ return REASON_PCM_BAD_RATE;
231549
+ }
231550
+ if (frame.pcm.length === 0 || (frame.pcm.length & 1) !== 0) {
231551
+ this.refuse(REASON_PCM_ODD_BYTES, { bytes: frame.pcm.length });
231552
+ return REASON_PCM_ODD_BYTES;
231553
+ }
231554
+ let api;
231555
+ try {
231556
+ api = this.opts.resolveAudioCodec();
231557
+ } catch (err) {
231558
+ this.refuse(REASON_PCM_NO_CODEC_CAP, { error: errMessage(err) });
231559
+ return REASON_PCM_NO_CODEC_CAP;
231560
+ }
231561
+ if (this.active !== null && this.active.sourceSampleRate !== sourceSampleRate) {
231562
+ const previous = this.active.sourceSampleRate;
231563
+ await this.disposeSession(api, "source-rate-changed");
231564
+ this.opts.logger.info("intercom: pcm resample source rate changed — session recreated", {
231565
+ tags: { deviceId: this.opts.deviceId },
231566
+ meta: {
231567
+ previousSourceSampleRate: previous,
231568
+ sourceSampleRate
231569
+ }
231570
+ });
231571
+ }
231572
+ if (this.active === null) try {
231573
+ const created = await api.createDecodeSession({
231574
+ codec: TALK_PCM_CODEC,
231575
+ sourceSampleRate,
231576
+ sourceChannels: 1,
231577
+ targetSampleRate: this.opts.targetSampleRate,
231578
+ targetChannels: 1,
231579
+ targetFormat: "s16le",
231580
+ tag: this.opts.tag
231581
+ });
231582
+ this.active = {
231583
+ sessionId: created.sessionId,
231584
+ nodeId: created.nodeId,
231585
+ sourceSampleRate
231586
+ };
231587
+ this.opts.logger.info("intercom: pcm resample session opened", {
231588
+ tags: { deviceId: this.opts.deviceId },
231589
+ meta: {
231590
+ codec: TALK_PCM_CODEC,
231591
+ codecSessionId: created.sessionId,
231592
+ codecNodeId: created.nodeId,
231593
+ sourceSampleRate,
231594
+ targetSampleRate: this.opts.targetSampleRate,
231595
+ tag: this.opts.tag
231596
+ }
231597
+ });
231598
+ } catch (err) {
231599
+ this.refuse(REASON_PCM_SESSION_OPEN_FAILED, {
231600
+ sourceSampleRate,
231601
+ targetSampleRate: this.opts.targetSampleRate,
231602
+ error: errMessage(err)
231603
+ });
231604
+ return REASON_PCM_SESSION_OPEN_FAILED;
231605
+ }
231606
+ const session = this.active;
231607
+ try {
231608
+ await api.pushEncodedFrame({
231609
+ sessionId: session.sessionId,
231610
+ nodeId: session.nodeId,
231611
+ data: new Uint8Array(frame.pcm.buffer, frame.pcm.byteOffset, frame.pcm.byteLength)
231612
+ });
231613
+ const chunks = await api.pullPcm({
231614
+ sessionId: session.sessionId,
231615
+ nodeId: session.nodeId,
231616
+ maxCount: 8
231617
+ });
231618
+ for (const chunk of chunks) {
231619
+ const out = Buffer.from(chunk.data.buffer, chunk.data.byteOffset, chunk.data.byteLength);
231620
+ if (out.length > 0) this.opts.feed(out);
231621
+ }
231622
+ return null;
231623
+ } catch (err) {
231624
+ this.refuse(REASON_PCM_CONVERT_FAILED, {
231625
+ codecSessionId: session.sessionId,
231626
+ sourceSampleRate,
231627
+ targetSampleRate: this.opts.targetSampleRate,
231628
+ error: errMessage(err)
231629
+ });
231630
+ await this.disposeSession(api, "convert-failed");
231631
+ return REASON_PCM_CONVERT_FAILED;
231632
+ }
231633
+ }
231634
+ /**
231635
+ * Close the codec session. Idempotent, and called from the provider's
231636
+ * `endTalkSession` so the session dies with the talk session it served.
231637
+ */
231638
+ async close() {
231639
+ this.closed = true;
231640
+ if (this.active === null) return;
231641
+ let api;
231642
+ try {
231643
+ api = this.opts.resolveAudioCodec();
231644
+ } catch (err) {
231645
+ this.opts.logger.debug("intercom: pcm resample close skipped — audio-codec gone", {
231646
+ tags: { deviceId: this.opts.deviceId },
231647
+ meta: {
231648
+ codecSessionId: this.active.sessionId,
231649
+ error: errMessage(err)
231650
+ }
231651
+ });
231652
+ this.active = null;
231653
+ return;
231654
+ }
231655
+ await this.disposeSession(api, "talk-session-ended");
231656
+ }
231657
+ /** Close + forget the current session. Never throws. */
231658
+ async disposeSession(api, why) {
231659
+ const session = this.active;
231660
+ this.active = null;
231661
+ if (session === null) return;
231662
+ try {
231663
+ await api.closeSession({
231664
+ sessionId: session.sessionId,
231665
+ nodeId: session.nodeId
231666
+ });
231667
+ } catch (err) {
231668
+ this.opts.logger.debug("intercom: pcm resample closeSession error (continuing)", {
231669
+ tags: { deviceId: this.opts.deviceId },
231670
+ meta: {
231671
+ codecSessionId: session.sessionId,
231672
+ why,
231673
+ error: errMessage(err)
231674
+ }
231675
+ });
231676
+ }
231677
+ }
231678
+ /** One warn per refused frame. A branch that drops work says so. */
231679
+ refuse(reason, meta) {
231680
+ this.opts.logger.warn("intercom: pcm talk frame refused — not converted, nothing fed", {
231681
+ tags: { deviceId: this.opts.deviceId },
231682
+ meta: {
231683
+ reason,
231684
+ targetSampleRate: this.opts.targetSampleRate,
231685
+ ...meta
231686
+ }
231687
+ });
231688
+ }
231689
+ };
231690
+ //#endregion
231248
231691
  //#region src/intercom-webrtc-peer.ts
231249
231692
  var _werift;
231250
231693
  /**
@@ -234514,13 +234957,20 @@ var ReolinkCamera = class ReolinkCamera extends BaseDevice {
234514
234957
  * Called at the four points that open or close a session — and seeded at
234515
234958
  * registration, so the slice says `talking: false` from boot rather than
234516
234959
  * only after the first session.
234960
+ *
234961
+ * `ability` is STICKY: it is the firmware's negotiated format, learned when a
234962
+ * session opens and still true after it closes, so a caller reading between
234963
+ * sessions gets the last probed value rather than `null`. Passing it is what
234964
+ * changed — it used to be copied forward from `previous` at every one of the
234965
+ * four call sites and written by nobody, while `session.sampleRate` was in
234966
+ * hand and only reaching a log line (D281).
234517
234967
  */
234518
- publishIntercomState(talking) {
234968
+ publishIntercomState(talking, ability) {
234519
234969
  const previous = this.getCapSlice(intercomCapability);
234520
234970
  this.setCapSlice(intercomCapability, {
234521
234971
  talking,
234522
234972
  lastSessionAt: talking ? Date.now() : previous?.lastSessionAt ?? null,
234523
- ability: previous?.ability ?? null
234973
+ ability: ability ?? previous?.ability ?? null
234524
234974
  });
234525
234975
  }
234526
234976
  registerIntercomIfSupported() {
@@ -234559,7 +235009,7 @@ var ReolinkCamera = class ReolinkCamera extends BaseDevice {
234559
235009
  });
234560
235010
  try {
234561
235011
  const opened = await this.intercomOrchestrator.start();
234562
- this.publishIntercomState(true);
235012
+ this.publishIntercomState(true, this.intercomOrchestrator.ability ?? void 0);
234563
235013
  return opened;
234564
235014
  } catch (err) {
234565
235015
  this.publishIntercomState(false);
@@ -234581,8 +235031,10 @@ var ReolinkCamera = class ReolinkCamera extends BaseDevice {
234581
235031
  if (deviceId !== this.id) throw new Error(`ReolinkCamera: intercom deviceId mismatch, expected ${this.id}, got ${deviceId}`);
234582
235032
  if (this.disabled) throw new Error("Reolink intercom: device is disabled — re-enable it before opening a talk session");
234583
235033
  if (this.intercomRawSession) {
234584
- await this.intercomRawSession.session.stop().catch(() => {});
235034
+ const previous = this.intercomRawSession;
234585
235035
  this.intercomRawSession = null;
235036
+ await previous.pcmTranscode.close();
235037
+ await previous.session.stop().catch(() => {});
234586
235038
  }
234587
235039
  const api = await this.ensureApi();
234588
235040
  if (this.isBattery) await this.wakeForIntercom(api);
@@ -234604,9 +235056,17 @@ var ReolinkCamera = class ReolinkCamera extends BaseDevice {
234604
235056
  id,
234605
235057
  session,
234606
235058
  lastSequenceNumber: -1,
234607
- opusDecode: null
235059
+ opusDecode: null,
235060
+ pcmTranscode: new TalkPcmTranscoder({
235061
+ deviceId: this.id,
235062
+ logger: this.ctx.logger,
235063
+ resolveAudioCodec: () => this.resolveAudioCodecApi(),
235064
+ targetSampleRate: session.sampleRate,
235065
+ tag: `reolink-intercom-pcm:${this.id}:${id}`,
235066
+ feed: (pcm) => session.feedPcm(pcm)
235067
+ })
234608
235068
  };
234609
- this.publishIntercomState(true);
235069
+ this.publishIntercomState(true, session.ability);
234610
235070
  this.ctx.logger.info("intercom talk session opened", {
234611
235071
  tags: { deviceId: this.id },
234612
235072
  meta: {
@@ -234618,13 +235078,24 @@ var ReolinkCamera = class ReolinkCamera extends BaseDevice {
234618
235078
  },
234619
235079
  pushTalkAudio: async ({ deviceId, audioBase64, codec, sampleRate, channels, sequenceNumber }) => {
234620
235080
  if (deviceId !== this.id) return { accepted: false };
235081
+ const wireCodec = codec ?? "s16le";
235082
+ const note = (reason) => {
235083
+ intercomFailureReport.noteTalkFrame(this.id, wireCodec, reason);
235084
+ };
234621
235085
  const active = this.intercomRawSession;
234622
235086
  if (!active || !active.session.isOpen) return { accepted: false };
234623
- if (sequenceNumber <= active.lastSequenceNumber) return { accepted: false };
235087
+ if (sequenceNumber <= active.lastSequenceNumber) {
235088
+ note(REASON_TALK_OUT_OF_ORDER);
235089
+ return { accepted: false };
235090
+ }
234624
235091
  const buf = Buffer.from(audioBase64, "base64");
234625
- if (buf.length === 0) return { accepted: false };
235092
+ if (buf.length === 0) {
235093
+ note(REASON_TALK_EMPTY);
235094
+ return { accepted: false };
235095
+ }
234626
235096
  const ch = channels ?? 1;
234627
235097
  if (ch !== 1) {
235098
+ note(REASON_TALK_NOT_MONO);
234628
235099
  this.ctx.logger.warn("intercom: dropping non-mono talk frame (Reolink is mono-only)", {
234629
235100
  tags: { deviceId: this.id },
234630
235101
  meta: {
@@ -234634,8 +235105,8 @@ var ReolinkCamera = class ReolinkCamera extends BaseDevice {
234634
235105
  });
234635
235106
  return { accepted: false };
234636
235107
  }
234637
- const wireCodec = codec ?? "s16le";
234638
235108
  if (wireCodec === "g711ulaw" || wireCodec === "g711alaw") {
235109
+ note(REASON_TALK_CODEC_UNSUPPORTED);
234639
235110
  this.ctx.logger.warn("intercom: g711 passthrough not supported on Reolink (camera codec is ADPCM) — dropping frame", {
234640
235111
  tags: { deviceId: this.id },
234641
235112
  meta: { wireCodec }
@@ -234644,24 +235115,29 @@ var ReolinkCamera = class ReolinkCamera extends BaseDevice {
234644
235115
  }
234645
235116
  if (wireCodec === "s16le") {
234646
235117
  if (!sampleRate) {
235118
+ note(REASON_TALK_NO_SAMPLE_RATE);
234647
235119
  this.ctx.logger.warn("intercom: s16le push with no sampleRate — dropping (rate is ambiguous)", { tags: { deviceId: this.id } });
234648
235120
  return { accepted: false };
234649
235121
  }
234650
235122
  if (sampleRate !== active.session.sampleRate) {
234651
- this.ctx.logger.warn("intercom: s16le sampleRate mismatch (PCM-only resample not implemented) — dropping", {
234652
- tags: { deviceId: this.id },
234653
- meta: {
234654
- wireRate: sampleRate,
234655
- cameraRate: active.session.sampleRate
234656
- }
235123
+ const refusal = await active.pcmTranscode.feedResampled({
235124
+ pcm: buf,
235125
+ sourceSampleRate: sampleRate
234657
235126
  });
234658
- return { accepted: false };
235127
+ if (refusal !== null) {
235128
+ note(refusal);
235129
+ return { accepted: false };
235130
+ }
235131
+ active.lastSequenceNumber = sequenceNumber;
235132
+ note();
235133
+ return { accepted: true };
234659
235134
  }
234660
235135
  active.lastSequenceNumber = sequenceNumber;
234661
235136
  active.session.feedPcm(buf);
235137
+ note();
234662
235138
  return { accepted: true };
234663
235139
  }
234664
- if (wireCodec === "opus") {
235140
+ if (wireCodec === "opus") try {
234665
235141
  if (!active.opusDecode) {
234666
235142
  const created = await this.resolveAudioCodecApi().createDecodeSession({
234667
235143
  codec: "opus",
@@ -234704,8 +235180,17 @@ var ReolinkCamera = class ReolinkCamera extends BaseDevice {
234704
235180
  const pcmBuf = Buffer.from(chunk.data.buffer, chunk.data.byteOffset, chunk.data.byteLength);
234705
235181
  if (pcmBuf.length > 0) active.session.feedPcm(pcmBuf);
234706
235182
  }
235183
+ note();
234707
235184
  return { accepted: true };
235185
+ } catch (err) {
235186
+ note(REASON_TALK_OPUS_FAILED);
235187
+ throw err;
234708
235188
  }
235189
+ note(REASON_TALK_CODEC_UNSUPPORTED);
235190
+ this.ctx.logger.warn("intercom: no path onto the talk channel for this wire codec", {
235191
+ tags: { deviceId: this.id },
235192
+ meta: { wireCodec }
235193
+ });
234709
235194
  return { accepted: false };
234710
235195
  },
234711
235196
  endTalkSession: async ({ deviceId }) => {
@@ -234713,6 +235198,7 @@ var ReolinkCamera = class ReolinkCamera extends BaseDevice {
234713
235198
  const active = this.intercomRawSession;
234714
235199
  if (!active) return;
234715
235200
  this.intercomRawSession = null;
235201
+ await active.pcmTranscode.close();
234716
235202
  if (active.opusDecode) await this.resolveAudioCodecApi().closeSession({
234717
235203
  sessionId: active.opusDecode.sessionId,
234718
235204
  nodeId: active.opusDecode.nodeId
@@ -241586,6 +242072,10 @@ var ReolinkProviderAddon = class extends BaseDeviceProvider {
241586
242072
  capability: logChannelsCapability,
241587
242073
  provider: this.logChannels
241588
242074
  });
242075
+ regs.push({
242076
+ capability: failureContributionCapability,
242077
+ provider: { list: () => intercomFailureReport.list() }
242078
+ });
241589
242079
  this.subscribe({ category: EventCategory.StreamBrokerOnRequestStreamSourceRefresh }, (event) => {
241590
242080
  const data = event.data;
241591
242081
  const deviceId = typeof data.deviceId === "number" ? data.deviceId : null;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@camstack/addon-provider-reolink",
3
- "version": "1.2.61",
3
+ "version": "1.2.62",
4
4
  "description": "Reolink camera device provider addon for CamStack — native Baichuan protocol",
5
5
  "keywords": [
6
6
  "camstack",
@@ -62,6 +62,9 @@
62
62
  },
63
63
  {
64
64
  "name": "log-channels"
65
+ },
66
+ {
67
+ "name": "failure-contribution"
65
68
  }
66
69
  ]
67
70
  }