@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.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",
@@ -13960,6 +14066,133 @@ method(LogEntrySchema, _void(), { kind: "mutation" }), method(object({
13960
14066
  limit: number().optional(),
13961
14067
  tags: record(string(), string()).optional()
13962
14068
  }), array(LogEntrySchema).readonly());
14069
+ /**
14070
+ * `failure-contribution` — the capability an addon reports its OWN losses
14071
+ * through, per camera, with the denominator attached. It stores nothing.
14072
+ *
14073
+ * ## The twin of `load-contribution`, and why it is a twin and not a field
14074
+ *
14075
+ * `load-contribution` answers *what did this camera COST*. This answers *what
14076
+ * did this camera LOSE*. The reporting discipline is identical and deliberately
14077
+ * copied: the contributor reports what it already knows, hub-main adds only
14078
+ * `addonId`, nothing needs global knowledge, and there is no central list for
14079
+ * somebody to forget to edit.
14080
+ *
14081
+ * They are not merged, because their invariants are opposites:
14082
+ *
14083
+ * - a `load-contribution` measurement is **absent, never zero** — a zero would
14084
+ * claim a camera cost nothing, which is a measurement nobody made;
14085
+ * - a `failure-contribution` zero is the **most valuable value on the
14086
+ * surface** — `attempts: 400, succeeded: 400` is the proof a fix landed,
14087
+ * and it is exactly what an absent entry cannot say.
14088
+ *
14089
+ * Putting a loss counter on a cost entry would also break the reconciliation
14090
+ * that gives `load-contribution` its point: contributions are subtracted from
14091
+ * `metrics.node-processes-snapshot` to find processes nobody claims. A failure
14092
+ * has no process.
14093
+ *
14094
+ * ## Why not a log line, since the counters already exist
14095
+ *
14096
+ * Several of these paths already counted themselves — `CaptureScheduler`'s
14097
+ * per-device window, `KeyFrameCaptureLog`, `bumpCropMetric`. Every one of them
14098
+ * ends in a log line, and a log line is the thing the operator asked to stop
14099
+ * needing: *"possiamo armare questi errori intanto? Così al prossimo giro
14100
+ * ricontrolliamo tutti questi punti"*. Reading them meant grepping Loki and
14101
+ * hand-correlating timestamps, which is how a 22% thumbnail gap and a 3-hour
14102
+ * media blackout were both diagnosed. The counters stay; this is where they can
14103
+ * be READ.
14104
+ *
14105
+ * ## The rate is served with its denominator or not at all
14106
+ *
14107
+ * Every entry carries `attempts` and `succeeded`. A miss count alone is
14108
+ * unreadable: on 2026-08-28 the enrichment-crop miss count read as "35x worse
14109
+ * than yesterday" and was **flat across twelve hours** once divided by the
14110
+ * successes on the same path. A surface that publishes only the numerator
14111
+ * reproduces that mistake on every read.
14112
+ *
14113
+ * ## Shape
14114
+ *
14115
+ * Copied from `load-contribution.cap.ts` (`mode: 'collection'`,
14116
+ * `internal: true`, `mount: { kind: 'skip' }`): no tRPC route of its own and no
14117
+ * generated hooks, while `addons.listCapabilityProviders` still enumerates it
14118
+ * and the hub's `CapabilityRegistry` still holds an RPC proxy per provider — so
14119
+ * a forked runner's entries reach hub-main over transport that already exists.
14120
+ * No new UDS message, no second registry (D3). The operator reads the assembled
14121
+ * result through `system.getFailureContributions`.
14122
+ */
14123
+ var FailureReasonCountSchema = object({
14124
+ /**
14125
+ * Why the attempt did not land, in the contributor's own vocabulary —
14126
+ * `worker-lease-gone`, `queue-overflow`, `timeout`, `empty-read`. The same
14127
+ * strings that already appear in this repo's logs and, where one exists, the
14128
+ * same string the per-track `previewMissReason` records (D276): a second
14129
+ * vocabulary for the same loss would make the row and the counter
14130
+ * un-joinable.
14131
+ */
14132
+ reason: string(),
14133
+ count: number().int().nonnegative()
14134
+ });
14135
+ var FailureContributionSchema = object({
14136
+ /**
14137
+ * The failing path — `enrichment-crop`, `inference`, `plate-ocr`,
14138
+ * `person-over-vehicle`. Free text, for the reason `load-contribution` keeps
14139
+ * `unit` free: the families are owned by different addons and a shared enum
14140
+ * is a central list that rots invisibly.
14141
+ */
14142
+ family: string(),
14143
+ /**
14144
+ * The NUMERIC device id — the same value every log line carries as
14145
+ * `tags.deviceId`. Never nullable and never absent: a contributor that
14146
+ * cannot name the camera must not emit the entry, because a fleet total
14147
+ * cannot answer the only question anybody asks of this surface.
14148
+ */
14149
+ deviceId: number().int().positive(),
14150
+ /**
14151
+ * A second dimension inside the family: the model / step id for an inference
14152
+ * timeout, so "which camera AND which model" is one read. Absent when the
14153
+ * family has a single variant.
14154
+ */
14155
+ variant: string().optional(),
14156
+ /**
14157
+ * Epoch ms this counter started — the INCARNATION MARKER. A consumer
14158
+ * differencing two reads must drop the interval when it changes, because the
14159
+ * counter restarted from zero in a respawned runner. Same discipline as
14160
+ * `LoadContribution.startedAtMs`.
14161
+ */
14162
+ sinceMs: number(),
14163
+ /** Epoch ms it was read. `atMs - sinceMs` is the interval this covers. */
14164
+ atMs: number(),
14165
+ /**
14166
+ * THE DENOMINATOR — every attempt on this path for this camera in the
14167
+ * window. A failure count published without it is the mistake this schema
14168
+ * exists to make impossible.
14169
+ */
14170
+ attempts: number().int().nonnegative(),
14171
+ /** Attempts that landed. `attempts - succeeded` is the loss. */
14172
+ succeeded: number().int().nonnegative(),
14173
+ /** The loss, partitioned. Sums to `attempts - succeeded`. */
14174
+ reasons: array(FailureReasonCountSchema).readonly()
14175
+ });
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
+ };
13963
14196
  var LoadContributionSchema = object({
13964
14197
  role: _enum([
13965
14198
  "decode",
@@ -18550,6 +18783,20 @@ var TrackSchema = object({
18550
18783
  * `=== true` and render nothing otherwise — never infer "no rider".
18551
18784
  */
18552
18785
  hasRider: boolean().optional(),
18786
+ /**
18787
+ * WHY this track ended without a NATIVE best-shot tile
18788
+ * ([D276](../decisions/adr-0276-a-stand-in-tile-is-provisional-and-a-close-says-why.md)) —
18789
+ * a composed token line (`no-key-frame capture=keyframe:native-missx4`,
18790
+ * `derive-returned-null tile=standin`, …) written at close and CLEARED by
18791
+ * the late-keyFrame upgrade when a native tile lands after all. The
18792
+ * operator-facing answer to "perché manca l'immagine?" on a track whose
18793
+ * tile is a face/plate stand-in, a raster crop, or an icon.
18794
+ *
18795
+ * **Absent ≠ "missed silently"**: a row written before the column, a hub
18796
+ * that predates the field, and every track whose tile landed native all
18797
+ * omit it. Render nothing when absent.
18798
+ */
18799
+ previewMissReason: string().optional(),
18553
18800
  ...TrackFlagFields,
18554
18801
  ...TrackRetrainFields
18555
18802
  });
@@ -30114,6 +30361,13 @@ var LoggingSettingsPatchSchema = object({
30114
30361
  * anyone but its owner.
30115
30362
  */
30116
30363
  var ReportedLoadContributionSchema = LoadContributionSchema.extend({ addonId: string() });
30364
+ /**
30365
+ * One per-camera failure counter, plus WHO reported it.
30366
+ *
30367
+ * Same rule as {@link ReportedLoadContributionSchema}: `addonId` is stamped by
30368
+ * the hub as it enumerates providers, never by the contributor.
30369
+ */
30370
+ var ReportedFailureContributionSchema = FailureContributionSchema.extend({ addonId: string() });
30117
30371
  var GetLoggingSettingsInputSchema = object({
30118
30372
  scopeNodeId: string().optional(),
30119
30373
  /**
@@ -30172,7 +30426,7 @@ method(_void(), FeatureManifestSchema), method(_void(), HealthStatusSchema), met
30172
30426
  }), method(_void(), SiteLocationStatusSchema, {
30173
30427
  kind: "mutation",
30174
30428
  auth: "admin"
30175
- }), method(_void(), RequestCensusStatusSchema, { auth: "admin" }), method(_void(), array(ReportedLoadContributionSchema).readonly(), { auth: "admin" }), method(GetLoggingSettingsInputSchema, LoggingSettingsStateSchema, { auth: "admin" }), method(SetLoggingSettingsInputSchema, LoggingSettingsStateSchema, {
30429
+ }), 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, {
30176
30430
  kind: "mutation",
30177
30431
  auth: "admin"
30178
30432
  });
@@ -34329,6 +34583,12 @@ Object.freeze({
34329
34583
  addonId: null,
34330
34584
  access: "create"
34331
34585
  },
34586
+ "failureContribution.list": {
34587
+ capName: "failure-contribution",
34588
+ capScope: "system",
34589
+ addonId: null,
34590
+ access: "view"
34591
+ },
34332
34592
  "fanControl.setDirection": {
34333
34593
  capName: "fan-control",
34334
34594
  capScope: "device",
@@ -37635,6 +37895,12 @@ Object.freeze({
37635
37895
  addonId: null,
37636
37896
  access: "create"
37637
37897
  },
37898
+ "system.getFailureContributions": {
37899
+ capName: "system",
37900
+ capScope: "system",
37901
+ addonId: null,
37902
+ access: "view"
37903
+ },
37638
37904
  "system.getLoadContributions": {
37639
37905
  capName: "system",
37640
37906
  capScope: "system",
@@ -228873,6 +229139,90 @@ function buildInitialStatus(config) {
228873
229139
  };
228874
229140
  }
228875
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
228876
229226
  //#region src/log-channels.ts
228877
229227
  /**
228878
229228
  * The diagnostic log CHANNELS `provider-reolink` declares.
@@ -230706,6 +231056,15 @@ function encodeImaAdpcm(pcm, blockSizeBytes) {
230706
231056
  var DEFAULT_BACKLOG_MS = 120;
230707
231057
  var MAX_BACKLOG_MS = 5e3;
230708
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
+ }
230709
231068
  var DEFAULT_BLOCKS_PER_PAYLOAD = 1;
230710
231069
  var DEFAULT_GAIN = 1;
230711
231070
  var MIN_GAIN = .1;
@@ -230733,6 +231092,36 @@ var ReolinkIntercomSession = class {
230733
231092
  if (!this.session) throw new Error("ReolinkIntercomSession.sampleRate read before start()");
230734
231093
  return this.session.info.audioConfig.sampleRate;
230735
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
+ }
230736
231125
  async start() {
230737
231126
  if (this.session) return;
230738
231127
  this.outputGain = clampGain(this.opts.outputGain);
@@ -230759,7 +231148,7 @@ var ReolinkIntercomSession = class {
230759
231148
  } catch {}
230760
231149
  throw new Error(`Reolink talk session reported invalid sampleRate: ${sampleRate}`);
230761
231150
  }
230762
- const wantedBacklogMs = Math.max(MIN_BACKLOG_MS, Math.min(MAX_BACKLOG_MS, this.opts.maxBacklogMs ?? DEFAULT_BACKLOG_MS));
231151
+ const wantedBacklogMs = this.backlogMs;
230763
231152
  this.maxBacklogBytes = Math.max(this.bytesPerBlock, Math.floor(wantedBacklogMs / 1e3 * sampleRate * 2));
230764
231153
  this.session = session;
230765
231154
  this.pcmBuffer = Buffer.alloc(0);
@@ -230887,6 +231276,14 @@ var IntercomOrchestrator = class {
230887
231276
  return this.session !== null && !this.session.closed;
230888
231277
  }
230889
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
+ /**
230890
231287
  * Open a fresh WebRTC peer + audio-codec decode session + Reolink
230891
231288
  * talk session, wire them, return the SDP offer. Throws (and tears
230892
231289
  * down everything it had spun up) on any failure — the cap router
@@ -231104,6 +231501,193 @@ function errMsg$1(err) {
231104
231501
  return err instanceof Error ? err.message : String(err);
231105
231502
  }
231106
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
231107
231691
  //#region src/intercom-webrtc-peer.ts
231108
231692
  var _werift;
231109
231693
  /**
@@ -234373,13 +234957,20 @@ var ReolinkCamera = class ReolinkCamera extends BaseDevice {
234373
234957
  * Called at the four points that open or close a session — and seeded at
234374
234958
  * registration, so the slice says `talking: false` from boot rather than
234375
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).
234376
234967
  */
234377
- publishIntercomState(talking) {
234968
+ publishIntercomState(talking, ability) {
234378
234969
  const previous = this.getCapSlice(intercomCapability);
234379
234970
  this.setCapSlice(intercomCapability, {
234380
234971
  talking,
234381
234972
  lastSessionAt: talking ? Date.now() : previous?.lastSessionAt ?? null,
234382
- ability: previous?.ability ?? null
234973
+ ability: ability ?? previous?.ability ?? null
234383
234974
  });
234384
234975
  }
234385
234976
  registerIntercomIfSupported() {
@@ -234418,7 +235009,7 @@ var ReolinkCamera = class ReolinkCamera extends BaseDevice {
234418
235009
  });
234419
235010
  try {
234420
235011
  const opened = await this.intercomOrchestrator.start();
234421
- this.publishIntercomState(true);
235012
+ this.publishIntercomState(true, this.intercomOrchestrator.ability ?? void 0);
234422
235013
  return opened;
234423
235014
  } catch (err) {
234424
235015
  this.publishIntercomState(false);
@@ -234440,8 +235031,10 @@ var ReolinkCamera = class ReolinkCamera extends BaseDevice {
234440
235031
  if (deviceId !== this.id) throw new Error(`ReolinkCamera: intercom deviceId mismatch, expected ${this.id}, got ${deviceId}`);
234441
235032
  if (this.disabled) throw new Error("Reolink intercom: device is disabled — re-enable it before opening a talk session");
234442
235033
  if (this.intercomRawSession) {
234443
- await this.intercomRawSession.session.stop().catch(() => {});
235034
+ const previous = this.intercomRawSession;
234444
235035
  this.intercomRawSession = null;
235036
+ await previous.pcmTranscode.close();
235037
+ await previous.session.stop().catch(() => {});
234445
235038
  }
234446
235039
  const api = await this.ensureApi();
234447
235040
  if (this.isBattery) await this.wakeForIntercom(api);
@@ -234463,9 +235056,17 @@ var ReolinkCamera = class ReolinkCamera extends BaseDevice {
234463
235056
  id,
234464
235057
  session,
234465
235058
  lastSequenceNumber: -1,
234466
- 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
+ })
234467
235068
  };
234468
- this.publishIntercomState(true);
235069
+ this.publishIntercomState(true, session.ability);
234469
235070
  this.ctx.logger.info("intercom talk session opened", {
234470
235071
  tags: { deviceId: this.id },
234471
235072
  meta: {
@@ -234477,13 +235078,24 @@ var ReolinkCamera = class ReolinkCamera extends BaseDevice {
234477
235078
  },
234478
235079
  pushTalkAudio: async ({ deviceId, audioBase64, codec, sampleRate, channels, sequenceNumber }) => {
234479
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
+ };
234480
235085
  const active = this.intercomRawSession;
234481
235086
  if (!active || !active.session.isOpen) return { accepted: false };
234482
- if (sequenceNumber <= active.lastSequenceNumber) return { accepted: false };
235087
+ if (sequenceNumber <= active.lastSequenceNumber) {
235088
+ note(REASON_TALK_OUT_OF_ORDER);
235089
+ return { accepted: false };
235090
+ }
234483
235091
  const buf = Buffer.from(audioBase64, "base64");
234484
- if (buf.length === 0) return { accepted: false };
235092
+ if (buf.length === 0) {
235093
+ note(REASON_TALK_EMPTY);
235094
+ return { accepted: false };
235095
+ }
234485
235096
  const ch = channels ?? 1;
234486
235097
  if (ch !== 1) {
235098
+ note(REASON_TALK_NOT_MONO);
234487
235099
  this.ctx.logger.warn("intercom: dropping non-mono talk frame (Reolink is mono-only)", {
234488
235100
  tags: { deviceId: this.id },
234489
235101
  meta: {
@@ -234493,8 +235105,8 @@ var ReolinkCamera = class ReolinkCamera extends BaseDevice {
234493
235105
  });
234494
235106
  return { accepted: false };
234495
235107
  }
234496
- const wireCodec = codec ?? "s16le";
234497
235108
  if (wireCodec === "g711ulaw" || wireCodec === "g711alaw") {
235109
+ note(REASON_TALK_CODEC_UNSUPPORTED);
234498
235110
  this.ctx.logger.warn("intercom: g711 passthrough not supported on Reolink (camera codec is ADPCM) — dropping frame", {
234499
235111
  tags: { deviceId: this.id },
234500
235112
  meta: { wireCodec }
@@ -234503,24 +235115,29 @@ var ReolinkCamera = class ReolinkCamera extends BaseDevice {
234503
235115
  }
234504
235116
  if (wireCodec === "s16le") {
234505
235117
  if (!sampleRate) {
235118
+ note(REASON_TALK_NO_SAMPLE_RATE);
234506
235119
  this.ctx.logger.warn("intercom: s16le push with no sampleRate — dropping (rate is ambiguous)", { tags: { deviceId: this.id } });
234507
235120
  return { accepted: false };
234508
235121
  }
234509
235122
  if (sampleRate !== active.session.sampleRate) {
234510
- this.ctx.logger.warn("intercom: s16le sampleRate mismatch (PCM-only resample not implemented) — dropping", {
234511
- tags: { deviceId: this.id },
234512
- meta: {
234513
- wireRate: sampleRate,
234514
- cameraRate: active.session.sampleRate
234515
- }
235123
+ const refusal = await active.pcmTranscode.feedResampled({
235124
+ pcm: buf,
235125
+ sourceSampleRate: sampleRate
234516
235126
  });
234517
- 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 };
234518
235134
  }
234519
235135
  active.lastSequenceNumber = sequenceNumber;
234520
235136
  active.session.feedPcm(buf);
235137
+ note();
234521
235138
  return { accepted: true };
234522
235139
  }
234523
- if (wireCodec === "opus") {
235140
+ if (wireCodec === "opus") try {
234524
235141
  if (!active.opusDecode) {
234525
235142
  const created = await this.resolveAudioCodecApi().createDecodeSession({
234526
235143
  codec: "opus",
@@ -234563,8 +235180,17 @@ var ReolinkCamera = class ReolinkCamera extends BaseDevice {
234563
235180
  const pcmBuf = Buffer.from(chunk.data.buffer, chunk.data.byteOffset, chunk.data.byteLength);
234564
235181
  if (pcmBuf.length > 0) active.session.feedPcm(pcmBuf);
234565
235182
  }
235183
+ note();
234566
235184
  return { accepted: true };
235185
+ } catch (err) {
235186
+ note(REASON_TALK_OPUS_FAILED);
235187
+ throw err;
234567
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
+ });
234568
235194
  return { accepted: false };
234569
235195
  },
234570
235196
  endTalkSession: async ({ deviceId }) => {
@@ -234572,6 +235198,7 @@ var ReolinkCamera = class ReolinkCamera extends BaseDevice {
234572
235198
  const active = this.intercomRawSession;
234573
235199
  if (!active) return;
234574
235200
  this.intercomRawSession = null;
235201
+ await active.pcmTranscode.close();
234575
235202
  if (active.opusDecode) await this.resolveAudioCodecApi().closeSession({
234576
235203
  sessionId: active.opusDecode.sessionId,
234577
235204
  nodeId: active.opusDecode.nodeId
@@ -241445,6 +242072,10 @@ var ReolinkProviderAddon = class extends BaseDeviceProvider {
241445
242072
  capability: logChannelsCapability,
241446
242073
  provider: this.logChannels
241447
242074
  });
242075
+ regs.push({
242076
+ capability: failureContributionCapability,
242077
+ provider: { list: () => intercomFailureReport.list() }
242078
+ });
241448
242079
  this.subscribe({ category: EventCategory.StreamBrokerOnRequestStreamSourceRefresh }, (event) => {
241449
242080
  const data = event.data;
241450
242081
  const deviceId = typeof data.deviceId === "number" ? data.deviceId : null;