@camstack/addon-provider-reolink 1.2.60 → 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 +650 -19
  2. package/dist/addon.mjs +650 -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",
@@ -13965,6 +14071,133 @@ method(LogEntrySchema, _void(), { kind: "mutation" }), method(object({
13965
14071
  limit: number().optional(),
13966
14072
  tags: record(string(), string()).optional()
13967
14073
  }), array(LogEntrySchema).readonly());
14074
+ /**
14075
+ * `failure-contribution` — the capability an addon reports its OWN losses
14076
+ * through, per camera, with the denominator attached. It stores nothing.
14077
+ *
14078
+ * ## The twin of `load-contribution`, and why it is a twin and not a field
14079
+ *
14080
+ * `load-contribution` answers *what did this camera COST*. This answers *what
14081
+ * did this camera LOSE*. The reporting discipline is identical and deliberately
14082
+ * copied: the contributor reports what it already knows, hub-main adds only
14083
+ * `addonId`, nothing needs global knowledge, and there is no central list for
14084
+ * somebody to forget to edit.
14085
+ *
14086
+ * They are not merged, because their invariants are opposites:
14087
+ *
14088
+ * - a `load-contribution` measurement is **absent, never zero** — a zero would
14089
+ * claim a camera cost nothing, which is a measurement nobody made;
14090
+ * - a `failure-contribution` zero is the **most valuable value on the
14091
+ * surface** — `attempts: 400, succeeded: 400` is the proof a fix landed,
14092
+ * and it is exactly what an absent entry cannot say.
14093
+ *
14094
+ * Putting a loss counter on a cost entry would also break the reconciliation
14095
+ * that gives `load-contribution` its point: contributions are subtracted from
14096
+ * `metrics.node-processes-snapshot` to find processes nobody claims. A failure
14097
+ * has no process.
14098
+ *
14099
+ * ## Why not a log line, since the counters already exist
14100
+ *
14101
+ * Several of these paths already counted themselves — `CaptureScheduler`'s
14102
+ * per-device window, `KeyFrameCaptureLog`, `bumpCropMetric`. Every one of them
14103
+ * ends in a log line, and a log line is the thing the operator asked to stop
14104
+ * needing: *"possiamo armare questi errori intanto? Così al prossimo giro
14105
+ * ricontrolliamo tutti questi punti"*. Reading them meant grepping Loki and
14106
+ * hand-correlating timestamps, which is how a 22% thumbnail gap and a 3-hour
14107
+ * media blackout were both diagnosed. The counters stay; this is where they can
14108
+ * be READ.
14109
+ *
14110
+ * ## The rate is served with its denominator or not at all
14111
+ *
14112
+ * Every entry carries `attempts` and `succeeded`. A miss count alone is
14113
+ * unreadable: on 2026-08-28 the enrichment-crop miss count read as "35x worse
14114
+ * than yesterday" and was **flat across twelve hours** once divided by the
14115
+ * successes on the same path. A surface that publishes only the numerator
14116
+ * reproduces that mistake on every read.
14117
+ *
14118
+ * ## Shape
14119
+ *
14120
+ * Copied from `load-contribution.cap.ts` (`mode: 'collection'`,
14121
+ * `internal: true`, `mount: { kind: 'skip' }`): no tRPC route of its own and no
14122
+ * generated hooks, while `addons.listCapabilityProviders` still enumerates it
14123
+ * and the hub's `CapabilityRegistry` still holds an RPC proxy per provider — so
14124
+ * a forked runner's entries reach hub-main over transport that already exists.
14125
+ * No new UDS message, no second registry (D3). The operator reads the assembled
14126
+ * result through `system.getFailureContributions`.
14127
+ */
14128
+ var FailureReasonCountSchema = object({
14129
+ /**
14130
+ * Why the attempt did not land, in the contributor's own vocabulary —
14131
+ * `worker-lease-gone`, `queue-overflow`, `timeout`, `empty-read`. The same
14132
+ * strings that already appear in this repo's logs and, where one exists, the
14133
+ * same string the per-track `previewMissReason` records (D276): a second
14134
+ * vocabulary for the same loss would make the row and the counter
14135
+ * un-joinable.
14136
+ */
14137
+ reason: string(),
14138
+ count: number().int().nonnegative()
14139
+ });
14140
+ var FailureContributionSchema = object({
14141
+ /**
14142
+ * The failing path — `enrichment-crop`, `inference`, `plate-ocr`,
14143
+ * `person-over-vehicle`. Free text, for the reason `load-contribution` keeps
14144
+ * `unit` free: the families are owned by different addons and a shared enum
14145
+ * is a central list that rots invisibly.
14146
+ */
14147
+ family: string(),
14148
+ /**
14149
+ * The NUMERIC device id — the same value every log line carries as
14150
+ * `tags.deviceId`. Never nullable and never absent: a contributor that
14151
+ * cannot name the camera must not emit the entry, because a fleet total
14152
+ * cannot answer the only question anybody asks of this surface.
14153
+ */
14154
+ deviceId: number().int().positive(),
14155
+ /**
14156
+ * A second dimension inside the family: the model / step id for an inference
14157
+ * timeout, so "which camera AND which model" is one read. Absent when the
14158
+ * family has a single variant.
14159
+ */
14160
+ variant: string().optional(),
14161
+ /**
14162
+ * Epoch ms this counter started — the INCARNATION MARKER. A consumer
14163
+ * differencing two reads must drop the interval when it changes, because the
14164
+ * counter restarted from zero in a respawned runner. Same discipline as
14165
+ * `LoadContribution.startedAtMs`.
14166
+ */
14167
+ sinceMs: number(),
14168
+ /** Epoch ms it was read. `atMs - sinceMs` is the interval this covers. */
14169
+ atMs: number(),
14170
+ /**
14171
+ * THE DENOMINATOR — every attempt on this path for this camera in the
14172
+ * window. A failure count published without it is the mistake this schema
14173
+ * exists to make impossible.
14174
+ */
14175
+ attempts: number().int().nonnegative(),
14176
+ /** Attempts that landed. `attempts - succeeded` is the loss. */
14177
+ succeeded: number().int().nonnegative(),
14178
+ /** The loss, partitioned. Sums to `attempts - succeeded`. */
14179
+ reasons: array(FailureReasonCountSchema).readonly()
14180
+ });
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
+ };
13968
14201
  var LoadContributionSchema = object({
13969
14202
  role: _enum([
13970
14203
  "decode",
@@ -18555,6 +18788,20 @@ var TrackSchema = object({
18555
18788
  * `=== true` and render nothing otherwise — never infer "no rider".
18556
18789
  */
18557
18790
  hasRider: boolean().optional(),
18791
+ /**
18792
+ * WHY this track ended without a NATIVE best-shot tile
18793
+ * ([D276](../decisions/adr-0276-a-stand-in-tile-is-provisional-and-a-close-says-why.md)) —
18794
+ * a composed token line (`no-key-frame capture=keyframe:native-missx4`,
18795
+ * `derive-returned-null tile=standin`, …) written at close and CLEARED by
18796
+ * the late-keyFrame upgrade when a native tile lands after all. The
18797
+ * operator-facing answer to "perché manca l'immagine?" on a track whose
18798
+ * tile is a face/plate stand-in, a raster crop, or an icon.
18799
+ *
18800
+ * **Absent ≠ "missed silently"**: a row written before the column, a hub
18801
+ * that predates the field, and every track whose tile landed native all
18802
+ * omit it. Render nothing when absent.
18803
+ */
18804
+ previewMissReason: string().optional(),
18558
18805
  ...TrackFlagFields,
18559
18806
  ...TrackRetrainFields
18560
18807
  });
@@ -30119,6 +30366,13 @@ var LoggingSettingsPatchSchema = object({
30119
30366
  * anyone but its owner.
30120
30367
  */
30121
30368
  var ReportedLoadContributionSchema = LoadContributionSchema.extend({ addonId: string() });
30369
+ /**
30370
+ * One per-camera failure counter, plus WHO reported it.
30371
+ *
30372
+ * Same rule as {@link ReportedLoadContributionSchema}: `addonId` is stamped by
30373
+ * the hub as it enumerates providers, never by the contributor.
30374
+ */
30375
+ var ReportedFailureContributionSchema = FailureContributionSchema.extend({ addonId: string() });
30122
30376
  var GetLoggingSettingsInputSchema = object({
30123
30377
  scopeNodeId: string().optional(),
30124
30378
  /**
@@ -30177,7 +30431,7 @@ method(_void(), FeatureManifestSchema), method(_void(), HealthStatusSchema), met
30177
30431
  }), method(_void(), SiteLocationStatusSchema, {
30178
30432
  kind: "mutation",
30179
30433
  auth: "admin"
30180
- }), method(_void(), RequestCensusStatusSchema, { auth: "admin" }), method(_void(), array(ReportedLoadContributionSchema).readonly(), { auth: "admin" }), method(GetLoggingSettingsInputSchema, LoggingSettingsStateSchema, { auth: "admin" }), method(SetLoggingSettingsInputSchema, LoggingSettingsStateSchema, {
30434
+ }), method(_void(), RequestCensusStatusSchema, { auth: "admin" }), method(_void(), array(ReportedLoadContributionSchema).readonly(), { auth: "admin" }), method(_void(), array(ReportedFailureContributionSchema).readonly(), { auth: "admin" }), method(GetLoggingSettingsInputSchema, LoggingSettingsStateSchema, { auth: "admin" }), method(SetLoggingSettingsInputSchema, LoggingSettingsStateSchema, {
30181
30435
  kind: "mutation",
30182
30436
  auth: "admin"
30183
30437
  });
@@ -34334,6 +34588,12 @@ Object.freeze({
34334
34588
  addonId: null,
34335
34589
  access: "create"
34336
34590
  },
34591
+ "failureContribution.list": {
34592
+ capName: "failure-contribution",
34593
+ capScope: "system",
34594
+ addonId: null,
34595
+ access: "view"
34596
+ },
34337
34597
  "fanControl.setDirection": {
34338
34598
  capName: "fan-control",
34339
34599
  capScope: "device",
@@ -37640,6 +37900,12 @@ Object.freeze({
37640
37900
  addonId: null,
37641
37901
  access: "create"
37642
37902
  },
37903
+ "system.getFailureContributions": {
37904
+ capName: "system",
37905
+ capScope: "system",
37906
+ addonId: null,
37907
+ access: "view"
37908
+ },
37643
37909
  "system.getLoadContributions": {
37644
37910
  capName: "system",
37645
37911
  capScope: "system",
@@ -228893,6 +229159,90 @@ function buildInitialStatus(config) {
228893
229159
  };
228894
229160
  }
228895
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
228896
229246
  //#region src/log-channels.ts
228897
229247
  /**
228898
229248
  * The diagnostic log CHANNELS `provider-reolink` declares.
@@ -230726,6 +231076,15 @@ function encodeImaAdpcm(pcm, blockSizeBytes) {
230726
231076
  var DEFAULT_BACKLOG_MS = 120;
230727
231077
  var MAX_BACKLOG_MS = 5e3;
230728
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
+ }
230729
231088
  var DEFAULT_BLOCKS_PER_PAYLOAD = 1;
230730
231089
  var DEFAULT_GAIN = 1;
230731
231090
  var MIN_GAIN = .1;
@@ -230753,6 +231112,36 @@ var ReolinkIntercomSession = class {
230753
231112
  if (!this.session) throw new Error("ReolinkIntercomSession.sampleRate read before start()");
230754
231113
  return this.session.info.audioConfig.sampleRate;
230755
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
+ }
230756
231145
  async start() {
230757
231146
  if (this.session) return;
230758
231147
  this.outputGain = clampGain(this.opts.outputGain);
@@ -230779,7 +231168,7 @@ var ReolinkIntercomSession = class {
230779
231168
  } catch {}
230780
231169
  throw new Error(`Reolink talk session reported invalid sampleRate: ${sampleRate}`);
230781
231170
  }
230782
- const wantedBacklogMs = Math.max(MIN_BACKLOG_MS, Math.min(MAX_BACKLOG_MS, this.opts.maxBacklogMs ?? DEFAULT_BACKLOG_MS));
231171
+ const wantedBacklogMs = this.backlogMs;
230783
231172
  this.maxBacklogBytes = Math.max(this.bytesPerBlock, Math.floor(wantedBacklogMs / 1e3 * sampleRate * 2));
230784
231173
  this.session = session;
230785
231174
  this.pcmBuffer = Buffer.alloc(0);
@@ -230907,6 +231296,14 @@ var IntercomOrchestrator = class {
230907
231296
  return this.session !== null && !this.session.closed;
230908
231297
  }
230909
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
+ /**
230910
231307
  * Open a fresh WebRTC peer + audio-codec decode session + Reolink
230911
231308
  * talk session, wire them, return the SDP offer. Throws (and tears
230912
231309
  * down everything it had spun up) on any failure — the cap router
@@ -231124,6 +231521,193 @@ function errMsg$1(err) {
231124
231521
  return err instanceof Error ? err.message : String(err);
231125
231522
  }
231126
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
231127
231711
  //#region src/intercom-webrtc-peer.ts
231128
231712
  var _werift;
231129
231713
  /**
@@ -234393,13 +234977,20 @@ var ReolinkCamera = class ReolinkCamera extends BaseDevice {
234393
234977
  * Called at the four points that open or close a session — and seeded at
234394
234978
  * registration, so the slice says `talking: false` from boot rather than
234395
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).
234396
234987
  */
234397
- publishIntercomState(talking) {
234988
+ publishIntercomState(talking, ability) {
234398
234989
  const previous = this.getCapSlice(intercomCapability);
234399
234990
  this.setCapSlice(intercomCapability, {
234400
234991
  talking,
234401
234992
  lastSessionAt: talking ? Date.now() : previous?.lastSessionAt ?? null,
234402
- ability: previous?.ability ?? null
234993
+ ability: ability ?? previous?.ability ?? null
234403
234994
  });
234404
234995
  }
234405
234996
  registerIntercomIfSupported() {
@@ -234438,7 +235029,7 @@ var ReolinkCamera = class ReolinkCamera extends BaseDevice {
234438
235029
  });
234439
235030
  try {
234440
235031
  const opened = await this.intercomOrchestrator.start();
234441
- this.publishIntercomState(true);
235032
+ this.publishIntercomState(true, this.intercomOrchestrator.ability ?? void 0);
234442
235033
  return opened;
234443
235034
  } catch (err) {
234444
235035
  this.publishIntercomState(false);
@@ -234460,8 +235051,10 @@ var ReolinkCamera = class ReolinkCamera extends BaseDevice {
234460
235051
  if (deviceId !== this.id) throw new Error(`ReolinkCamera: intercom deviceId mismatch, expected ${this.id}, got ${deviceId}`);
234461
235052
  if (this.disabled) throw new Error("Reolink intercom: device is disabled — re-enable it before opening a talk session");
234462
235053
  if (this.intercomRawSession) {
234463
- await this.intercomRawSession.session.stop().catch(() => {});
235054
+ const previous = this.intercomRawSession;
234464
235055
  this.intercomRawSession = null;
235056
+ await previous.pcmTranscode.close();
235057
+ await previous.session.stop().catch(() => {});
234465
235058
  }
234466
235059
  const api = await this.ensureApi();
234467
235060
  if (this.isBattery) await this.wakeForIntercom(api);
@@ -234483,9 +235076,17 @@ var ReolinkCamera = class ReolinkCamera extends BaseDevice {
234483
235076
  id,
234484
235077
  session,
234485
235078
  lastSequenceNumber: -1,
234486
- 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
+ })
234487
235088
  };
234488
- this.publishIntercomState(true);
235089
+ this.publishIntercomState(true, session.ability);
234489
235090
  this.ctx.logger.info("intercom talk session opened", {
234490
235091
  tags: { deviceId: this.id },
234491
235092
  meta: {
@@ -234497,13 +235098,24 @@ var ReolinkCamera = class ReolinkCamera extends BaseDevice {
234497
235098
  },
234498
235099
  pushTalkAudio: async ({ deviceId, audioBase64, codec, sampleRate, channels, sequenceNumber }) => {
234499
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
+ };
234500
235105
  const active = this.intercomRawSession;
234501
235106
  if (!active || !active.session.isOpen) return { accepted: false };
234502
- if (sequenceNumber <= active.lastSequenceNumber) return { accepted: false };
235107
+ if (sequenceNumber <= active.lastSequenceNumber) {
235108
+ note(REASON_TALK_OUT_OF_ORDER);
235109
+ return { accepted: false };
235110
+ }
234503
235111
  const buf = Buffer.from(audioBase64, "base64");
234504
- if (buf.length === 0) return { accepted: false };
235112
+ if (buf.length === 0) {
235113
+ note(REASON_TALK_EMPTY);
235114
+ return { accepted: false };
235115
+ }
234505
235116
  const ch = channels ?? 1;
234506
235117
  if (ch !== 1) {
235118
+ note(REASON_TALK_NOT_MONO);
234507
235119
  this.ctx.logger.warn("intercom: dropping non-mono talk frame (Reolink is mono-only)", {
234508
235120
  tags: { deviceId: this.id },
234509
235121
  meta: {
@@ -234513,8 +235125,8 @@ var ReolinkCamera = class ReolinkCamera extends BaseDevice {
234513
235125
  });
234514
235126
  return { accepted: false };
234515
235127
  }
234516
- const wireCodec = codec ?? "s16le";
234517
235128
  if (wireCodec === "g711ulaw" || wireCodec === "g711alaw") {
235129
+ note(REASON_TALK_CODEC_UNSUPPORTED);
234518
235130
  this.ctx.logger.warn("intercom: g711 passthrough not supported on Reolink (camera codec is ADPCM) — dropping frame", {
234519
235131
  tags: { deviceId: this.id },
234520
235132
  meta: { wireCodec }
@@ -234523,24 +235135,29 @@ var ReolinkCamera = class ReolinkCamera extends BaseDevice {
234523
235135
  }
234524
235136
  if (wireCodec === "s16le") {
234525
235137
  if (!sampleRate) {
235138
+ note(REASON_TALK_NO_SAMPLE_RATE);
234526
235139
  this.ctx.logger.warn("intercom: s16le push with no sampleRate — dropping (rate is ambiguous)", { tags: { deviceId: this.id } });
234527
235140
  return { accepted: false };
234528
235141
  }
234529
235142
  if (sampleRate !== active.session.sampleRate) {
234530
- this.ctx.logger.warn("intercom: s16le sampleRate mismatch (PCM-only resample not implemented) — dropping", {
234531
- tags: { deviceId: this.id },
234532
- meta: {
234533
- wireRate: sampleRate,
234534
- cameraRate: active.session.sampleRate
234535
- }
235143
+ const refusal = await active.pcmTranscode.feedResampled({
235144
+ pcm: buf,
235145
+ sourceSampleRate: sampleRate
234536
235146
  });
234537
- 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 };
234538
235154
  }
234539
235155
  active.lastSequenceNumber = sequenceNumber;
234540
235156
  active.session.feedPcm(buf);
235157
+ note();
234541
235158
  return { accepted: true };
234542
235159
  }
234543
- if (wireCodec === "opus") {
235160
+ if (wireCodec === "opus") try {
234544
235161
  if (!active.opusDecode) {
234545
235162
  const created = await this.resolveAudioCodecApi().createDecodeSession({
234546
235163
  codec: "opus",
@@ -234583,8 +235200,17 @@ var ReolinkCamera = class ReolinkCamera extends BaseDevice {
234583
235200
  const pcmBuf = Buffer.from(chunk.data.buffer, chunk.data.byteOffset, chunk.data.byteLength);
234584
235201
  if (pcmBuf.length > 0) active.session.feedPcm(pcmBuf);
234585
235202
  }
235203
+ note();
234586
235204
  return { accepted: true };
235205
+ } catch (err) {
235206
+ note(REASON_TALK_OPUS_FAILED);
235207
+ throw err;
234587
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
+ });
234588
235214
  return { accepted: false };
234589
235215
  },
234590
235216
  endTalkSession: async ({ deviceId }) => {
@@ -234592,6 +235218,7 @@ var ReolinkCamera = class ReolinkCamera extends BaseDevice {
234592
235218
  const active = this.intercomRawSession;
234593
235219
  if (!active) return;
234594
235220
  this.intercomRawSession = null;
235221
+ await active.pcmTranscode.close();
234595
235222
  if (active.opusDecode) await this.resolveAudioCodecApi().closeSession({
234596
235223
  sessionId: active.opusDecode.sessionId,
234597
235224
  nodeId: active.opusDecode.nodeId
@@ -241465,6 +242092,10 @@ var ReolinkProviderAddon = class extends BaseDeviceProvider {
241465
242092
  capability: logChannelsCapability,
241466
242093
  provider: this.logChannels
241467
242094
  });
242095
+ regs.push({
242096
+ capability: failureContributionCapability,
242097
+ provider: { list: () => intercomFailureReport.list() }
242098
+ });
241468
242099
  this.subscribe({ category: EventCategory.StreamBrokerOnRequestStreamSourceRefresh }, (event) => {
241469
242100
  const data = event.data;
241470
242101
  const deviceId = typeof data.deviceId === "number" ? data.deviceId : null;