@camstack/addon-provider-reolink 1.2.61 → 1.2.64

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 +572 -23
  2. package/dist/addon.mjs +572 -23
  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",
@@ -14380,6 +14505,50 @@ var NodeProcessSchema = object({
14380
14505
  /** Wall-clock uptime (seconds). Parsed from `ps etime`. */
14381
14506
  uptimeSec: number()
14382
14507
  });
14508
+ /**
14509
+ * One retained container-memory reading.
14510
+ *
14511
+ * `atMs` is the timestamp of the PROCESS snapshot taken in the same tick, not
14512
+ * a second clock: that is what makes "processes sum to X, container says Y"
14513
+ * subtractable per point rather than an eyeballed comparison of two series
14514
+ * sampled at different instants.
14515
+ *
14516
+ * A reduced window keeps the sample with the LARGEST `currentBytes` in each
14517
+ * bucket, WHOLE. Taking a per-field maximum would synthesise a row whose parts
14518
+ * never coexisted, and a mean would smear away the peak this exists to find.
14519
+ */
14520
+ var ContainerMemoryPointSchema = object({
14521
+ /** Which hierarchy answered, so a reading is never ambiguous. */
14522
+ source: _enum(["cgroup-v2", "cgroup-v1"]),
14523
+ /** `memory.current` (v2) / `memory.usage_in_bytes` (v1). Always known. */
14524
+ currentBytes: number(),
14525
+ /** The cgroup's ceiling. `null` = NO LIMIT — never a sentinel, never zero. */
14526
+ limitBytes: number().nullable(),
14527
+ /** Anonymous pages: the closest thing to "what the processes allocated". */
14528
+ anonBytes: number().nullable(),
14529
+ /** Page cache. Charged to the cgroup, owned by no process. */
14530
+ fileBytes: number().nullable(),
14531
+ /**
14532
+ * Shared memory — and the field that explained the largest single surprise.
14533
+ * The i915 driver backs GPU buffers with shmem, so an inference pool or a
14534
+ * hardware-decode session holding DRM objects is charged HERE and appears
14535
+ * nowhere in a `ps` scan.
14536
+ */
14537
+ shmemBytes: number().nullable(),
14538
+ /** Kernel slab charged to this cgroup. `null` on v1, which never publishes it. */
14539
+ slabBytes: number().nullable(),
14540
+ /**
14541
+ * Shrinkable i915 GEM object bytes, from debugfs.
14542
+ *
14543
+ * **Host-wide across every DRM client, NOT cgroup-scoped.** It is not a
14544
+ * component of `currentBytes` and must not be subtracted from it; it says
14545
+ * what put the shmem there, where `shmemBytes` only says how much.
14546
+ *
14547
+ * `null` wherever debugfs is not mounted — which is inside every camstack
14548
+ * container today — and on any node with no Intel GPU.
14549
+ */
14550
+ gpuShmemBytes: number().nullable()
14551
+ }).extend({ atMs: number() });
14383
14552
  var DumpHeapSnapshotInputSchema = object({
14384
14553
  /** The addon whose runner should dump a heap snapshot. */
14385
14554
  addonId: string() });
@@ -14443,6 +14612,21 @@ var NodeLoadSeriesSchema = object({
14443
14612
  /** One entry per function seen in the window, heaviest-first. */
14444
14613
  series: array(LoadFunctionSeriesSchema).readonly(),
14445
14614
  /**
14615
+ * The CONTAINER's memory over the same window, oldest-first.
14616
+ *
14617
+ * Sits next to `series` rather than in a method of its own because the whole
14618
+ * question is a subtraction: the per-process rows in `series` sum to one
14619
+ * number and this one is another, and an operator who has to issue two calls
14620
+ * to compare them will compare two different instants. Same reader, same
14621
+ * `sinceMs`, same `bucketMs`, same timestamps.
14622
+ *
14623
+ * **EMPTY means ABSENT, never zero.** A node with no cgroup — a developer
14624
+ * Mac, a bare-metal host, a container with the hierarchy hidden — reports no
14625
+ * points at all. A zero here would be indistinguishable from a healthy
14626
+ * container and is precisely the lie this field exists to avoid.
14627
+ */
14628
+ containerMemory: array(ContainerMemoryPointSchema).readonly(),
14629
+ /**
14446
14630
  * Width of one returned bucket, in ms. Equals the sampling cadence when no
14447
14631
  * reduction was needed — so a caller can always say what one point covers
14448
14632
  * without having to know whether it was reduced.
@@ -28329,10 +28513,10 @@ var rebootCapability = {
28329
28513
  * recording config. NOTE on events (source of truth, R5/C3): this cap carries
28330
28514
  * NO event surface — `getPlaybackManifest` returns playlist URLs only. Timeline
28331
28515
  * events (motion/object/audio) come from `pipelineAnalytics` (durable SQLite
28332
- * rows); the recorder's internal EventMap markers are ephemeral in-RAM
28333
- * annotations that are not exposed here and must not be treated as an event
28334
- * feed. Event<->footage joins are by time, padded with the shared `EVENT_PAD_MS`
28335
- * (`interfaces/recording-config.ts`).
28516
+ * rows) and are the ONLY event surface the recorder has none. The in-RAM
28517
+ * playback markers it used to build were deleted on 2026-08-29 because nothing
28518
+ * ever read them. Event<->footage joins are by time, padded with the shared
28519
+ * `EVENT_PAD_MS` (`interfaces/recording-config.ts`).
28336
28520
  */
28337
28521
  var RecordingStatusSchema = object({
28338
28522
  deviceId: number(),
@@ -229034,6 +229218,90 @@ function buildInitialStatus(config) {
229034
229218
  };
229035
229219
  }
229036
229220
  //#endregion
229221
+ //#region src/intercom-failure-report.ts
229222
+ /**
229223
+ * Per-camera talk-back counters, published through `failure-contribution`.
229224
+ *
229225
+ * ## The number that was never divided
229226
+ *
229227
+ * The rate-mismatch drop had ONE warn line and no counter, so "how much
229228
+ * talk-back is this camera losing" was answerable only by grepping Loki and
229229
+ * hand-correlating timestamps — the exact cost `failure-contribution` exists to
229230
+ * remove. And a bare drop count could not have answered it either: 40 drops out
229231
+ * of 40 pushes and 40 out of 40 000 are opposite findings that produce
229232
+ * identical log volume.
229233
+ *
229234
+ * So EVERY `pushTalkAudio` outcome on a live talk session is noted from the one
229235
+ * place that decides it — the accepted ones too. {@link FailureCounters}
229236
+ * carries `attempts` as the denominator and `succeeded` as the numerator, and
229237
+ * the reasons partition the rest. A success counted somewhere else would drift
229238
+ * from the failures and turn the ratio into fiction.
229239
+ *
229240
+ * ## `variant` is the wire codec, and it is honest
229241
+ *
229242
+ * `failure-contribution` keeps `variant` for a second dimension WITHIN a
229243
+ * family, and here the useful one is the format the caller pushed: an operator
229244
+ * asking "why is 617 silent" needs to know whether HomeKit's Opus or Alexa's
229245
+ * raw PCM is the half that is failing. The provider is handed that value on
229246
+ * every call, so it is reported rather than guessed — absent, never invented.
229247
+ *
229248
+ * ## Process-wide, because a counter is
229249
+ *
229250
+ * One addon is one process (D2) and every camera this addon owns lives in it,
229251
+ * so the instance is module-scoped: the cameras note into it and the addon
229252
+ * registers ONE `failure-contribution` provider that reads it. `sinceMs` is the
229253
+ * incarnation marker — a respawned runner restarts from zero and says so.
229254
+ * Reading NEVER drains.
229255
+ */
229256
+ /** One `pushTalkAudio` call against an open talk session. */
229257
+ var FAMILY_INTERCOM_TALK = "intercom-talk";
229258
+ /** The push arrived with a sequence number at or below the last accepted one. */
229259
+ var REASON_TALK_OUT_OF_ORDER = "out-of-order";
229260
+ /** The payload decoded to zero bytes. */
229261
+ var REASON_TALK_EMPTY = "empty-frame";
229262
+ /** More than one channel — every camera here is mono-only. */
229263
+ var REASON_TALK_NOT_MONO = "not-mono";
229264
+ /** `s16le` push with no `sampleRate`; the rate is ambiguous, not assumed. */
229265
+ var REASON_TALK_NO_SAMPLE_RATE = "missing-sample-rate";
229266
+ /** The wire codec has no path onto this camera's talk channel. */
229267
+ var REASON_TALK_CODEC_UNSUPPORTED = "codec-unsupported";
229268
+ /** The Opus decode path threw or could not open its session. */
229269
+ var REASON_TALK_OPUS_FAILED = "opus-decode-failed";
229270
+ /**
229271
+ * The addon's talk-back counters. One instance per process; the export at the
229272
+ * bottom of this file IS that instance.
229273
+ */
229274
+ var IntercomFailureReport = class {
229275
+ now;
229276
+ counters;
229277
+ constructor(now = Date.now, counters = new FailureCounters()) {
229278
+ this.now = now;
229279
+ this.counters = counters;
229280
+ }
229281
+ /**
229282
+ * Note one `pushTalkAudio` outcome. `reason` absent = the frame reached the
229283
+ * camera's talk channel.
229284
+ */
229285
+ noteTalkFrame(deviceId, wireCodec, reason) {
229286
+ this.counters.note({
229287
+ deviceId,
229288
+ family: FAMILY_INTERCOM_TALK,
229289
+ variant: wireCodec,
229290
+ ...reason !== void 0 ? { reason } : {}
229291
+ }, this.now());
229292
+ }
229293
+ /** The `failure-contribution` provider's payload. Reads, never resets. */
229294
+ list() {
229295
+ return this.counters.snapshot(this.now());
229296
+ }
229297
+ /** Addon disposal. */
229298
+ clear() {
229299
+ this.counters.clear();
229300
+ }
229301
+ };
229302
+ /** The process-wide instance every camera in this addon notes into. */
229303
+ var intercomFailureReport = new IntercomFailureReport();
229304
+ //#endregion
229037
229305
  //#region src/log-channels.ts
229038
229306
  /**
229039
229307
  * The diagnostic log CHANNELS `provider-reolink` declares.
@@ -230867,6 +231135,15 @@ function encodeImaAdpcm(pcm, blockSizeBytes) {
230867
231135
  var DEFAULT_BACKLOG_MS = 120;
230868
231136
  var MAX_BACKLOG_MS = 5e3;
230869
231137
  var MIN_BACKLOG_MS = 20;
231138
+ /**
231139
+ * The ONE place the operator's backlog request becomes the enforced bound.
231140
+ * `start()` sizes the byte window from it and `ability.maxBacklogMs` reports
231141
+ * it — a second clamp would let the number a caller reads drift from the
231142
+ * number the buffer honours.
231143
+ */
231144
+ function clampBacklogMs(requested) {
231145
+ return Math.max(MIN_BACKLOG_MS, Math.min(MAX_BACKLOG_MS, requested ?? DEFAULT_BACKLOG_MS));
231146
+ }
230870
231147
  var DEFAULT_BLOCKS_PER_PAYLOAD = 1;
230871
231148
  var DEFAULT_GAIN = 1;
230872
231149
  var MIN_GAIN = .1;
@@ -230894,6 +231171,36 @@ var ReolinkIntercomSession = class {
230894
231171
  if (!this.session) throw new Error("ReolinkIntercomSession.sampleRate read before start()");
230895
231172
  return this.session.info.audioConfig.sampleRate;
230896
231173
  }
231174
+ /**
231175
+ * Effective PCM backlog bound, in ms — the operator's value clamped to
231176
+ * [{@link MIN_BACKLOG_MS}, {@link MAX_BACKLOG_MS}], i.e. what the session
231177
+ * is actually enforcing rather than what it was asked for. Readable before
231178
+ * `start()` because the clamp is pure.
231179
+ */
231180
+ get backlogMs() {
231181
+ return clampBacklogMs(this.opts.maxBacklogMs);
231182
+ }
231183
+ /**
231184
+ * The firmware's talk-back format — `IntercomStatus.ability`. Throws before
231185
+ * `start()`, like `sampleRate`, because the rate is the camera's answer and
231186
+ * not a default.
231187
+ *
231188
+ * The field was declared, mirrored into runtime state and written by NOBODY
231189
+ * while these values were in hand and only reaching a log line (D281).
231190
+ *
231191
+ * `duplex` is the one judgement call: the Baichuan talk channel is a single
231192
+ * dedicated session and this provider enforces one at a time per camera, so
231193
+ * `half` is reported. `full` would be the dangerous direction — a consumer
231194
+ * that believes it may listen while speaking takes no lock.
231195
+ */
231196
+ get ability() {
231197
+ return {
231198
+ codecs: ["adpcm-ima"],
231199
+ sampleRate: this.sampleRate,
231200
+ duplex: "half",
231201
+ maxBacklogMs: this.backlogMs
231202
+ };
231203
+ }
230897
231204
  async start() {
230898
231205
  if (this.session) return;
230899
231206
  this.outputGain = clampGain(this.opts.outputGain);
@@ -230920,7 +231227,7 @@ var ReolinkIntercomSession = class {
230920
231227
  } catch {}
230921
231228
  throw new Error(`Reolink talk session reported invalid sampleRate: ${sampleRate}`);
230922
231229
  }
230923
- const wantedBacklogMs = Math.max(MIN_BACKLOG_MS, Math.min(MAX_BACKLOG_MS, this.opts.maxBacklogMs ?? DEFAULT_BACKLOG_MS));
231230
+ const wantedBacklogMs = this.backlogMs;
230924
231231
  this.maxBacklogBytes = Math.max(this.bytesPerBlock, Math.floor(wantedBacklogMs / 1e3 * sampleRate * 2));
230925
231232
  this.session = session;
230926
231233
  this.pcmBuffer = Buffer.alloc(0);
@@ -231048,6 +231355,14 @@ var IntercomOrchestrator = class {
231048
231355
  return this.session !== null && !this.session.closed;
231049
231356
  }
231050
231357
  /**
231358
+ * The live talk session's firmware ability, or `null` when no session is
231359
+ * open. Read by the camera at `startSession` so the WebRTC path writes
231360
+ * `IntercomStatus.ability` from the same source the raw-PCM path does.
231361
+ */
231362
+ get ability() {
231363
+ return this.session === null || this.session.closed ? null : this.session.talkSession.ability;
231364
+ }
231365
+ /**
231051
231366
  * Open a fresh WebRTC peer + audio-codec decode session + Reolink
231052
231367
  * talk session, wire them, return the SDP offer. Throws (and tears
231053
231368
  * down everything it had spun up) on any failure — the cap router
@@ -231265,6 +231580,193 @@ function errMsg$1(err) {
231265
231580
  return err instanceof Error ? err.message : String(err);
231266
231581
  }
231267
231582
  //#endregion
231583
+ //#region src/talk-pcm-transcoder.ts
231584
+ /** libav codec name of a linear little-endian 16-bit PCM decode session. */
231585
+ var TALK_PCM_CODEC = "pcm_s16le";
231586
+ /** The transcoder was already closed — the talk session ended under the push. */
231587
+ var REASON_PCM_CLOSED = "pcm-transcoder-closed";
231588
+ /** The caller's declared source rate is not a usable positive integer. */
231589
+ var REASON_PCM_BAD_RATE = "pcm-bad-source-rate";
231590
+ /** The frame is empty or holds half a sample — malformed, not convertible. */
231591
+ var REASON_PCM_ODD_BYTES = "pcm-odd-bytes";
231592
+ /** No `audio-codec` provider is mounted on this cluster. */
231593
+ var REASON_PCM_NO_CODEC_CAP = "pcm-audio-codec-unavailable";
231594
+ /** The codec cap refused to open a linear-PCM decode session. */
231595
+ var REASON_PCM_SESSION_OPEN_FAILED = "pcm-resample-session-failed";
231596
+ /** The push/pull round-trip through the codec cap threw. */
231597
+ var REASON_PCM_CONVERT_FAILED = "pcm-resample-failed";
231598
+ function errMessage(err) {
231599
+ return err instanceof Error ? err.message : String(err);
231600
+ }
231601
+ var TalkPcmTranscoder = class {
231602
+ opts;
231603
+ active = null;
231604
+ closed = false;
231605
+ constructor(opts) {
231606
+ this.opts = opts;
231607
+ }
231608
+ /** The open codec session, or `null` before the first converted frame. */
231609
+ get sessionId() {
231610
+ return this.active?.sessionId ?? null;
231611
+ }
231612
+ /**
231613
+ * Convert one frame to the camera's rate and hand every produced chunk to
231614
+ * `feed`.
231615
+ *
231616
+ * Returns `null` when the frame was converted and fed, or the REASON string
231617
+ * it was refused for — already logged, with nothing fed.
231618
+ */
231619
+ async feedResampled(frame) {
231620
+ if (this.closed) {
231621
+ this.refuse(REASON_PCM_CLOSED, {});
231622
+ return REASON_PCM_CLOSED;
231623
+ }
231624
+ const sourceSampleRate = frame.sourceSampleRate;
231625
+ if (!Number.isInteger(sourceSampleRate) || sourceSampleRate <= 0) {
231626
+ this.refuse(REASON_PCM_BAD_RATE, { sourceSampleRate });
231627
+ return REASON_PCM_BAD_RATE;
231628
+ }
231629
+ if (frame.pcm.length === 0 || (frame.pcm.length & 1) !== 0) {
231630
+ this.refuse(REASON_PCM_ODD_BYTES, { bytes: frame.pcm.length });
231631
+ return REASON_PCM_ODD_BYTES;
231632
+ }
231633
+ let api;
231634
+ try {
231635
+ api = this.opts.resolveAudioCodec();
231636
+ } catch (err) {
231637
+ this.refuse(REASON_PCM_NO_CODEC_CAP, { error: errMessage(err) });
231638
+ return REASON_PCM_NO_CODEC_CAP;
231639
+ }
231640
+ if (this.active !== null && this.active.sourceSampleRate !== sourceSampleRate) {
231641
+ const previous = this.active.sourceSampleRate;
231642
+ await this.disposeSession(api, "source-rate-changed");
231643
+ this.opts.logger.info("intercom: pcm resample source rate changed — session recreated", {
231644
+ tags: { deviceId: this.opts.deviceId },
231645
+ meta: {
231646
+ previousSourceSampleRate: previous,
231647
+ sourceSampleRate
231648
+ }
231649
+ });
231650
+ }
231651
+ if (this.active === null) try {
231652
+ const created = await api.createDecodeSession({
231653
+ codec: TALK_PCM_CODEC,
231654
+ sourceSampleRate,
231655
+ sourceChannels: 1,
231656
+ targetSampleRate: this.opts.targetSampleRate,
231657
+ targetChannels: 1,
231658
+ targetFormat: "s16le",
231659
+ tag: this.opts.tag
231660
+ });
231661
+ this.active = {
231662
+ sessionId: created.sessionId,
231663
+ nodeId: created.nodeId,
231664
+ sourceSampleRate
231665
+ };
231666
+ this.opts.logger.info("intercom: pcm resample session opened", {
231667
+ tags: { deviceId: this.opts.deviceId },
231668
+ meta: {
231669
+ codec: TALK_PCM_CODEC,
231670
+ codecSessionId: created.sessionId,
231671
+ codecNodeId: created.nodeId,
231672
+ sourceSampleRate,
231673
+ targetSampleRate: this.opts.targetSampleRate,
231674
+ tag: this.opts.tag
231675
+ }
231676
+ });
231677
+ } catch (err) {
231678
+ this.refuse(REASON_PCM_SESSION_OPEN_FAILED, {
231679
+ sourceSampleRate,
231680
+ targetSampleRate: this.opts.targetSampleRate,
231681
+ error: errMessage(err)
231682
+ });
231683
+ return REASON_PCM_SESSION_OPEN_FAILED;
231684
+ }
231685
+ const session = this.active;
231686
+ try {
231687
+ await api.pushEncodedFrame({
231688
+ sessionId: session.sessionId,
231689
+ nodeId: session.nodeId,
231690
+ data: new Uint8Array(frame.pcm.buffer, frame.pcm.byteOffset, frame.pcm.byteLength)
231691
+ });
231692
+ const chunks = await api.pullPcm({
231693
+ sessionId: session.sessionId,
231694
+ nodeId: session.nodeId,
231695
+ maxCount: 8
231696
+ });
231697
+ for (const chunk of chunks) {
231698
+ const out = Buffer.from(chunk.data.buffer, chunk.data.byteOffset, chunk.data.byteLength);
231699
+ if (out.length > 0) this.opts.feed(out);
231700
+ }
231701
+ return null;
231702
+ } catch (err) {
231703
+ this.refuse(REASON_PCM_CONVERT_FAILED, {
231704
+ codecSessionId: session.sessionId,
231705
+ sourceSampleRate,
231706
+ targetSampleRate: this.opts.targetSampleRate,
231707
+ error: errMessage(err)
231708
+ });
231709
+ await this.disposeSession(api, "convert-failed");
231710
+ return REASON_PCM_CONVERT_FAILED;
231711
+ }
231712
+ }
231713
+ /**
231714
+ * Close the codec session. Idempotent, and called from the provider's
231715
+ * `endTalkSession` so the session dies with the talk session it served.
231716
+ */
231717
+ async close() {
231718
+ this.closed = true;
231719
+ if (this.active === null) return;
231720
+ let api;
231721
+ try {
231722
+ api = this.opts.resolveAudioCodec();
231723
+ } catch (err) {
231724
+ this.opts.logger.debug("intercom: pcm resample close skipped — audio-codec gone", {
231725
+ tags: { deviceId: this.opts.deviceId },
231726
+ meta: {
231727
+ codecSessionId: this.active.sessionId,
231728
+ error: errMessage(err)
231729
+ }
231730
+ });
231731
+ this.active = null;
231732
+ return;
231733
+ }
231734
+ await this.disposeSession(api, "talk-session-ended");
231735
+ }
231736
+ /** Close + forget the current session. Never throws. */
231737
+ async disposeSession(api, why) {
231738
+ const session = this.active;
231739
+ this.active = null;
231740
+ if (session === null) return;
231741
+ try {
231742
+ await api.closeSession({
231743
+ sessionId: session.sessionId,
231744
+ nodeId: session.nodeId
231745
+ });
231746
+ } catch (err) {
231747
+ this.opts.logger.debug("intercom: pcm resample closeSession error (continuing)", {
231748
+ tags: { deviceId: this.opts.deviceId },
231749
+ meta: {
231750
+ codecSessionId: session.sessionId,
231751
+ why,
231752
+ error: errMessage(err)
231753
+ }
231754
+ });
231755
+ }
231756
+ }
231757
+ /** One warn per refused frame. A branch that drops work says so. */
231758
+ refuse(reason, meta) {
231759
+ this.opts.logger.warn("intercom: pcm talk frame refused — not converted, nothing fed", {
231760
+ tags: { deviceId: this.opts.deviceId },
231761
+ meta: {
231762
+ reason,
231763
+ targetSampleRate: this.opts.targetSampleRate,
231764
+ ...meta
231765
+ }
231766
+ });
231767
+ }
231768
+ };
231769
+ //#endregion
231268
231770
  //#region src/intercom-webrtc-peer.ts
231269
231771
  var _werift;
231270
231772
  /**
@@ -234534,13 +235036,20 @@ var ReolinkCamera = class ReolinkCamera extends BaseDevice {
234534
235036
  * Called at the four points that open or close a session — and seeded at
234535
235037
  * registration, so the slice says `talking: false` from boot rather than
234536
235038
  * only after the first session.
235039
+ *
235040
+ * `ability` is STICKY: it is the firmware's negotiated format, learned when a
235041
+ * session opens and still true after it closes, so a caller reading between
235042
+ * sessions gets the last probed value rather than `null`. Passing it is what
235043
+ * changed — it used to be copied forward from `previous` at every one of the
235044
+ * four call sites and written by nobody, while `session.sampleRate` was in
235045
+ * hand and only reaching a log line (D281).
234537
235046
  */
234538
- publishIntercomState(talking) {
235047
+ publishIntercomState(talking, ability) {
234539
235048
  const previous = this.getCapSlice(intercomCapability);
234540
235049
  this.setCapSlice(intercomCapability, {
234541
235050
  talking,
234542
235051
  lastSessionAt: talking ? Date.now() : previous?.lastSessionAt ?? null,
234543
- ability: previous?.ability ?? null
235052
+ ability: ability ?? previous?.ability ?? null
234544
235053
  });
234545
235054
  }
234546
235055
  registerIntercomIfSupported() {
@@ -234579,7 +235088,7 @@ var ReolinkCamera = class ReolinkCamera extends BaseDevice {
234579
235088
  });
234580
235089
  try {
234581
235090
  const opened = await this.intercomOrchestrator.start();
234582
- this.publishIntercomState(true);
235091
+ this.publishIntercomState(true, this.intercomOrchestrator.ability ?? void 0);
234583
235092
  return opened;
234584
235093
  } catch (err) {
234585
235094
  this.publishIntercomState(false);
@@ -234601,8 +235110,10 @@ var ReolinkCamera = class ReolinkCamera extends BaseDevice {
234601
235110
  if (deviceId !== this.id) throw new Error(`ReolinkCamera: intercom deviceId mismatch, expected ${this.id}, got ${deviceId}`);
234602
235111
  if (this.disabled) throw new Error("Reolink intercom: device is disabled — re-enable it before opening a talk session");
234603
235112
  if (this.intercomRawSession) {
234604
- await this.intercomRawSession.session.stop().catch(() => {});
235113
+ const previous = this.intercomRawSession;
234605
235114
  this.intercomRawSession = null;
235115
+ await previous.pcmTranscode.close();
235116
+ await previous.session.stop().catch(() => {});
234606
235117
  }
234607
235118
  const api = await this.ensureApi();
234608
235119
  if (this.isBattery) await this.wakeForIntercom(api);
@@ -234624,9 +235135,17 @@ var ReolinkCamera = class ReolinkCamera extends BaseDevice {
234624
235135
  id,
234625
235136
  session,
234626
235137
  lastSequenceNumber: -1,
234627
- opusDecode: null
235138
+ opusDecode: null,
235139
+ pcmTranscode: new TalkPcmTranscoder({
235140
+ deviceId: this.id,
235141
+ logger: this.ctx.logger,
235142
+ resolveAudioCodec: () => this.resolveAudioCodecApi(),
235143
+ targetSampleRate: session.sampleRate,
235144
+ tag: `reolink-intercom-pcm:${this.id}:${id}`,
235145
+ feed: (pcm) => session.feedPcm(pcm)
235146
+ })
234628
235147
  };
234629
- this.publishIntercomState(true);
235148
+ this.publishIntercomState(true, session.ability);
234630
235149
  this.ctx.logger.info("intercom talk session opened", {
234631
235150
  tags: { deviceId: this.id },
234632
235151
  meta: {
@@ -234638,13 +235157,24 @@ var ReolinkCamera = class ReolinkCamera extends BaseDevice {
234638
235157
  },
234639
235158
  pushTalkAudio: async ({ deviceId, audioBase64, codec, sampleRate, channels, sequenceNumber }) => {
234640
235159
  if (deviceId !== this.id) return { accepted: false };
235160
+ const wireCodec = codec ?? "s16le";
235161
+ const note = (reason) => {
235162
+ intercomFailureReport.noteTalkFrame(this.id, wireCodec, reason);
235163
+ };
234641
235164
  const active = this.intercomRawSession;
234642
235165
  if (!active || !active.session.isOpen) return { accepted: false };
234643
- if (sequenceNumber <= active.lastSequenceNumber) return { accepted: false };
235166
+ if (sequenceNumber <= active.lastSequenceNumber) {
235167
+ note(REASON_TALK_OUT_OF_ORDER);
235168
+ return { accepted: false };
235169
+ }
234644
235170
  const buf = Buffer.from(audioBase64, "base64");
234645
- if (buf.length === 0) return { accepted: false };
235171
+ if (buf.length === 0) {
235172
+ note(REASON_TALK_EMPTY);
235173
+ return { accepted: false };
235174
+ }
234646
235175
  const ch = channels ?? 1;
234647
235176
  if (ch !== 1) {
235177
+ note(REASON_TALK_NOT_MONO);
234648
235178
  this.ctx.logger.warn("intercom: dropping non-mono talk frame (Reolink is mono-only)", {
234649
235179
  tags: { deviceId: this.id },
234650
235180
  meta: {
@@ -234654,8 +235184,8 @@ var ReolinkCamera = class ReolinkCamera extends BaseDevice {
234654
235184
  });
234655
235185
  return { accepted: false };
234656
235186
  }
234657
- const wireCodec = codec ?? "s16le";
234658
235187
  if (wireCodec === "g711ulaw" || wireCodec === "g711alaw") {
235188
+ note(REASON_TALK_CODEC_UNSUPPORTED);
234659
235189
  this.ctx.logger.warn("intercom: g711 passthrough not supported on Reolink (camera codec is ADPCM) — dropping frame", {
234660
235190
  tags: { deviceId: this.id },
234661
235191
  meta: { wireCodec }
@@ -234664,24 +235194,29 @@ var ReolinkCamera = class ReolinkCamera extends BaseDevice {
234664
235194
  }
234665
235195
  if (wireCodec === "s16le") {
234666
235196
  if (!sampleRate) {
235197
+ note(REASON_TALK_NO_SAMPLE_RATE);
234667
235198
  this.ctx.logger.warn("intercom: s16le push with no sampleRate — dropping (rate is ambiguous)", { tags: { deviceId: this.id } });
234668
235199
  return { accepted: false };
234669
235200
  }
234670
235201
  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
- }
235202
+ const refusal = await active.pcmTranscode.feedResampled({
235203
+ pcm: buf,
235204
+ sourceSampleRate: sampleRate
234677
235205
  });
234678
- return { accepted: false };
235206
+ if (refusal !== null) {
235207
+ note(refusal);
235208
+ return { accepted: false };
235209
+ }
235210
+ active.lastSequenceNumber = sequenceNumber;
235211
+ note();
235212
+ return { accepted: true };
234679
235213
  }
234680
235214
  active.lastSequenceNumber = sequenceNumber;
234681
235215
  active.session.feedPcm(buf);
235216
+ note();
234682
235217
  return { accepted: true };
234683
235218
  }
234684
- if (wireCodec === "opus") {
235219
+ if (wireCodec === "opus") try {
234685
235220
  if (!active.opusDecode) {
234686
235221
  const created = await this.resolveAudioCodecApi().createDecodeSession({
234687
235222
  codec: "opus",
@@ -234724,8 +235259,17 @@ var ReolinkCamera = class ReolinkCamera extends BaseDevice {
234724
235259
  const pcmBuf = Buffer.from(chunk.data.buffer, chunk.data.byteOffset, chunk.data.byteLength);
234725
235260
  if (pcmBuf.length > 0) active.session.feedPcm(pcmBuf);
234726
235261
  }
235262
+ note();
234727
235263
  return { accepted: true };
235264
+ } catch (err) {
235265
+ note(REASON_TALK_OPUS_FAILED);
235266
+ throw err;
234728
235267
  }
235268
+ note(REASON_TALK_CODEC_UNSUPPORTED);
235269
+ this.ctx.logger.warn("intercom: no path onto the talk channel for this wire codec", {
235270
+ tags: { deviceId: this.id },
235271
+ meta: { wireCodec }
235272
+ });
234729
235273
  return { accepted: false };
234730
235274
  },
234731
235275
  endTalkSession: async ({ deviceId }) => {
@@ -234733,6 +235277,7 @@ var ReolinkCamera = class ReolinkCamera extends BaseDevice {
234733
235277
  const active = this.intercomRawSession;
234734
235278
  if (!active) return;
234735
235279
  this.intercomRawSession = null;
235280
+ await active.pcmTranscode.close();
234736
235281
  if (active.opusDecode) await this.resolveAudioCodecApi().closeSession({
234737
235282
  sessionId: active.opusDecode.sessionId,
234738
235283
  nodeId: active.opusDecode.nodeId
@@ -241606,6 +242151,10 @@ var ReolinkProviderAddon = class extends BaseDeviceProvider {
241606
242151
  capability: logChannelsCapability,
241607
242152
  provider: this.logChannels
241608
242153
  });
242154
+ regs.push({
242155
+ capability: failureContributionCapability,
242156
+ provider: { list: () => intercomFailureReport.list() }
242157
+ });
241609
242158
  this.subscribe({ category: EventCategory.StreamBrokerOnRequestStreamSourceRefresh }, (event) => {
241610
242159
  const data = event.data;
241611
242160
  const deviceId = typeof data.deviceId === "number" ? data.deviceId : null;