@camstack/addon-export-hap 1.2.12 → 1.2.14

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.
@@ -1,6 +1,7 @@
1
1
  import { createRequire } from "node:module";
2
2
  import { createHash, randomBytes } from "node:crypto";
3
3
  import * as path from "node:path";
4
+ import { readFileSync } from "node:fs";
4
5
  import { spawn } from "node:child_process";
5
6
  import { Accessory, AudioBitrate, AudioStreamingCodecType, AudioStreamingSamplerate, CameraController, Categories, Characteristic, DoorbellController, H264Level, H264Profile, HAPStorage, SRTPCryptoSuites, Service, uuid } from "@homebridge/hap-nodejs";
6
7
  import * as fs from "node:fs/promises";
@@ -10,6 +11,43 @@ import { networkInterfaces } from "node:os";
10
11
  var __commonJSMin = (cb, mod) => () => (mod || (cb((mod = { exports: {} }).exports, mod), cb = null), mod.exports);
11
12
  var __require = /* @__PURE__ */ createRequire(import.meta.url);
12
13
  //#endregion
14
+ //#region src/exposed-entry.ts
15
+ /**
16
+ * Carrying an exposed-device entry across a re-expose.
17
+ *
18
+ * `exposeDevice` rebuilds its entry from scratch — display name, mapper kind,
19
+ * timestamp — and then REPLACES the stored one. Anything the rebuilt object
20
+ * does not mention is therefore destroyed, and two things it never mentioned
21
+ * were the per-camera settings and the capability list.
22
+ *
23
+ * The visible cost: the operator's "Source stream (HomeKit)" selector writes
24
+ * `low`, the addon logs `streamPreference changed — refreshing accessory
25
+ * {from=auto to=low}`, and the accessory that comes back derives its
26
+ * advertisement from DEFAULTS — `streamPreference=auto` — because the settings
27
+ * were dropped between the write and the rebuild. The selector only ever took
28
+ * effect after a full addon restart, when the settings were loaded first. A
29
+ * second write after the re-expose hid this: the store ended up correct, so
30
+ * nothing looked wrong except the stream nobody could explain.
31
+ */
32
+ /**
33
+ * Fill `base` from `existing` for the given keys, letting `base` win wherever
34
+ * it actually says something.
35
+ *
36
+ * That asymmetry is the point: a caller who passes `capabilities` is stating a
37
+ * new truth and must not be overruled by the stored copy, while a caller who
38
+ * says nothing about `settings` is not asking for them to be erased.
39
+ */
40
+ function carryForward(base, existing, keys) {
41
+ if (existing === void 0) return base;
42
+ const out = { ...base };
43
+ for (const key of keys) {
44
+ if (out[key] !== void 0) continue;
45
+ const carried = existing[key];
46
+ if (carried !== void 0) out[key] = carried;
47
+ }
48
+ return out;
49
+ }
50
+ //#endregion
13
51
  //#region ../types/dist/event-category-41fKf-q9.mjs
14
52
  var EventCategory = /* @__PURE__ */ function(EventCategory) {
15
53
  EventCategory["SystemBoot"] = "system.boot";
@@ -774,7 +812,7 @@ var propertyKeyTypes = /* @__PURE__*/ new Set([
774
812
  "number",
775
813
  "symbol"
776
814
  ]);
777
- function escapeRegex$1(str) {
815
+ function escapeRegex(str) {
778
816
  return str.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
779
817
  }
780
818
  function clone(inst, def, params) {
@@ -1504,7 +1542,7 @@ var $ZodCheckUpperCase = /*@__PURE__*/ $constructor("$ZodCheckUpperCase", (inst,
1504
1542
  });
1505
1543
  var $ZodCheckIncludes = /*@__PURE__*/ $constructor("$ZodCheckIncludes", (inst, def) => {
1506
1544
  $ZodCheck.init(inst, def);
1507
- const escapedRegex = escapeRegex$1(def.includes);
1545
+ const escapedRegex = escapeRegex(def.includes);
1508
1546
  const pattern = new RegExp(typeof def.position === "number" ? `^.{${def.position}}${escapedRegex}` : escapedRegex);
1509
1547
  def.pattern = pattern;
1510
1548
  inst._zod.onattach.push((inst) => {
@@ -1527,7 +1565,7 @@ var $ZodCheckIncludes = /*@__PURE__*/ $constructor("$ZodCheckIncludes", (inst, d
1527
1565
  });
1528
1566
  var $ZodCheckStartsWith = /*@__PURE__*/ $constructor("$ZodCheckStartsWith", (inst, def) => {
1529
1567
  $ZodCheck.init(inst, def);
1530
- const pattern = new RegExp(`^${escapeRegex$1(def.prefix)}.*`);
1568
+ const pattern = new RegExp(`^${escapeRegex(def.prefix)}.*`);
1531
1569
  def.pattern ?? (def.pattern = pattern);
1532
1570
  inst._zod.onattach.push((inst) => {
1533
1571
  const bag = inst._zod.bag;
@@ -1549,7 +1587,7 @@ var $ZodCheckStartsWith = /*@__PURE__*/ $constructor("$ZodCheckStartsWith", (ins
1549
1587
  });
1550
1588
  var $ZodCheckEndsWith = /*@__PURE__*/ $constructor("$ZodCheckEndsWith", (inst, def) => {
1551
1589
  $ZodCheck.init(inst, def);
1552
- const pattern = new RegExp(`.*${escapeRegex$1(def.suffix)}$`);
1590
+ const pattern = new RegExp(`.*${escapeRegex(def.suffix)}$`);
1553
1591
  def.pattern ?? (def.pattern = pattern);
1554
1592
  inst._zod.onattach.push((inst) => {
1555
1593
  const bag = inst._zod.bag;
@@ -2785,7 +2823,7 @@ var $ZodEnum = /*@__PURE__*/ $constructor("$ZodEnum", (inst, def) => {
2785
2823
  const values = getEnumValues(def.entries);
2786
2824
  const valuesSet = new Set(values);
2787
2825
  inst._zod.values = valuesSet;
2788
- inst._zod.pattern = new RegExp(`^(${values.filter((k) => propertyKeyTypes.has(typeof k)).map((o) => typeof o === "string" ? escapeRegex$1(o) : o.toString()).join("|")})$`);
2826
+ inst._zod.pattern = new RegExp(`^(${values.filter((k) => propertyKeyTypes.has(typeof k)).map((o) => typeof o === "string" ? escapeRegex(o) : o.toString()).join("|")})$`);
2789
2827
  inst._zod.parse = (payload, _ctx) => {
2790
2828
  const input = payload.value;
2791
2829
  if (valuesSet.has(input)) return payload;
@@ -2803,7 +2841,7 @@ var $ZodLiteral = /*@__PURE__*/ $constructor("$ZodLiteral", (inst, def) => {
2803
2841
  if (def.values.length === 0) throw new Error("Cannot create literal schema with no valid values");
2804
2842
  const values = new Set(def.values);
2805
2843
  inst._zod.values = values;
2806
- inst._zod.pattern = new RegExp(`^(${def.values.map((o) => typeof o === "string" ? escapeRegex$1(o) : o ? escapeRegex$1(o.toString()) : String(o)).join("|")})$`);
2844
+ inst._zod.pattern = new RegExp(`^(${def.values.map((o) => typeof o === "string" ? escapeRegex(o) : o ? escapeRegex(o.toString()) : String(o)).join("|")})$`);
2807
2845
  inst._zod.parse = (payload, _ctx) => {
2808
2846
  const input = payload.value;
2809
2847
  if (values.has(input)) return payload;
@@ -6496,6 +6534,65 @@ var ProfileRtspEntrySchema = object({
6496
6534
  resolution: CamStreamResolutionSchema.optional()
6497
6535
  });
6498
6536
  /**
6537
+ * Per-call node pinning for `ctx.api` capability calls.
6538
+ *
6539
+ * A capability call normally resolves to its DEFAULT provider — a `singleton`
6540
+ * cap resolves to the hub, a device-scoped cap to the device's owning node. To
6541
+ * query a SPECIFIC node's provider instead (e.g. a remote agent's own
6542
+ * in-process `platform-probe` hardware, which the hub cannot probe), pin the
6543
+ * call to that node.
6544
+ *
6545
+ * The nodeId rides OUT-OF-BAND in the tRPC call context (NOT in the validated
6546
+ * method args), so capability method signatures stay `nodeId`-free — node
6547
+ * targeting is a property of the CALL, not of the method. The transport lifts
6548
+ * it from `op.context` onto the `CapCallInput.nodeId` field (`ipcParentLink`),
6549
+ * and the hub parent's `onUnownedCall` passes it to the `CapRouteResolver`,
6550
+ * which classifies a pinned agent node as `agent-child-forward`
6551
+ * (`$agent-cap-fwd.forward` → the agent's in-process provider).
6552
+ *
6553
+ * Usage at a call site:
6554
+ *
6555
+ * await api.platformProbe.getCapabilities.query(undefined, nodePin(nodeId))
6556
+ */
6557
+ /** tRPC `op.context` key carrying a per-call node pin. */
6558
+ var CAP_NODE_PIN_CONTEXT_KEY = "__camstackNodePin";
6559
+ /**
6560
+ * Build the tRPC request options that pin a single capability call to `nodeId`.
6561
+ * Pass as the second argument to `.query(input, …)` / `.mutate(input, …)`.
6562
+ *
6563
+ * ## The id is normalised here, and it has to be
6564
+ *
6565
+ * A forked addon reads its own node from `ctx.kernel.localNodeId`, and inside a
6566
+ * worker that value is a RUNNER id — `hub/export-hap`, not `hub`. Routing
6567
+ * compares a pin against real node ids, so such a pin matches nothing and the
6568
+ * call fails with `no provider registered for cap "…"`. The local-first
6569
+ * resolver already guarded against this (`localNodeId.split('/')[0]`), which
6570
+ * made the hazard invisible: unpinned calls worked, and only an explicit pin —
6571
+ * the thing you reach for when you specifically need THIS node — silently
6572
+ * addressed a node that does not exist.
6573
+ *
6574
+ * Cost of it being missing: `addon-export-hap` pinned `decoder.getInfo` to its
6575
+ * own node to read the host's hardware-decode backend. It never once answered,
6576
+ * so every HomeKit egress transcode decoded in SOFTWARE — including 4K H.265 —
6577
+ * while D67's whole premise was that the decoder addon is the authority on
6578
+ * hardware. The warn said `decoding in SOFTWARE` and read as "this node has no
6579
+ * hardware", which was false.
6580
+ *
6581
+ * Normalising in the ONE constructor fixes every caller at once, which is why
6582
+ * it is here and not at the call sites.
6583
+ */
6584
+ function nodePin(nodeId) {
6585
+ return { context: { [CAP_NODE_PIN_CONTEXT_KEY]: toNodeId(nodeId) } };
6586
+ }
6587
+ /**
6588
+ * A runner id is `<nodeId>/<addonId>`; a node id has no slash. Taking the head
6589
+ * is idempotent, so passing an already-clean id costs nothing.
6590
+ */
6591
+ function toNodeId(idOrRunnerId) {
6592
+ const head = idOrRunnerId.split("/")[0];
6593
+ return head === void 0 || head.length === 0 ? idOrRunnerId : head;
6594
+ }
6595
+ /**
6499
6596
  * Output schema shared by the contribution + live methods.
6500
6597
  *
6501
6598
  * Mirrors the `ConfigUISchemaWithValues` shape (sections[] + optional
@@ -6943,6 +7040,39 @@ method(object({ deviceId: number() }), array(StreamSourceEntrySchema)), method(o
6943
7040
  action: string().min(1),
6944
7041
  input: unknown()
6945
7042
  }), unknown(), { kind: "mutation" }), method(object({ deviceId: number() }), _void(), { kind: "mutation" }), method(object({ deviceId: number() }), unknown().nullable()), method(object({ deviceId: number() }), RawStateResultSchema.nullable(), { auth: "protected" });
7043
+ //#endregion
7044
+ //#region ../types/dist/canonical-hash-7nfBbEqR.mjs
7045
+ /**
7046
+ * Deterministic SHA-256 hash of an arbitrary serialisable value. The
7047
+ * canonical form sorts object keys alphabetically at every depth so two
7048
+ * structurally-equal inputs with different key insertion orders produce
7049
+ * the same hash. Returns a 64-char lowercase hex digest.
7050
+ *
7051
+ * Used by export adapters (Alexa, HAP) to short-circuit re-discovery /
7052
+ * accessory-rebuild work when the upstream shape is byte-identical to
7053
+ * the last applied state — preventing user-visible "re-discovery"
7054
+ * notifications on every addon-runner respawn. Each respawn re-fires
7055
+ * `DeviceBindingsChanged` for every cap registration, which without
7056
+ * this guard would propagate redundant pushes.
7057
+ *
7058
+ * Note: this is a SYMPTOMATIC fix layered on top of the binding-change
7059
+ * subscription. The proper fix is a single "device ready" lifecycle
7060
+ * barrier so exports react only when the full cap set has landed —
7061
+ * tracked separately for post-HA-integration work.
7062
+ */
7063
+ function canonicalHash(value) {
7064
+ const canonical = JSON.stringify(value, replaceWithSortedKeys);
7065
+ return createHash("sha256").update(canonical ?? "").digest("hex");
7066
+ }
7067
+ function replaceWithSortedKeys(_key, value) {
7068
+ if (value && typeof value === "object" && !Array.isArray(value)) {
7069
+ const obj = value;
7070
+ const out = {};
7071
+ for (const k of Object.keys(obj).toSorted()) out[k] = obj[k];
7072
+ return out;
7073
+ }
7074
+ return value;
7075
+ }
6946
7076
  var EncodeProfileSchema = object({
6947
7077
  video: object({
6948
7078
  codec: _enum([
@@ -6955,6 +7085,14 @@ var EncodeProfileSchema = object({
6955
7085
  "main",
6956
7086
  "high"
6957
7087
  ]).optional(),
7088
+ /**
7089
+ * `-level`, e.g. `'3.1'`. A consumer that ADVERTISES a level in its SDP
7090
+ * (`profile-level-id=42e01f` is Baseline 3.1) must constrain the encoder to
7091
+ * it, or it ships a stream that does not match its own advertisement — the
7092
+ * defect class that kept HomeKit black for a year and that Alexa carried
7093
+ * silently. Optional because a browser negotiates the level itself.
7094
+ */
7095
+ level: string().optional(),
6958
7096
  width: number().int().positive().optional(),
6959
7097
  height: number().int().positive().optional(),
6960
7098
  fps: number().positive().optional(),
@@ -7001,6 +7139,321 @@ var EncodeProfileSchema = object({
7001
7139
  */
7002
7140
  outputArgs: array(string()).optional()
7003
7141
  });
7142
+ var AUDIO_ENCODER_BY_CODEC = {
7143
+ opus: "libopus",
7144
+ aac: "aac",
7145
+ pcmu: "pcm_mulaw",
7146
+ pcma: "pcm_alaw"
7147
+ };
7148
+ /**
7149
+ * Camera-microphone audio, per codec. Lives HERE rather than in
7150
+ * `encode-defaults.ts` only to avoid an import cycle (`encode-defaults` depends
7151
+ * on these types); it is re-exported from there, which is where to read it.
7152
+ *
7153
+ * Every source in this repo is a mono camera mic. The former broker preset
7154
+ * encoded Opus at `channels: 2`, spending bitrate duplicating one channel —
7155
+ * that is the value this consolidation changed.
7156
+ */
7157
+ var AUDIO_PRESETS = {
7158
+ aac: {
7159
+ kind: "encode",
7160
+ codec: "aac",
7161
+ bitrateKbps: 128,
7162
+ sampleRateHz: 48e3,
7163
+ channels: 1
7164
+ },
7165
+ opus: {
7166
+ kind: "encode",
7167
+ codec: "opus",
7168
+ bitrateKbps: 64,
7169
+ sampleRateHz: 48e3,
7170
+ channels: 1
7171
+ },
7172
+ pcmu: {
7173
+ kind: "encode",
7174
+ codec: "pcmu",
7175
+ sampleRateHz: 8e3,
7176
+ channels: 1
7177
+ }
7178
+ };
7179
+ /** `-hide_banner -loglevel <level>` — every ffmpeg site opens with this. */
7180
+ function logBannerArgs(level) {
7181
+ return [
7182
+ "-hide_banner",
7183
+ "-loglevel",
7184
+ level
7185
+ ];
7186
+ }
7187
+ /** `true` when the resolved value means "decode in software" (⇒ no `-hwaccel`). */
7188
+ function isSoftwareDecode(decodeHwAccel) {
7189
+ return !decodeHwAccel || decodeHwAccel === "none" || decodeHwAccel === "copy";
7190
+ }
7191
+ /**
7192
+ * Every INPUT option, in order, terminated by `-i <url>`. Nothing may be
7193
+ * appended to this list by a caller — that is the whole point of the function.
7194
+ */
7195
+ function buildInputArgs(input, decodeHwAccel) {
7196
+ const args = [];
7197
+ if (!isSoftwareDecode(decodeHwAccel)) args.push("-hwaccel", String(decodeHwAccel));
7198
+ if (input.extraArgs?.length) args.push(...input.extraArgs);
7199
+ if (input.analyzeDurationUs !== void 0) args.push("-analyzeduration", String(input.analyzeDurationUs));
7200
+ if (input.probeSizeBytes !== void 0) args.push("-probesize", String(input.probeSizeBytes));
7201
+ if (input.fflags?.length) for (const flag of input.fflags) args.push("-fflags", flag);
7202
+ if (input.rtspTransport) args.push("-rtsp_transport", input.rtspTransport);
7203
+ args.push("-i", input.url);
7204
+ return args;
7205
+ }
7206
+ /** The `-vf` filter args, or `[]` when a consumer `-vf` already claims the slot. */
7207
+ function buildVideoFilterArgs(scale, outputArgs) {
7208
+ if (!scale) return [];
7209
+ if (outputArgs.some((a) => a === "-vf")) return [];
7210
+ if (scale.mode === "exact") return ["-vf", `scale=${scale.width}:${scale.height}`];
7211
+ return ["-vf", `scale='min(${scale.width},iw)':'min(${scale.height},ih)':force_original_aspect_ratio=decrease:force_divisible_by=2`];
7212
+ }
7213
+ /** Rate-control args for an encode plan. */
7214
+ function buildRateControlArgs(video) {
7215
+ const kbps = video.bitrateKbps;
7216
+ if (kbps === void 0) return [];
7217
+ const rc = video.rateControl ?? {
7218
+ kind: "cap",
7219
+ vbvSeconds: 2
7220
+ };
7221
+ const bufsize = Math.max(1, Math.round(kbps * rc.vbvSeconds));
7222
+ return [
7223
+ ...rc.kind === "cbr" ? ["-b:v", `${kbps}k`] : [],
7224
+ "-maxrate",
7225
+ `${kbps}k`,
7226
+ "-bufsize",
7227
+ `${bufsize}k`
7228
+ ];
7229
+ }
7230
+ /** The whole video block (`-vf` … `-c:v` … knobs), after `-i`. */
7231
+ function buildVideoArgs(video, outputArgs) {
7232
+ if (video.kind === "copy") return [
7233
+ "-c:v",
7234
+ "copy",
7235
+ ...video.bitstreamFilter ? ["-bsf:v", video.bitstreamFilter] : []
7236
+ ];
7237
+ const args = [
7238
+ ...buildVideoFilterArgs(video.scale, outputArgs),
7239
+ "-c:v",
7240
+ video.encoder
7241
+ ];
7242
+ if (video.preset !== void 0) args.push("-preset", video.preset);
7243
+ if (video.tune !== void 0) args.push("-tune", video.tune);
7244
+ if (video.profile !== void 0) args.push("-profile:v", video.profile);
7245
+ if (video.level !== void 0) args.push("-level", video.level);
7246
+ if (video.pixelFormat !== void 0) args.push("-pix_fmt", video.pixelFormat);
7247
+ if (video.fps !== void 0) args.push("-r", String(video.fps));
7248
+ if (video.gopFrames !== void 0) args.push("-g", String(video.gopFrames));
7249
+ if (video.forceKeyFramesSeconds !== void 0) args.push("-force_key_frames", `expr:gte(t,n_forced*${video.forceKeyFramesSeconds})`);
7250
+ if (video.bf !== void 0) args.push("-bf", String(video.bf));
7251
+ args.push(...buildRateControlArgs(video));
7252
+ if (video.bitstreamFilter !== void 0) args.push("-bsf:v", video.bitstreamFilter);
7253
+ return args;
7254
+ }
7255
+ /** The whole audio block, after `-i`. */
7256
+ function buildAudioArgs(audio) {
7257
+ if (audio.kind === "none") return ["-an"];
7258
+ if (audio.kind === "copy") return ["-c:a", "copy"];
7259
+ const args = [];
7260
+ if (audio.filter !== void 0) args.push("-af", audio.filter);
7261
+ args.push("-c:a", AUDIO_ENCODER_BY_CODEC[audio.codec]);
7262
+ if (audio.application !== void 0) args.push("-application", audio.application);
7263
+ if (audio.frameDurationMs !== void 0) args.push("-frame_duration", String(audio.frameDurationMs));
7264
+ if (audio.globalHeader === true) args.push("-flags", "+global_header");
7265
+ if (audio.sampleRateHz !== void 0) args.push("-ar", String(audio.sampleRateHz));
7266
+ if (audio.bitrateKbps !== void 0) args.push("-b:a", `${audio.bitrateKbps}k`);
7267
+ if (audio.vbvBufferKbits !== void 0) args.push("-bufsize", `${audio.vbvBufferKbits}k`);
7268
+ if (audio.channels !== void 0) args.push("-ac", String(audio.channels));
7269
+ return args;
7270
+ }
7271
+ /** RTP output-leg args (`-payload_type`, `-ssrc`, `-sdp_file`, `-f rtp <url>`). */
7272
+ function buildRtpOutputArgs(out) {
7273
+ const args = [];
7274
+ if (out.payloadType !== void 0) args.push("-payload_type", String(out.payloadType));
7275
+ if (out.ssrc !== void 0) args.push("-ssrc", String(out.ssrc));
7276
+ if (out.sdpFile !== void 0) args.push("-sdp_file", out.sdpFile);
7277
+ args.push("-f", "rtp", out.url);
7278
+ return args;
7279
+ }
7280
+ /** `true` when the sink is a raw elementary bytestream that cannot mux audio. */
7281
+ function isElementaryVideoSink(sink) {
7282
+ return sink.kind === "stdout" && (sink.container === "h264" || sink.container === "hevc");
7283
+ }
7284
+ /**
7285
+ * The fragmented-MP4 muxer flags, in the order the recorder has proven them
7286
+ * (`recorder/addon/ffmpeg-args.ts` passes the same `movflags` string through
7287
+ * `-segment_format_options`, across every vendor in the fleet):
7288
+ *
7289
+ * - `frag_keyframe` — cut a fragment at each key frame, so every fragment
7290
+ * opens on a sync sample. HKSV's whole requirement.
7291
+ * - `empty_moov` — write `ftyp`+`moov` up front with no samples in it, which
7292
+ * is what makes the head a standalone INITIALISATION segment.
7293
+ * - `default_base_moof` — fragment offsets are self-relative, so a fragment is
7294
+ * demuxable without the bytes that preceded it. D31's byte-range read path
7295
+ * depends on exactly this property of the recorder's segments.
7296
+ */
7297
+ var FMP4_MOVFLAGS = "+frag_keyframe+empty_moov+default_base_moof";
7298
+ /**
7299
+ * The terminal sink args for every non-`rtp-outputs` sink. Exhaustive over the
7300
+ * union so a new member cannot fall through to `['-f', container, 'pipe:1']`,
7301
+ * which is what a plain `container` read would have done for `mp4` — a valid
7302
+ * argv that writes a NON-fragmented, unseekable-to-a-pipe MP4 and produces one
7303
+ * unusable byte stream.
7304
+ */
7305
+ function buildStdoutOrRtspSinkArgs(sink) {
7306
+ if (sink.kind === "rtsp-listen") return [
7307
+ "-f",
7308
+ "rtsp",
7309
+ "-rtsp_transport",
7310
+ "tcp",
7311
+ "-rtsp_flags",
7312
+ "listen",
7313
+ sink.url
7314
+ ];
7315
+ if (sink.kind === "rtp-outputs") return [];
7316
+ return sink.container === "mp4" ? buildFmp4SinkArgs(sink) : [
7317
+ "-f",
7318
+ sink.container,
7319
+ "pipe:1"
7320
+ ];
7321
+ }
7322
+ /** `-movflags … -min_frag_duration <us> -f mp4 pipe:1`. */
7323
+ function buildFmp4SinkArgs(sink) {
7324
+ return [
7325
+ "-movflags",
7326
+ FMP4_MOVFLAGS,
7327
+ "-min_frag_duration",
7328
+ String(Math.max(0, Math.round(sink.fragmentMs * 1e3))),
7329
+ "-f",
7330
+ "mp4",
7331
+ "pipe:1"
7332
+ ];
7333
+ }
7334
+ /**
7335
+ * A second output mapping source audio to RTP-over-UDP. `0:a:0?` makes the
7336
+ * audio optional so a source with no audio skips it instead of failing the
7337
+ * whole invocation.
7338
+ */
7339
+ function buildAudioSidecarArgs(sidecar) {
7340
+ return [
7341
+ "-map",
7342
+ "0:a:0?",
7343
+ ...buildAudioArgs(sidecar.codec === "pcma" ? {
7344
+ kind: "encode",
7345
+ codec: "pcma",
7346
+ sampleRateHz: 8e3,
7347
+ channels: 1
7348
+ } : AUDIO_PRESETS[sidecar.codec]),
7349
+ ...buildRtpOutputArgs({
7350
+ url: sidecar.rtpUrl,
7351
+ sdpFile: sidecar.sdpFile
7352
+ })
7353
+ ];
7354
+ }
7355
+ /**
7356
+ * Assemble the full ffmpeg argument list. Layout:
7357
+ *
7358
+ * -hide_banner -loglevel <level>
7359
+ * [-hwaccel <backend|auto>] ─┐ INPUT options — strictly before -i.
7360
+ * [<input.extraArgs>] │
7361
+ * [-fflags <flag>…] │
7362
+ * [-rtsp_transport tcp] │
7363
+ * -i <url> ─┘
7364
+ * <video block> <threads> <audio block> ─┐ OUTPUT options.
7365
+ * <consumer outputArgs verbatim> │
7366
+ * <sink> ─┘ terminal
7367
+ */
7368
+ function buildFfmpegArgs(inv) {
7369
+ const head = [...logBannerArgs(inv.logLevel), ...buildInputArgs(inv.input, inv.decodeHwAccel)];
7370
+ const threadArgs = inv.threadCount > 0 ? ["-threads", String(inv.threadCount)] : [];
7371
+ if (inv.sink.kind === "rtp-outputs") {
7372
+ const videoLeg = inv.sink.video ? [
7373
+ "-an",
7374
+ "-map",
7375
+ "0:v:0",
7376
+ ...buildVideoArgs(inv.video, inv.outputArgs),
7377
+ ...threadArgs,
7378
+ ...inv.outputArgs,
7379
+ ...buildRtpOutputArgs(inv.sink.video)
7380
+ ] : [];
7381
+ const audioLeg = inv.sink.audio ? [
7382
+ "-vn",
7383
+ "-map",
7384
+ "0:a:0?",
7385
+ ...buildAudioArgs(inv.audio),
7386
+ ...buildRtpOutputArgs(inv.sink.audio)
7387
+ ] : [];
7388
+ return [
7389
+ ...head,
7390
+ ...videoLeg,
7391
+ ...audioLeg
7392
+ ];
7393
+ }
7394
+ const audioArgs = isElementaryVideoSink(inv.sink) ? ["-an"] : buildAudioArgs(inv.audio);
7395
+ const sinkArgs = buildStdoutOrRtspSinkArgs(inv.sink);
7396
+ return [
7397
+ ...head,
7398
+ ...buildVideoArgs(inv.video, inv.outputArgs),
7399
+ ...threadArgs,
7400
+ ...audioArgs,
7401
+ ...inv.outputArgs,
7402
+ ...sinkArgs,
7403
+ ...inv.audioSidecar ? buildAudioSidecarArgs(inv.audioSidecar) : []
7404
+ ];
7405
+ }
7406
+ /**
7407
+ * The shape every live egress starts from: H.264 Baseline 3.1 at 720p25.
7408
+ * Baseline because it is the one profile every consumer in this repo decodes
7409
+ * (Echo, iOS, an old browser); 3.1 because that is what the SDPs advertise.
7410
+ */
7411
+ var BASE_LIVE_EGRESS_PROFILE = {
7412
+ video: {
7413
+ codec: "h264",
7414
+ profile: "baseline",
7415
+ level: "3.1",
7416
+ width: 1280,
7417
+ height: 720,
7418
+ fps: 25,
7419
+ bitrateKbps: 2500,
7420
+ gopFrames: 25,
7421
+ bf: 0,
7422
+ preset: "veryfast",
7423
+ tune: "zerolatency"
7424
+ },
7425
+ audio: "passthrough"
7426
+ };
7427
+ ({ ...BASE_LIVE_EGRESS_PROFILE }), { ...BASE_LIVE_EGRESS_PROFILE.video };
7428
+ ({ ...BASE_LIVE_EGRESS_PROFILE });
7429
+ /** VBV window for a consumer whose budget is enforced per second (HomeKit). */
7430
+ var RATE_CONTROL_TIGHT = {
7431
+ kind: "cbr",
7432
+ vbvSeconds: 1
7433
+ };
7434
+ var HAP_AUDIO_BASE = {
7435
+ kind: "encode",
7436
+ codec: "opus",
7437
+ bitrateKbps: 24,
7438
+ channels: 1,
7439
+ application: "lowdelay",
7440
+ globalHeader: true,
7441
+ filter: "aresample=async=1000:first_pts=0"
7442
+ };
7443
+ function createHwAccelCache(options) {
7444
+ const now = options.now ?? (() => Date.now());
7445
+ let value = null;
7446
+ let writtenAt = Number.NEGATIVE_INFINITY;
7447
+ return {
7448
+ read() {
7449
+ return now() - writtenAt < options.ttlMs ? value : void 0;
7450
+ },
7451
+ write(next) {
7452
+ value = next;
7453
+ writtenAt = now();
7454
+ }
7455
+ };
7456
+ }
7004
7457
  /**
7005
7458
  * Deep wiring healthcheck — snapshot of active reachability probes across
7006
7459
  * every declared capability + widget of every installed plugin, on every
@@ -7051,6 +7504,154 @@ object({
7051
7504
  })
7052
7505
  });
7053
7506
  /**
7507
+ * Per-camera FUNCTION SWITCHES — the one coherent on/off surface over the
7508
+ * pipeline functions an operator thinks in terms of.
7509
+ *
7510
+ * ## This file adds no state
7511
+ *
7512
+ * Every switch here is a VIEW onto an authority that already existed
7513
+ * ([D62](../../../../docs/decisions/adr-0062.md)). The whole point of the
7514
+ * group is that there is exactly one place each function is turned off, and
7515
+ * the group routes to it:
7516
+ *
7517
+ * | Switch | Authority | Proven "off stops the work" gate |
7518
+ * | --- | --- | --- |
7519
+ * | `stream-broker` | `deviceManager.setDisabled` | `StreamBrokerManager.reconcileAllCatalogs` releases the brokers; `ensureBroker` refuses re-creation |
7520
+ * | `object-detection` | `deviceManager.setWrapperActive('detection-pipeline')` | `PipelineSettingsStore.resolvePipelineForDevice` returns `{ steps: [], audio: null }` |
7521
+ * | `audio-analysis` | `deviceManager.setWrapperActive('audio-analysis')` | `AudioSubscriptionController.subscribeAudioStream` returns `null` before opening the stream |
7522
+ * | `recording` | `recording.setDeviceConfig` → `RecordingConfig.enabled` | `band-decision.shouldRecord` returns false; the controller detaches the device |
7523
+ * | `notifications` | `notificationRules.setDeviceMuted` | `NotificationCenter.evaluateAndEnqueue` returns before any rule is evaluated |
7524
+ * | `privacy-mask` | `privacyMask.setMask({ enabled })` → the CAMERA | the camera blanks the masked regions itself; every stream and recording carries the black boxes |
7525
+ * | `device-audio` | `privacyMask.setAudioEnabled` → the CAMERA | the camera stops encoding an audio track at all; every consumer sees silent video |
7526
+ *
7527
+ * ## The two switches whose authority is not on this server
7528
+ *
7529
+ * `privacy-mask` and `device-audio` write the CAMERA. That is not a loophole
7530
+ * in "the group stores nothing" — it is the purest form of it: the camera
7531
+ * holds the fact, every read is a read-through, and there is no server-side
7532
+ * copy that could drift. Their availability therefore cannot come from
7533
+ * `listBindableCapsForDeviceType` (a device-NATIVE cap carries no wrappers and
7534
+ * is filtered out there); it comes from the cap's own camera-probed
7535
+ * `privacyMask.getOptions()`, which is strictly more honest — it answers for
7536
+ * THIS camera rather than for the device type
7537
+ * ([D74](../../../../docs/decisions/adr-0074.md)).
7538
+ *
7539
+ * ## `privacy-mask` is the one row whose ON is not "the function is working"
7540
+ *
7541
+ * Every other switch means *this camera's function is doing its job*, so
7542
+ * `enabled: false` is a thing an operator took away. `privacy-mask` means **the
7543
+ * MASK is active** — `enabled: true` is video deliberately obscured. The
7544
+ * polarity is not a choice made here: `addon-export-hap`'s privacy `Switch`
7545
+ * (`builders/privacy-switch.ts`) already mirrors `patch.enabled` verbatim, and
7546
+ * a HomeKit toggle that disagreed with the app's toggle for the same camera is
7547
+ * worse than either surface not having one.
7548
+ *
7549
+ * Two consequences follow and both are load-bearing:
7550
+ *
7551
+ * - **It never counts as `switchedOff`.** `countsAsSwitchedOff` is `false` for
7552
+ * exactly this row. With the polarity above, every camera that has NOT drawn
7553
+ * a privacy mask would otherwise report `switchedOff: ['privacy-mask']` — the
7554
+ * normal, healthy state of most cameras rendered as an operator disablement.
7555
+ * - **Its cost line names BOTH directions.** `costWhenOff` is rendered
7556
+ * unconditionally by both clients, so for this row it has to read correctly
7557
+ * whichever way the switch is sitting.
7558
+ *
7559
+ * The wrapper-binding pair is not a new idea: `legacy-migrations.ts` already
7560
+ * migrated the legacy `audioEnabled` / `pipelineEnabled` /
7561
+ * `motionDetectionEnabled` booleans ONTO `setWrapperActive`. The group is the
7562
+ * surface that decision never got.
7563
+ *
7564
+ * ## Two rules that are load-bearing
7565
+ *
7566
+ * - **Recording's switch is `enabled`, never the bands.** `bands` is the only
7567
+ * authored intent and `mode` is derived from it (`deriveRecordingMode`).
7568
+ * Expressing "off" by clearing bands destroys the operator's schedule and
7569
+ * turning the camera back on would then silently record nothing.
7570
+ * - **A switch that is off must be reported as off**, not merely produce
7571
+ * nothing. {@link CameraSwitch.enabled} is what a status surface renders as
7572
+ * "disabled by an operator" instead of "broken" — see
7573
+ * `CameraStatus.switchedOff`.
7574
+ */
7575
+ /**
7576
+ * The functions the operator named — five on 2026-08-05, plus the camera's own
7577
+ * microphone on 2026-08-07. Deliberately NOT one id per pipeline step: face
7578
+ * recognition and plate/LPR are per-step toggles on
7579
+ * `pipelineOrchestrator.setCameraStepToggle` and belong in the pipeline
7580
+ * editor, not in a safety group.
7581
+ */
7582
+ var CameraSwitchIdSchema = _enum([
7583
+ "stream-broker",
7584
+ "object-detection",
7585
+ "privacy-mask",
7586
+ "device-audio",
7587
+ "audio-analysis",
7588
+ "recording",
7589
+ "notifications"
7590
+ ]);
7591
+ /**
7592
+ * WHERE the switch's state actually lives. A discriminated union rather than a
7593
+ * string so both the writer (the orchestrator's `setCameraSwitch`) and any
7594
+ * reader can exhaustively narrow — and so "the group added a parallel map" is
7595
+ * a compile error rather than a review comment.
7596
+ */
7597
+ var CameraSwitchAuthoritySchema = discriminatedUnion("kind", [
7598
+ object({ kind: literal("device-disabled") }),
7599
+ object({
7600
+ kind: literal("wrapper-binding"),
7601
+ capName: string()
7602
+ }),
7603
+ object({ kind: literal("recording-config") }),
7604
+ object({ kind: literal("notification-mute") }),
7605
+ object({
7606
+ kind: literal("camera-audio"),
7607
+ capName: string()
7608
+ }),
7609
+ object({
7610
+ kind: literal("camera-mask"),
7611
+ capName: string()
7612
+ })
7613
+ ]);
7614
+ /**
7615
+ * Why a switch is not offered for this camera. Rendered instead of the
7616
+ * control, never as a dead control — an absent function and a broken one must
7617
+ * not look the same.
7618
+ */
7619
+ var CameraSwitchUnavailableReasonSchema = _enum([
7620
+ "no-provider",
7621
+ "source-unreachable",
7622
+ "not-configured"
7623
+ ]);
7624
+ /**
7625
+ * One switch, resolved for one camera.
7626
+ *
7627
+ * `label` and `costWhenOff` travel ON THE WIRE rather than being looked up
7628
+ * client-side: the viewer is a separate repository that does not import
7629
+ * `@camstack/types`, and a cost line duplicated in two clients is a cost line
7630
+ * that will disagree with itself. Five rows per camera is nothing.
7631
+ */
7632
+ var CameraSwitchSchema = object({
7633
+ id: CameraSwitchIdSchema,
7634
+ label: string(),
7635
+ /**
7636
+ * What the operator LOSES while this is off, in one sentence. Required, not
7637
+ * optional: a switch that cannot say what it costs should not ship.
7638
+ */
7639
+ costWhenOff: string(),
7640
+ /** False = do not render a control. `unavailableReason` says why. */
7641
+ available: boolean(),
7642
+ unavailableReason: CameraSwitchUnavailableReasonSchema.optional(),
7643
+ /** Current state. Meaningless when `available` is false — read it as `true`. */
7644
+ enabled: boolean(),
7645
+ authority: CameraSwitchAuthoritySchema
7646
+ });
7647
+ /** The whole group for one camera. */
7648
+ var CameraSwitchGroupSchema = object({
7649
+ deviceId: number().int(),
7650
+ switches: array(CameraSwitchSchema).readonly(),
7651
+ /** Unix ms when the group was composed server-side. */
7652
+ fetchedAt: number()
7653
+ });
7654
+ /**
7054
7655
  * Ops-log — the durable, append-only operations audit shared by the
7055
7656
  * recordings and events management surfaces.
7056
7657
  *
@@ -7069,14 +7670,16 @@ var OpsLogOpSchema = _enum([
7069
7670
  "manual-delete",
7070
7671
  "rescan",
7071
7672
  "retention-run",
7072
- "relocate"
7673
+ "relocate",
7674
+ "orphan-audit"
7073
7675
  ]);
7074
7676
  /** Why the operation ran. */
7075
7677
  var OpsLogReasonSchema = _enum([
7076
7678
  "retention",
7077
7679
  "quota",
7078
7680
  "manual",
7079
- "operator"
7681
+ "operator",
7682
+ "maintenance"
7080
7683
  ]);
7081
7684
  /** One audit row, shared verbatim by both domains. */
7082
7685
  var OpsLogEntrySchema = object({
@@ -8991,6 +9594,126 @@ var RtpSourceSchema = object({
8991
9594
  encoder: string(),
8992
9595
  pipelineKey: string()
8993
9596
  });
9597
+ /**
9598
+ * The encode request — **structured and serialisable, with NO raw-flag escape
9599
+ * hatch.** This is deliberate and it is the one lesson taken from
9600
+ * `getStreamWithCodec`: that method's `outputArgs: string[]` is simultaneously
9601
+ * its extensibility mechanism AND part of `pipelineKeyFor`'s sharing key, so
9602
+ * adding a flag silently forks the shared child, and two consumers that mean
9603
+ * the same thing but spell it differently never share. Here every knob is a
9604
+ * NAMED field: a new requirement becomes a schema field (and a codegen run),
9605
+ * never an opaque array.
9606
+ *
9607
+ * `inputArgs` / `outputArgs` are omitted from the profile for the same reason.
9608
+ * The operator-facing derived-stream transform editor still has them — that is
9609
+ * a different surface (`publishCameraStream({ kind: 'derived' })`) with a
9610
+ * different purpose (reshaping a badly-behaved SOURCE), and it is unchanged.
9611
+ */
9612
+ var EgressEncodeSchema = EncodeProfileSchema.omit({
9613
+ inputArgs: true,
9614
+ outputArgs: true
9615
+ });
9616
+ /**
9617
+ * How the encoder is bounded. `'tight'` is a one-second VBV window for a
9618
+ * consumer whose budget is enforced per second (HomeKit); `'relaxed'` is two
9619
+ * seconds, letting a keyframe spike borrow from the next second (a browser,
9620
+ * an Echo). Named rather than numeric so the INTENT survives.
9621
+ */
9622
+ var EgressRateControlSchema = _enum(["tight", "relaxed"]);
9623
+ var EgressTranscodeRequestSchema = object({
9624
+ deviceId: number().int().nonnegative(),
9625
+ /** Which published stream to read. */
9626
+ source: discriminatedUnion("kind", [object({
9627
+ kind: literal("profile"),
9628
+ profile: CamProfileSchema
9629
+ }), object({
9630
+ kind: literal("cam-stream"),
9631
+ camStreamId: string().min(1)
9632
+ })]),
9633
+ encode: EgressEncodeSchema,
9634
+ rateControl: EgressRateControlSchema.optional(),
9635
+ /**
9636
+ * `-bsf:v`. A consumer that negotiates its OWN SDP (HomeKit) cannot carry
9637
+ * out-of-band extradata and needs `dump_extra` on both the copy and encode
9638
+ * branches. Enumerated, not free text.
9639
+ */
9640
+ bitstreamFilter: _enum([
9641
+ "dump_extra",
9642
+ "h264_mp4toannexb",
9643
+ "hevc_mp4toannexb"
9644
+ ]).optional(),
9645
+ /**
9646
+ * Publish the transcode as a LOCAL push cam stream, instead of leaving the
9647
+ * consumer to dial the returned url. The broker picks the id and returns it
9648
+ * as `camStreamId` — a caller-supplied one would be circular, since the
9649
+ * sharing key is computed FROM this request.
9650
+ *
9651
+ * The url is still returned and still the contract for a transcode pinned to
9652
+ * another node. But dialling it locally costs an RTSP round trip that changes
9653
+ * the transport underneath the consumer: a dialled stream is an RTP source,
9654
+ * so `isRtpSource()` is true and the session takes the RTP-passthrough +
9655
+ * repacketizer branch. The push branch — the one the derived mechanism has
9656
+ * live hours on — is never reached. Measured on Alexa: broker registered, RTP
9657
+ * arriving, key frame arriving, black screen, on a chain healthy at every
9658
+ * other point.
9659
+ *
9660
+ * Same idea the transport already applies to CALLS, where `classifyCapRoute`
9661
+ * gives priority to `hub-in-process` so a local call never leaves the node.
9662
+ * This is that rule for media.
9663
+ */
9664
+ publishLocally: boolean().optional(),
9665
+ pixelFormat: _enum(["yuv420p", "nv12"]).optional(),
9666
+ /**
9667
+ * Operator/consumer override for decode hardware. ABSENT is the normal case
9668
+ * and the one that matters: the broker then resolves the backend from the
9669
+ * DECODER ADDON's per-node `probedBestHwaccel` (see
9670
+ * `@camstack/types` `ffmpeg/hwaccel.ts`), which is the ranking known to work
9671
+ * on this hardware — never the raw kernel resolver's qsv-first order.
9672
+ */
9673
+ decodeHwAccel: _enum([
9674
+ "auto",
9675
+ "none",
9676
+ "videotoolbox",
9677
+ "vaapi",
9678
+ "qsv",
9679
+ "cuda"
9680
+ ]).optional(),
9681
+ /**
9682
+ * Host to embed in the returned restream `url`. The broker mints hub-local
9683
+ * `127.0.0.1` URLs; a consumer on another node passes a cluster-resolvable
9684
+ * host (`NodeTopologyService.reachableHostByNode`) so the returned URL is
9685
+ * dialable from there. Same contract as `getStreamWithCodec.hostname` —
9686
+ * `substituteRtspHost` rewrites only the dial address, never the restreamer.
9687
+ */
9688
+ hostname: string().optional(),
9689
+ /** Attribution for the broker panel. Never part of the sharing key. */
9690
+ tag: string().optional()
9691
+ });
9692
+ var EgressTranscodeSchema = object({
9693
+ /** Dial-able RTSP url (host-substituted when `hostname` was supplied). */
9694
+ url: string(),
9695
+ /** Release handle. Refcounted — the child dies when the last holder releases. */
9696
+ pipelineKey: string(),
9697
+ videoCodec: _enum(["H264", "H265"]),
9698
+ resolution: object({
9699
+ width: number().int().positive(),
9700
+ height: number().int().positive()
9701
+ }),
9702
+ transcoded: boolean(),
9703
+ encoder: string(),
9704
+ /**
9705
+ * The decode backend the child ACTUALLY ran with — `null` for software.
9706
+ * Returned rather than assumed: a consumer that asked for hardware and got
9707
+ * software needs to be able to see that without reading the broker's logs.
9708
+ */
9709
+ decodeHwAccel: string().nullable(),
9710
+ /**
9711
+ * Set when `publishLocally` was honoured: attach to THIS instead of dialling
9712
+ * `url`, and the session takes the push/deframe transport rather than the
9713
+ * RTP-passthrough one. `null` means the consumer must dial.
9714
+ */
9715
+ camStreamId: string().nullable()
9716
+ });
8994
9717
  method(object({
8995
9718
  deviceId: number().int().nonnegative(),
8996
9719
  camStreamId: string().min(1),
@@ -9100,6 +9823,15 @@ method(object({
9100
9823
  }), {
9101
9824
  kind: "mutation",
9102
9825
  auth: "admin"
9826
+ }), method(EgressTranscodeRequestSchema, EgressTranscodeSchema, {
9827
+ kind: "mutation",
9828
+ auth: "admin"
9829
+ }), method(object({ pipelineKey: string() }), object({
9830
+ released: boolean(),
9831
+ refcount: number().int().nonnegative()
9832
+ }), {
9833
+ kind: "mutation",
9834
+ auth: "admin"
9103
9835
  }), method(SubscribeAudioChunksInputSchema, SubscribeAudioChunksResultSchema, { kind: "mutation" }), method(object({
9104
9836
  subscriptionId: string(),
9105
9837
  maxCount: number().int().positive().default(8)
@@ -9554,6 +10286,62 @@ method(_void(), EngineInfoSchema), method(object({
9554
10286
  indexes: array(CollectionIndexSchema).readonly().optional()
9555
10287
  }), _void(), { kind: "mutation" });
9556
10288
  /**
10289
+ * Stable UI option list for the `hwaccel` setting. Decoder addons
10290
+ * reuse this for `globalSettingsSchema()` so the dropdown is
10291
+ * identical everywhere. Order: auto → off → common backends by
10292
+ * platform affinity (macOS, NVIDIA, Intel/AMD, Windows, Linux).
10293
+ */
10294
+ var HWACCEL_OPTIONS = [
10295
+ {
10296
+ value: "auto",
10297
+ label: "Auto (defer to probed best)"
10298
+ },
10299
+ {
10300
+ value: "none",
10301
+ label: "Off (software)"
10302
+ },
10303
+ {
10304
+ value: "videotoolbox",
10305
+ label: "VideoToolbox (macOS)"
10306
+ },
10307
+ {
10308
+ value: "cuda",
10309
+ label: "CUDA (NVIDIA)"
10310
+ },
10311
+ {
10312
+ value: "nvdec",
10313
+ label: "NVDEC (NVIDIA legacy)"
10314
+ },
10315
+ {
10316
+ value: "vaapi",
10317
+ label: "VAAPI (Linux Intel/AMD)"
10318
+ },
10319
+ {
10320
+ value: "qsv",
10321
+ label: "QuickSync (Intel)"
10322
+ },
10323
+ {
10324
+ value: "d3d11va",
10325
+ label: "D3D11VA (Windows)"
10326
+ },
10327
+ {
10328
+ value: "dxva2",
10329
+ label: "DXVA2 (Windows legacy)"
10330
+ },
10331
+ {
10332
+ value: "amf",
10333
+ label: "AMF (AMD)"
10334
+ },
10335
+ {
10336
+ value: "vdpau",
10337
+ label: "VDPAU (Linux NVIDIA legacy)"
10338
+ },
10339
+ {
10340
+ value: "drm",
10341
+ label: "DRM (Linux generic)"
10342
+ }
10343
+ ];
10344
+ /**
9557
10345
  * shm ring usage stats for a `frameSink: 'shm'` decoder session —
9558
10346
  * exposed via `decoder.getShmStats` so downstream consumers can
9559
10347
  * observe ring pressure (slot count, byte budget, hit/miss ratio).
@@ -12903,7 +13691,7 @@ var DETECTION_SUB_COLORS = {
12903
13691
  zebra: "#404040",
12904
13692
  giraffe: "#d4a373"
12905
13693
  };
12906
- function titleCase(id) {
13694
+ function titleCase$1(id) {
12907
13695
  return id.split(/[-_ ]/).filter((p) => p.length > 0).map((p) => p.charAt(0).toUpperCase() + p.slice(1)).join(" ");
12908
13696
  }
12909
13697
  var entries = /* @__PURE__ */ new Map();
@@ -12942,7 +13730,7 @@ macro("control", "control", TAXONOMY_COLORS.control, "control", "Control");
12942
13730
  for (const [cocoClass, macroClass] of Object.entries(COCO_TO_MACRO.mapping)) {
12943
13731
  if (macroClass !== "vehicle" && macroClass !== "animal") continue;
12944
13732
  if (entries.has(cocoClass)) continue;
12945
- sub(cocoClass, macroClass, "detection", DETECTION_SUB_COLORS[cocoClass] ?? TAXONOMY_COLORS.genericDetection, cocoClass, titleCase(cocoClass));
13733
+ sub(cocoClass, macroClass, "detection", DETECTION_SUB_COLORS[cocoClass] ?? TAXONOMY_COLORS.genericDetection, cocoClass, titleCase$1(cocoClass));
12946
13734
  }
12947
13735
  sub("package-delivered", "package", "package", TAXONOMY_COLORS.package, "package", "Package delivered");
12948
13736
  sub("package-picked-up", "package", "package", TAXONOMY_COLORS.package, "package", "Package picked up");
@@ -13451,12 +14239,13 @@ var NcConditionsSchema = object({
13451
14239
  * source; otherwise the subject's source must equal it. Legacy records
13452
14240
  * with no stamped source are treated as `pipeline`. The union spans both
13453
14241
  * record kinds — object events carry `pipeline` | `onboard`, synthetic
13454
- * tracks carry `sensor`.
14242
+ * tracks carry `sensor` (a linked device) or `audio` (a D62 audio marker).
13455
14243
  */
13456
14244
  source: _enum([
13457
14245
  "pipeline",
13458
14246
  "onboard",
13459
14247
  "sensor",
14248
+ "audio",
13460
14249
  "any"
13461
14250
  ]).optional(),
13462
14251
  /**
@@ -14032,6 +14821,12 @@ method(object({}), object({ rules: array(NcRuleSchema) }), { auth: "admin" }), m
14032
14821
  }), object({ success: literal(true) }), {
14033
14822
  kind: "mutation",
14034
14823
  auth: "admin"
14824
+ }), method(object({}), object({ mutedDeviceIds: array(number().int()).readonly() }), { auth: "admin" }), method(object({
14825
+ deviceId: number().int(),
14826
+ muted: boolean()
14827
+ }), object({ success: literal(true) }), {
14828
+ kind: "mutation",
14829
+ auth: "admin"
14035
14830
  }), method(object({
14036
14831
  rule: NcRuleInputSchema,
14037
14832
  lookbackMinutes: number().int().min(1).max(1440).default(60)
@@ -14370,12 +15165,60 @@ var TrackAudioLabelSchema = object({
14370
15165
  });
14371
15166
  /**
14372
15167
  * How a track was produced. `pipeline` (default / absent) = the spatial
14373
- * detection+tracking pipeline. `sensor` = a SYNTHETIC track projected from a
14374
- * linked sensor/control state change (no positions; carries a snapshot). The
14375
- * spatial subsystems (tracker association, occupancy count, re-id/embedding,
14376
- * resurrection) MUST skip `sensor` tracks they have no bbox trajectory.
15168
+ * detection+tracking pipeline. Every OTHER value is a SYNTHETIC projection
15169
+ * no positions, a single snapshot, and no bbox trajectory at all:
15170
+ *
15171
+ * - `sensor` — a linked sensor/control device state change.
15172
+ * - `audio` — an audio event on the camera itself that was anomalous for
15173
+ * THAT camera, loud, and heard while nothing visual was happening (D62).
15174
+ *
15175
+ * The spatial subsystems (tracker association, occupancy count, re-id /
15176
+ * embedding, resurrection) MUST skip every synthetic source. Test for that
15177
+ * with `isSpatialTrack`, which allow-lists `pipeline` — a `!== 'sensor'`
15178
+ * check silently readmits every source added after it was written.
15179
+ */
15180
+ var TrackSourceSchema = _enum([
15181
+ "pipeline",
15182
+ "sensor",
15183
+ "audio"
15184
+ ]);
15185
+ /**
15186
+ * Per-track OPERATOR flags — set by hand from the admin UI or the viewer, never
15187
+ * by the pipeline. Spread into `TrackSchema` and `KeyEventSchema` from one place
15188
+ * so the two surfaces cannot drift.
15189
+ *
15190
+ * **Absent ≠ false.** A track that has never been touched omits the field; an
15191
+ * explicitly un-flagged track carries `false`. Legacy rows written before the
15192
+ * columns existed read as absent, and a consumer that needs a boolean should say
15193
+ * `flag === true`, not `flag !== false`.
15194
+ *
15195
+ * What the flags DO is deliberately UNDEFINED at the time of writing: they are
15196
+ * operator curation, and the behaviour they drive will be specified separately.
15197
+ * In particular a `markForTrain` track is NOT pinned against retention — see
15198
+ * `docs/decisions/adr-0059.md` for why that is a store-level change, not a flag.
15199
+ */
15200
+ var TrackFlagFields = {
15201
+ /** Operator marked this track as training material. */
15202
+ markForTrain: boolean().optional(),
15203
+ /** Operator marked this track for diagnostic attention. */
15204
+ debug: boolean().optional()
15205
+ };
15206
+ /**
15207
+ * The write half: a PARTIAL patch. An omitted key is left untouched, so setting
15208
+ * one flag can never clear the other — the toggles are independent and are
15209
+ * driven from three surfaces that do not know about each other.
15210
+ */
15211
+ var TrackFlagsPatchSchema = object(TrackFlagFields);
15212
+ /**
15213
+ * The resolved flag state after a write. Both fields are REQUIRED here (absent
15214
+ * collapses to `false`) so a caller can drive a toggle's checked state off the
15215
+ * mutation result without a re-fetch.
14377
15216
  */
14378
- var TrackSourceSchema = _enum(["pipeline", "sensor"]);
15217
+ var TrackFlagsSchema = object({
15218
+ trackId: string(),
15219
+ markForTrain: boolean(),
15220
+ debug: boolean()
15221
+ });
14379
15222
  var TrackSchema = object({
14380
15223
  trackId: string(),
14381
15224
  deviceId: number(),
@@ -14418,7 +15261,8 @@ var TrackSchema = object({
14418
15261
  /** Normalized 0..1 trajectory envelope (see {@link TrackEnvelopeSchema}).
14419
15262
  * Populated from the persisted envelope columns on historical reads;
14420
15263
  * absent on legacy rows, dims-less tracks and active (in-RAM) tracks. */
14421
- envelope: TrackEnvelopeSchema.optional()
15264
+ envelope: TrackEnvelopeSchema.optional(),
15265
+ ...TrackFlagFields
14422
15266
  });
14423
15267
  var BaseEventFields = {
14424
15268
  id: string(),
@@ -14631,7 +15475,8 @@ var KeyEventSchema = object({
14631
15475
  /** Highest-confidence ObjectEvent id for the track (empty when none). */
14632
15476
  bestEventId: string(),
14633
15477
  /** Track lifetime in ms (lastSeen - firstSeen). */
14634
- windowMs: number().optional()
15478
+ windowMs: number().optional(),
15479
+ ...TrackFlagFields
14635
15480
  });
14636
15481
  object({
14637
15482
  trackId: string(),
@@ -14717,7 +15562,31 @@ var RebuildObjectEmbeddingsInput = object({
14717
15562
  since: number().optional(),
14718
15563
  until: number().optional(),
14719
15564
  /** Stop after this many tracks; the result reports whether more remain. */
14720
- maxTracks: number().int().positive().optional()
15565
+ maxTracks: number().int().positive().optional(),
15566
+ /**
15567
+ * Run every embedding on THIS node instead of round-robining the fleet.
15568
+ *
15569
+ * Named `executeOnNodeId` and not `nodeId` on purpose: an inline `nodeId`
15570
+ * field in cap args is read by `parent-unowned-call.ts` as a ROUTING PIN, so
15571
+ * calling it that would pin the rebuild REQUEST itself to that node — the
15572
+ * rebuild orchestration lives on the hub, and only the per-track step runs
15573
+ * remotely. This field is data; the per-track pin is applied inside.
15574
+ *
15575
+ * Absent ⇒ round-robin over every online node whose runner can serve the
15576
+ * pinned model.
15577
+ */
15578
+ executeOnNodeId: string().optional(),
15579
+ /**
15580
+ * Milliseconds to wait between tracks; omit for the built-in default, `0` to
15581
+ * run flat out.
15582
+ *
15583
+ * A rebuild is bulk maintenance on hub-main's single thread. Measured
15584
+ * 2026-08-06, an unpaced pass held that thread busy 82.2 s out of 120 and
15585
+ * pushed `nodes.topology` from 0.25 s to 26 s for 43 minutes. The value in
15586
+ * force is logged at start and finish so a deliberately slow pass reads
15587
+ * differently from a stalled one.
15588
+ */
15589
+ pacingMs: number().int().nonnegative().optional()
14721
15590
  });
14722
15591
  /**
14723
15592
  * Result of emptying the CLIP index.
@@ -14751,13 +15620,23 @@ var RebuildStatusSchema = object({
14751
15620
  /** Tracks with no usable detection box. */
14752
15621
  missingBbox: number(),
14753
15622
  /**
14754
- * Tracks the pipeline REFUSED rather than broke on: the camera is not
14755
- * attached, or `clip-embedding` is not enabled in its step tree. Separate
14756
- * from `failed` because the remedy is a configuration change, not an engine
14757
- * investigation and because a pass over decommissioned cameras would
14758
- * otherwise read as a total engine outage.
15623
+ * Tracks an executing node REFUSED rather than broke on an unreadable key
15624
+ * frame, a step that threw. Separate from `failed` because the remedy is
15625
+ * different, and because a whole camera silently contributing zero vectors
15626
+ * is the shape of failure a rebuild must never hide.
14759
15627
  */
14760
15628
  notRunnable: number(),
15629
+ /**
15630
+ * The pass stopped because NO node could serve the pinned model.
15631
+ *
15632
+ * Distinct from `notRunnable` on purpose: that one says "this track was
15633
+ * refused", this one says "the cluster cannot do this work at all" — every
15634
+ * candidate node either lacks the `clip-embedding` step, lacks a build of the
15635
+ * pinned model for its engine format, or dropped out. The remedy is a model /
15636
+ * engine change, not a per-camera one. Non-zero here always comes with
15637
+ * `complete: false`.
15638
+ */
15639
+ noCapableNode: number(),
14761
15640
  failed: number(),
14762
15641
  /** Set once a pass ends: true only when EVERYTHING was covered. */
14763
15642
  complete: boolean().nullable(),
@@ -14829,7 +15708,12 @@ DeviceType.Camera, method(object({ deviceId: number() }), array(TrackSchema).rea
14829
15708
  }), {
14830
15709
  kind: "mutation",
14831
15710
  auth: "admin"
14832
- }), method(object({}), EventStoreFootprintSchema, {
15711
+ }), method(object({
15712
+ /** Log/audit scope only — the trackId is globally unique on its own. */
15713
+ deviceId: number(),
15714
+ trackId: string(),
15715
+ flags: TrackFlagsPatchSchema
15716
+ }), TrackFlagsSchema, { kind: "mutation" }), method(object({}), EventStoreFootprintSchema, {
14833
15717
  kind: "query",
14834
15718
  auth: "admin"
14835
15719
  }), method(object({
@@ -15439,6 +16323,53 @@ var DetailResultSchema = object({
15439
16323
  nativeFaceShortSidePx: number().optional()
15440
16324
  });
15441
16325
  /**
16326
+ * Why an executing node REFUSED a stateless step run (`runStatelessStep`).
16327
+ *
16328
+ * A refusal is a first-class answer, not an error, because the caller's next
16329
+ * move depends on WHICH one it is — and because "the pass produced nothing"
16330
+ * must never be reachable without a named, counted cause. The two tiers:
16331
+ *
16332
+ * - **node-level** (`unknown-step`, `model-not-servable`) — this node can
16333
+ * never serve this (step, model) pair. The caller drops it from its rotation
16334
+ * and retries the same work elsewhere; nothing about the work changes.
16335
+ * - **work-level** (`unreadable-frame`, `execution-failed`) — this node is
16336
+ * fine, this one request is not. Retrying it on another node would only
16337
+ * spread the same failure.
16338
+ */
16339
+ var StatelessStepRefusalSchema = _enum([
16340
+ "unknown-step",
16341
+ "model-not-servable",
16342
+ "unreadable-frame",
16343
+ "execution-failed"
16344
+ ]);
16345
+ /**
16346
+ * Answer to `runStatelessStep` — a discriminated union rather than a nullable
16347
+ * result, because `null` is exactly what made the camera-bound detail path
16348
+ * unable to tell "refused" from "never asked".
16349
+ */
16350
+ var RunStatelessStepResultSchema = discriminatedUnion("kind", [object({
16351
+ kind: literal("ran"),
16352
+ /** The node that actually executed it — the pin, echoed back for the log. */
16353
+ nodeId: string(),
16354
+ /**
16355
+ * The model the step ran with.
16356
+ *
16357
+ * The node verified this exact id has a build for the format it dispatched
16358
+ * on BEFORE running, so the executor's format resolution returns it
16359
+ * unchanged. A caller that pinned a model must compare this field and
16360
+ * treat a mismatch as a refusal — the whole point of the pin is that a
16361
+ * pass writes one feature space.
16362
+ */
16363
+ modelId: string(),
16364
+ details: array(DetailResultSchema)
16365
+ }), object({
16366
+ kind: literal("refused"),
16367
+ nodeId: string(),
16368
+ reason: StatelessStepRefusalSchema,
16369
+ /** Human-readable specifics — the format tried, the formats shipped, etc. */
16370
+ detail: string()
16371
+ })]);
16372
+ /**
15442
16373
  * Per-camera tunable ranges + defaults. Single source of truth used
15443
16374
  * by both the Zod data schema (validation + default fallback) and
15444
16375
  * the device settings UI (slider min/max/step). Touch one place and
@@ -15788,7 +16719,32 @@ method(RunnerCameraConfigSchema, object({ success: literal(true) }), { kind: "mu
15788
16719
  cropJpeg: string().optional(),
15789
16720
  parent: DetailParentSchema,
15790
16721
  steps: array(string()).optional()
15791
- }), object({ details: array(DetailResultSchema) }).nullable(), { kind: "mutation" });
16722
+ }), object({ details: array(DetailResultSchema) }).nullable(), { kind: "mutation" }), method(object({
16723
+ /** Catalog step id, e.g. `clip-embedding`. */
16724
+ stepId: string(),
16725
+ /**
16726
+ * REQUIRED model pin. The node runs this exact model or refuses with
16727
+ * `model-not-servable` — it never substitutes a format default, because
16728
+ * a fleet pass that round-robins across nodes would then fill one index
16729
+ * from several encoders.
16730
+ */
16731
+ modelId: string(),
16732
+ /** FULL FRAME, base64 JPEG. The runner cuts — do NOT pre-crop. */
16733
+ frameJpeg: string(),
16734
+ /**
16735
+ * The subject box, NORMALISED [0,1] against `frameJpeg`. Normalised on
16736
+ * purpose: the caller stores boxes against a downscaled analysis frame
16737
+ * while the stored key frame is native-resolution, and the only side
16738
+ * that reliably knows the image's pixel dimensions is the side that
16739
+ * decodes it. Denormalising here removes a second reader of the
16740
+ * dimensions and the class of mismatch that comes with it.
16741
+ */
16742
+ bbox: NativeCropBboxSchema,
16743
+ /** Parent class of the subject (`person`, `vehicle`, …) — carried into the result. */
16744
+ className: string(),
16745
+ /** Camera the pixels came from. Diagnostics + log tags ONLY — never routing. */
16746
+ sourceDeviceId: number()
16747
+ }), RunStatelessStepResultSchema, { kind: "mutation" });
15792
16748
  var CameraPipelineConfigSchema = object({
15793
16749
  engine: PipelineEngineChoiceSchema.optional(),
15794
16750
  steps: array(PipelineStepInputSchema).readonly(),
@@ -16086,6 +17042,20 @@ var CameraStatusSchema = object({
16086
17042
  detection: CameraDetectionStatusSchema.nullable(),
16087
17043
  audio: CameraAudioStatusSchema.nullable(),
16088
17044
  recording: CameraRecordingStatusSchema.nullable(),
17045
+ /**
17046
+ * Per-camera function switches an OPERATOR has turned off
17047
+ * ([D61](../../../../docs/decisions/adr-0067.md)).
17048
+ *
17049
+ * This is the difference between DISABLED and BROKEN. A camera whose
17050
+ * `detection` block reports zero fps and whose `switchedOff` contains
17051
+ * `'object-detection'` was switched off by a person; the same camera with an
17052
+ * empty list is failing. Every status surface must render the two
17053
+ * differently — a quiet camera that looks identical to a dead one is the
17054
+ * silence-reads-as-never-happened trap this repo keeps paying for.
17055
+ *
17056
+ * Empty when nothing is off. Never contains a switch no provider offers.
17057
+ */
17058
+ switchedOff: array(CameraSwitchIdSchema).readonly(),
16089
17059
  /** Unix timestamp (ms) when this snapshot was composed server-side. */
16090
17060
  fetchedAt: number()
16091
17061
  });
@@ -16254,7 +17224,14 @@ method(object({
16254
17224
  }), method(object({
16255
17225
  deviceId: number(),
16256
17226
  agentNodeId: string().optional()
16257
- }), CameraPipelineConfigSchema), method(object({ deviceId: number() }), CameraStatusSchema), method(object({ deviceIds: array(number()).optional() }), array(CameraStatusSchema).readonly()), method(_void(), array(PipelineTemplateSchema).readonly()), method(object({
17227
+ }), CameraPipelineConfigSchema), method(object({ deviceId: number() }), CameraSwitchGroupSchema), method(object({
17228
+ deviceId: number(),
17229
+ switchId: CameraSwitchIdSchema,
17230
+ enabled: boolean()
17231
+ }), CameraSwitchGroupSchema, {
17232
+ kind: "mutation",
17233
+ auth: "admin"
17234
+ }), method(object({ deviceId: number() }), CameraStatusSchema), method(object({ deviceIds: array(number()).optional() }), array(CameraStatusSchema).readonly()), method(_void(), array(PipelineTemplateSchema).readonly()), method(object({
16258
17235
  name: string(),
16259
17236
  description: string().optional(),
16260
17237
  config: CameraPipelineConfigSchema
@@ -16577,9 +17554,15 @@ DeviceType.Camera, method(object({
16577
17554
  * Bypass the cache freshness check and fetch directly from the
16578
17555
  * native (or stream-broker fallback). Triggered by the UI's
16579
17556
  * "refresh" button so an operator can force a fresh frame
16580
- * even when the cache is well within `snapshotMaxAgeMs`.
16581
- * On battery cams this WILL wake the camera — accept the
16582
- * cost only when the user explicitly asks for it.
17557
+ * even when the cache is well within the device's
17558
+ * `snapshotMaxAgeS` window.
17559
+ *
17560
+ * **`force` is an OPERATOR signal, not a freshness preference.** On a
17561
+ * battery camera it is the one thing that walks past the wrapper's
17562
+ * sleep gate and wakes the camera, so a background caller — a poller,
17563
+ * an event handler, a thumbnail — must NEVER set it. Every such caller
17564
+ * gets the cached frame, which on a sleeping battery camera is the
17565
+ * correct answer: stale but honest beats woken.
16583
17566
  */
16584
17567
  force: boolean().optional()
16585
17568
  }), SnapshotImageSchema.nullable()), method(object({ deviceId: number() }), _void(), {
@@ -20861,12 +21844,30 @@ object({
20861
21844
  });
20862
21845
  DeviceType.Sensor;
20863
21846
  /**
20864
- * Privacy mask = up to `maxRegions` SHAPES the camera blanks out (NOT a
20865
- * cell grid). Reolink `<shelterList>` zones are rectangles; Hikvision
20866
- * ISAPI `<RegionCoordinatesList>` zones are free polygons (this camera:
20867
- * exactly 4 vertices, not necessarily axis-aligned). The cap composes the
20868
- * shared rect|polygon subset of the MaskShape vocabulary. All coords are
20869
- * normalized 0..1 (top-left origin).
21847
+ * PRIVACY what the camera deliberately does not capture. Two planes:
21848
+ *
21849
+ * - **video**: up to `maxRegions` SHAPES the camera blanks out (NOT a cell
21850
+ * grid). Reolink `<shelterList>` zones are rectangles; Hikvision ISAPI
21851
+ * `<RegionCoordinatesList>` zones are free polygons (this camera: exactly
21852
+ * 4 vertices, not necessarily axis-aligned). The cap composes the shared
21853
+ * rect|polygon subset of the MaskShape vocabulary. All coords are
21854
+ * normalized 0..1 (top-left origin).
21855
+ * - **audio**: the camera's microphone. `setAudioEnabled(false)` stops the
21856
+ * camera encoding an audio track at all, so EVERY consumer — live view,
21857
+ * recording, the audio analyzer, an export — sees silent video. There is
21858
+ * no server-side copy of this fact; the camera is the store and every read
21859
+ * is a read-through, which is why a switch over it cannot drift
21860
+ * ([D62](../../../../docs/decisions/adr-0062.md)).
21861
+ *
21862
+ * Both belong here for one reason: they are the two things an operator turns
21863
+ * off when the answer to "what is this camera allowed to record" changes, and
21864
+ * both are applied ON the device, before anything leaves it.
21865
+ *
21866
+ * **The audio flag has exactly one writer.** `stream-params` used to carry a
21867
+ * per-profile `audio` in its patch schema — reachable from no UI and honoured
21868
+ * by one provider — and it was removed when this landed. A second writer onto
21869
+ * one device register is the shape of every knob this repo has shipped that
21870
+ * disagreed with the one the reader read.
20870
21871
  */
20871
21872
  /** A privacy-mask region's geometry — rectangle or free polygon. */
20872
21873
  var PrivacyMaskShapeSchema = discriminatedUnion("kind", [MaskRectShapeSchema, MaskPolygonShapeSchema]);
@@ -20882,16 +21883,40 @@ object({
20882
21883
  enabled: boolean(),
20883
21884
  /** Active zones (normalized 0..1). Length ≤ maxRegions. */
20884
21885
  regions: array(PrivacyMaskRegionSchema),
21886
+ /**
21887
+ * Is the camera capturing sound right now? Read from the camera, never from
21888
+ * a server-side mirror.
21889
+ *
21890
+ * `null` means "no answer" — either this camera exposes no controllable
21891
+ * microphone (`getOptions().supportsAudioMute === false`) or the read
21892
+ * failed. A consumer must render `null` as UNKNOWN and never as `false`:
21893
+ * "the microphone is off" and "we could not ask" look identical to an
21894
+ * operator only until one of them is wrong.
21895
+ *
21896
+ * On a camera whose profiles carry the flag independently (Reolink writes
21897
+ * it per stream), `true` means AT LEAST ONE profile still carries audio —
21898
+ * privacy is only satisfied when every one of them is silent.
21899
+ */
21900
+ audioEnabled: boolean().nullable(),
20885
21901
  lastFetchedAt: number()
20886
21902
  });
20887
- /** Per-camera availability. */
21903
+ /** Per-camera availability. Probed, never assumed from the model name. */
20888
21904
  var PrivacyMaskOptionsSchema = object({
20889
21905
  /** Maximum number of supported zones. */
20890
21906
  maxRegions: number(),
20891
21907
  /** Shape kinds this camera accepts — Reolink: ['rect']; Hikvision: ['rect','polygon']. */
20892
21908
  supportedShapes: array(MaskShapeKindSchema),
20893
21909
  /** Polygon vertex bounds when 'polygon' is supported (Hikvision: {min:4,max:4}). */
20894
- polygonVertices: MaskPolygonVerticesSchema.optional()
21910
+ polygonVertices: MaskPolygonVerticesSchema.optional(),
21911
+ /**
21912
+ * Does this camera expose a microphone switch we can actually write?
21913
+ *
21914
+ * Camera-probed: `true` only when the firmware answered with an audio flag
21915
+ * we know how to patch. A camera that never answered is `false` — a control
21916
+ * the operator can press that changes nothing is worse than no control, and
21917
+ * the switch group renders "not available" instead.
21918
+ */
21919
+ supportsAudioMute: boolean()
20895
21920
  });
20896
21921
  /** Partial change — every field optional. */
20897
21922
  var PrivacyMaskPatchSchema = object({
@@ -20904,6 +21929,12 @@ DeviceType.Camera, method(object({ deviceId: number() }), PrivacyMaskOptionsSche
20904
21929
  }), _void(), {
20905
21930
  kind: "mutation",
20906
21931
  auth: "admin"
21932
+ }), method(object({
21933
+ deviceId: number(),
21934
+ enabled: boolean()
21935
+ }), _void(), {
21936
+ kind: "mutation",
21937
+ auth: "admin"
20907
21938
  });
20908
21939
  var PtzPresetSchema = object({
20909
21940
  id: string(),
@@ -21113,6 +22144,21 @@ var LocateSegmentResultSchema = discriminatedUnion("kind", [object({
21113
22144
  })]);
21114
22145
  /** Raw bytes of one finalized footage segment (read off disk on the recording node). */
21115
22146
  var ReadSegmentBytesResultSchema = object({ data: _instanceof(Uint8Array) });
22147
+ /**
22148
+ * One GOP of a finalized segment, cut by byte range through the segment's own
22149
+ * `mfra` (D31 on the D42 feeder path). `data` is the `ftyp`+`moov` head plus
22150
+ * the single `moof`+`mdat` covering the requested instant — standalone-
22151
+ * demuxable, never the whole file. When the segment's index cannot be parsed
22152
+ * the provider degrades INSIDE the mechanism to the whole segment (still one
22153
+ * `data`, `gopStartMs` = the segment start) — a worse read, not another path.
22154
+ */
22155
+ var ReadGopBytesResultSchema = object({
22156
+ data: _instanceof(Uint8Array),
22157
+ /** Absolute epoch ms of the returned fragment's first sample. */
22158
+ gopStartMs: number(),
22159
+ /** Media ms the returned fragment covers. */
22160
+ gopDurMs: number()
22161
+ });
21116
22162
  method(object({
21117
22163
  deviceId: number(),
21118
22164
  fromMs: number(),
@@ -21155,6 +22201,14 @@ method(object({
21155
22201
  }), ReadSegmentBytesResultSchema, {
21156
22202
  kind: "query",
21157
22203
  auth: "admin"
22204
+ }), method(object({
22205
+ deviceId: number(),
22206
+ profile: string(),
22207
+ startMs: number(),
22208
+ epochMs: number()
22209
+ }), ReadGopBytesResultSchema, {
22210
+ kind: "query",
22211
+ auth: "admin"
21158
22212
  }), method(object({
21159
22213
  deviceId: number(),
21160
22214
  config: RecordingConfigSchema
@@ -21635,6 +22689,16 @@ var StreamProfileConfigSchema = object({
21635
22689
  "baseline"
21636
22690
  ]).optional(),
21637
22691
  gop: number().optional(),
22692
+ /**
22693
+ * Whether THIS profile currently carries an audio track. READ-ONLY here.
22694
+ *
22695
+ * There is no matching field on {@link StreamProfilePatchSchema}: the
22696
+ * camera's microphone is owned by `privacy-mask` (`setAudioEnabled`), which
22697
+ * writes every profile at once so "audio off" means silent everywhere. A
22698
+ * per-profile writer beside it would let a camera be half-muted and would be
22699
+ * a second knob onto one device register — the failure D62 exists to
22700
+ * prevent. Absent when the firmware does not report the flag.
22701
+ */
21638
22702
  audio: boolean().optional()
21639
22703
  });
21640
22704
  object({
@@ -21675,7 +22739,13 @@ var StreamParamsOptionsSchema = object({
21675
22739
  ext: StreamProfileOptionsSchema.optional()
21676
22740
  });
21677
22741
  /** A partial change to one profile — every field optional; a provider
21678
- * ignores fields it doesn't support. */
22742
+ * ignores fields it doesn't support.
22743
+ *
22744
+ * There is deliberately NO `audio` here. It existed until 2026-08-07,
22745
+ * reachable from no form and honoured by exactly one provider, while the
22746
+ * camera's microphone is a whole-device fact. It now has one writer,
22747
+ * `privacyMask.setAudioEnabled`, which writes every profile — see
22748
+ * `privacy-mask.cap.ts`. */
21679
22749
  var StreamProfilePatchSchema = object({
21680
22750
  width: number().optional(),
21681
22751
  height: number().optional(),
@@ -21688,8 +22758,7 @@ var StreamProfilePatchSchema = object({
21688
22758
  "main",
21689
22759
  "baseline"
21690
22760
  ]).optional(),
21691
- gop: number().optional(),
21692
- audio: boolean().optional()
22761
+ gop: number().optional()
21693
22762
  });
21694
22763
  DeviceType.Camera, method(object({ deviceId: number() }), StreamParamsOptionsSchema), method(object({
21695
22764
  deviceId: number(),
@@ -25211,6 +26280,12 @@ Object.freeze({
25211
26280
  addonId: null,
25212
26281
  access: "view"
25213
26282
  },
26283
+ "notificationRules.listDeviceMutes": {
26284
+ capName: "notification-rules",
26285
+ capScope: "system",
26286
+ addonId: null,
26287
+ access: "view"
26288
+ },
25214
26289
  "notificationRules.listRules": {
25215
26290
  capName: "notification-rules",
25216
26291
  capScope: "system",
@@ -25229,6 +26304,12 @@ Object.freeze({
25229
26304
  addonId: null,
25230
26305
  access: "create"
25231
26306
  },
26307
+ "notificationRules.setDeviceMuted": {
26308
+ capName: "notification-rules",
26309
+ capScope: "system",
26310
+ addonId: null,
26311
+ access: "create"
26312
+ },
25232
26313
  "notificationRules.setRuleEnabled": {
25233
26314
  capName: "notification-rules",
25234
26315
  capScope: "system",
@@ -25505,6 +26586,12 @@ Object.freeze({
25505
26586
  addonId: null,
25506
26587
  access: "view"
25507
26588
  },
26589
+ "pipelineAnalytics.setTrackFlags": {
26590
+ capName: "pipeline-analytics",
26591
+ capScope: "device",
26592
+ addonId: null,
26593
+ access: "create"
26594
+ },
25508
26595
  "pipelineAnalytics.wipeAllAnalytics": {
25509
26596
  capName: "pipeline-analytics",
25510
26597
  capScope: "device",
@@ -25811,6 +26898,12 @@ Object.freeze({
25811
26898
  addonId: null,
25812
26899
  access: "view"
25813
26900
  },
26901
+ "pipelineOrchestrator.getCameraSwitches": {
26902
+ capName: "pipeline-orchestrator",
26903
+ capScope: "system",
26904
+ addonId: null,
26905
+ access: "view"
26906
+ },
25814
26907
  "pipelineOrchestrator.getCapabilityBindings": {
25815
26908
  capName: "pipeline-orchestrator",
25816
26909
  capScope: "system",
@@ -25943,6 +27036,12 @@ Object.freeze({
25943
27036
  addonId: null,
25944
27037
  access: "create"
25945
27038
  },
27039
+ "pipelineOrchestrator.setCameraSwitch": {
27040
+ capName: "pipeline-orchestrator",
27041
+ capScope: "system",
27042
+ addonId: null,
27043
+ access: "create"
27044
+ },
25946
27045
  "pipelineOrchestrator.setCapabilityBinding": {
25947
27046
  capName: "pipeline-orchestrator",
25948
27047
  capScope: "system",
@@ -26033,6 +27132,12 @@ Object.freeze({
26033
27132
  addonId: null,
26034
27133
  access: "create"
26035
27134
  },
27135
+ "pipelineRunner.runStatelessStep": {
27136
+ capName: "pipeline-runner",
27137
+ capScope: "system",
27138
+ addonId: null,
27139
+ access: "create"
27140
+ },
26036
27141
  "plateGallery.assignPlate": {
26037
27142
  capName: "plate-gallery",
26038
27143
  capScope: "system",
@@ -26165,6 +27270,12 @@ Object.freeze({
26165
27270
  addonId: null,
26166
27271
  access: "view"
26167
27272
  },
27273
+ "privacyMask.setAudioEnabled": {
27274
+ capName: "privacy-mask",
27275
+ capScope: "device",
27276
+ addonId: null,
27277
+ access: "create"
27278
+ },
26168
27279
  "privacyMask.setMask": {
26169
27280
  capName: "privacy-mask",
26170
27281
  capScope: "device",
@@ -26333,6 +27444,12 @@ Object.freeze({
26333
27444
  addonId: null,
26334
27445
  access: "create"
26335
27446
  },
27447
+ "recording.readGopBytes": {
27448
+ capName: "recording",
27449
+ capScope: "system",
27450
+ addonId: null,
27451
+ access: "view"
27452
+ },
26336
27453
  "recording.readSegmentBytes": {
26337
27454
  capName: "recording",
26338
27455
  capScope: "system",
@@ -26849,6 +27966,12 @@ Object.freeze({
26849
27966
  addonId: null,
26850
27967
  access: "create"
26851
27968
  },
27969
+ "streamBroker.acquireEgressTranscode": {
27970
+ capName: "stream-broker",
27971
+ capScope: "system",
27972
+ addonId: null,
27973
+ access: "create"
27974
+ },
26852
27975
  "streamBroker.assignProfile": {
26853
27976
  capName: "stream-broker",
26854
27977
  capScope: "system",
@@ -26957,6 +28080,12 @@ Object.freeze({
26957
28080
  addonId: null,
26958
28081
  access: "create"
26959
28082
  },
28083
+ "streamBroker.releaseEgressTranscode": {
28084
+ capName: "stream-broker",
28085
+ capScope: "system",
28086
+ addonId: null,
28087
+ access: "create"
28088
+ },
26960
28089
  "streamBroker.releaseStreamWithCodec": {
26961
28090
  capName: "stream-broker",
26962
28091
  capScope: "system",
@@ -27742,36 +28871,87 @@ object({
27742
28871
  square: false
27743
28872
  }).paddingRatio;
27744
28873
  /**
27745
- * Deterministic SHA-256 hash of an arbitrary serialisable value. The
27746
- * canonical form sorts object keys alphabetically at every depth so two
27747
- * structurally-equal inputs with different key insertion orders produce
27748
- * the same hash. Returns a 64-char lowercase hex digest.
27749
- *
27750
- * Used by export adapters (Alexa, HAP) to short-circuit re-discovery /
27751
- * accessory-rebuild work when the upstream shape is byte-identical to
27752
- * the last applied state preventing user-visible "re-discovery"
27753
- * notifications on every addon-runner respawn. Each respawn re-fires
27754
- * `DeviceBindingsChanged` for every cap registration, which without
27755
- * this guard would propagate redundant pushes.
27756
- *
27757
- * Note: this is a SYMPTOMATIC fix layered on top of the binding-change
27758
- * subscription. The proper fix is a single "device ready" lifecycle
27759
- * barrier so exports react only when the full cap set has landed —
27760
- * tracked separately for post-HA-integration work.
27761
- */
27762
- function canonicalHash(value) {
27763
- const canonical = JSON.stringify(value, replaceWithSortedKeys);
27764
- return createHash("sha256").update(canonical ?? "").digest("hex");
27765
- }
27766
- function replaceWithSortedKeys(_key, value) {
27767
- if (value && typeof value === "object" && !Array.isArray(value)) {
27768
- const obj = value;
27769
- const out = {};
27770
- for (const k of Object.keys(obj).toSorted()) out[k] = obj[k];
27771
- return out;
27772
- }
27773
- return value;
27774
- }
28874
+ * WHICH delivered frames the decode worker retains a native copy of.
28875
+ *
28876
+ * - `all` every frame the worker delivered to the runner. The shipped
28877
+ * behaviour, and the only correct one if something can ask for a crop of a
28878
+ * frame the runner never sent to inference.
28879
+ * - `inferred` only the frames the runner ADMITTED to its detection queue.
28880
+ * A native-crop request always names a `frameId` that rode an inference
28881
+ * result, so that is the only set a request can name. How much it drops is
28882
+ * the two-plane governor's admit ratio and nothing else: measured at ~50% on
28883
+ * this cluster, not the ~80% the design sketch assumed, because the governor
28884
+ * was not throttling as hard as the sketch supposed. Read
28885
+ * `leaseAdmitted`/`leaseOffered` off the metrics line for the camera in front
28886
+ * of you rather than quoting a number from here. The newest delivered frame is
28887
+ * croppable regardless it is still the worker's reserved slot, not a lease —
28888
+ * which covers the one-frame race between a mark and the supersede that
28889
+ * consumes it.
28890
+ */
28891
+ var NativeLeaseAdmissionSchema = _enum(["all", "inferred"]);
28892
+ object({
28893
+ /**
28894
+ * How long a retained native frame is served before it counts as a miss.
28895
+ *
28896
+ * Must cover the FULL late-crop horizon: detection inference + the
28897
+ * cross-process inference-result hop to hub post-analysis + tracking + the
28898
+ * tRPC crop round-trip back. Below ~500 ms the busiest cameras' subject crops
28899
+ * outrun it and fall back to the ≤640 detection frame; above ~3 s the resident
28900
+ * RAM per busy camera grows linearly with no measured hit-rate gain.
28901
+ */
28902
+ ttlMs: number().int().min(250).max(1e4),
28903
+ /**
28904
+ * Hard per-decode-worker RAM ceiling for retained native frames, in MB.
28905
+ *
28906
+ * Intended as a SAFETY ceiling with the TTL as the effective cap — but check
28907
+ * which one is actually binding before reasoning from that. At the shipped
28908
+ * 1024 MB and a 2 800 ms TTL, a 4K camera hits the CEILING first (~43 frames
28909
+ * at ~24 MB each) and the TTL never gets to expire anything; `leaseMb` /
28910
+ * `leaseFrames` on the metrics line say which. When the ceiling binds, a
28911
+ * change that admits fewer frames buys retention WINDOW at constant RAM
28912
+ * rather than giving RAM back — lower this knob if RAM is what you wanted.
28913
+ * `0` DISABLES the lease entirely and falls the worker back to the tiny
28914
+ * leak-prone GPU surface ring (~85% crop miss; that is what the lease exists
28915
+ * to replace).
28916
+ */
28917
+ budgetMb: number().int().min(0).max(4096),
28918
+ /**
28919
+ * Demand window: eager per-frame native retention runs only within this many
28920
+ * ms of the last native-crop request (or of the dial starting).
28921
+ *
28922
+ * `0` means ALWAYS ON — it disables the gate, it does not disable retention.
28923
+ * That is the legacy behaviour that saturated an N100 (24 native-4K downloads
28924
+ * per second on a camera with zero crop demand), so leave it non-zero unless
28925
+ * you are reproducing that.
28926
+ */
28927
+ activityMs: number().int().min(0).max(12e4),
28928
+ /**
28929
+ * Which delivered frames are retained at all — see
28930
+ * {@link NativeLeaseAdmissionSchema}. This is the only knob of the four that
28931
+ * changes WHAT is kept rather than for how long, so it is also the only one
28932
+ * that can turn a crop that used to hit into a miss. The worker counts every
28933
+ * crop request naming a frame it did NOT see marked
28934
+ * (`leaseUnmarkedCrops` on the session-decode metrics line): a non-zero value
28935
+ * there is the signal that some caller names frames outside the inference set
28936
+ * and that this must go back to `all`.
28937
+ */
28938
+ admission: NativeLeaseAdmissionSchema
28939
+ });
28940
+ /**
28941
+ * The values in force when the operator has set nothing — byte-for-byte the
28942
+ * constants the decode worker shipped with as env-var defaults, so making these
28943
+ * settings changed no behaviour on the day it landed.
28944
+ */
28945
+ var DEFAULT_NATIVE_LEASE_SETTINGS = {
28946
+ ttlMs: 1200,
28947
+ budgetMb: 1024,
28948
+ activityMs: 15e3,
28949
+ admission: "inferred"
28950
+ };
28951
+ DEFAULT_NATIVE_LEASE_SETTINGS.ttlMs;
28952
+ DEFAULT_NATIVE_LEASE_SETTINGS.budgetMb;
28953
+ DEFAULT_NATIVE_LEASE_SETTINGS.activityMs;
28954
+ DEFAULT_NATIVE_LEASE_SETTINGS.admission;
27775
28955
  /**
27776
28956
  * Compute the stable 64-char lowercase-hex fingerprint of a device's
27777
28957
  * export-relevant shape. Two structurally-equal shapes (any feature order,
@@ -27911,7 +29091,7 @@ function clearPairingFiles(accessoryUuid, logger) {
27911
29091
  }
27912
29092
  //#endregion
27913
29093
  //#region src/hap-setup-uri.ts
27914
- function errMsg$10(e) {
29094
+ function errMsg$11(e) {
27915
29095
  return e instanceof Error ? e.message : String(e);
27916
29096
  }
27917
29097
  /**
@@ -27938,11 +29118,146 @@ function firstExposedAccessorySetupUri(exposed, logger) {
27938
29118
  try {
27939
29119
  return first.setupURI();
27940
29120
  } catch (err) {
27941
- logger.debug("export-hap: setupURI failed on first exposed accessory", { meta: { error: errMsg$10(err) } });
29121
+ logger.debug("export-hap: setupURI failed on first exposed accessory", { meta: { error: errMsg$11(err) } });
27942
29122
  return;
27943
29123
  }
27944
29124
  }
27945
29125
  }
29126
+ /**
29127
+ * hap-nodejs' `checkName` regex, verbatim.
29128
+ *
29129
+ * Duplicated rather than imported because it is `@private Private API` in that
29130
+ * package and not exported. Duplicating a private regex is a liability, so the
29131
+ * guard against drift is behavioural, not textual: `service-naming.spec.ts`
29132
+ * builds real services and asserts hap-nodejs emits ZERO characteristic
29133
+ * warnings — if this expression ever diverges from theirs, that test fails.
29134
+ */
29135
+ var HAP_NAME_PATTERN = /^[\p{L}\p{N}][\p{L}\p{N}\p{Zs}’'&!._:;()/,-]*[\p{L}\p{N}]$/u;
29136
+ /** Characters HAP tolerates INSIDE a name. Anything else becomes a space. */
29137
+ var HAP_NAME_INNER = /[^\p{L}\p{N}\p{Zs}’'&!._:;()/,-]/gu;
29138
+ /** Would hap-nodejs accept this as a `Name` characteristic value? */
29139
+ function isHapServiceName(value) {
29140
+ return HAP_NAME_PATTERN.test(value);
29141
+ }
29142
+ /**
29143
+ * Build one HAP-valid service name from device-derived parts.
29144
+ *
29145
+ * Empty and absent parts are dropped rather than joined, so a missing role
29146
+ * never produces a double space. `fallback` is used ONLY when nothing
29147
+ * device-derived survives sanitisation — it is the last resort, not the
29148
+ * default, because a name that says nothing about the device is the defect
29149
+ * this module exists to end.
29150
+ */
29151
+ function hapServiceName(parts, fallback) {
29152
+ const trimmed = trimToHapName(parts.map((part) => typeof part === "string" ? part : "").map((part) => part.replace(HAP_NAME_INNER, " ")).join(" ").replace(/\s+/gu, " ").trim());
29153
+ if (trimmed !== null) return trimmed;
29154
+ return trimToHapName(fallback.replace(HAP_NAME_INNER, " ").replace(/\s+/gu, " ").trim()) ?? "";
29155
+ }
29156
+ /**
29157
+ * The privacy-mask switch.
29158
+ *
29159
+ * Just "Privacy". The camera name is NOT prefixed: this service lives inside
29160
+ * the camera's own accessory, iOS already renders it under the camera, and a
29161
+ * round that prefixed it gave the operator "Videocamera ingresso Privacy"
29162
+ * sitting inside a tile titled "Videocamera ingresso".
29163
+ *
29164
+ * The prefix was added for a real reason — two cameras publishing a switch
29165
+ * called "Privacy" — but that was a symptom of the label being the ONLY thing
29166
+ * shown, which stopped being true once `ConfiguredName` made the service
29167
+ * render in its accessory's context. Uniqueness is required WITHIN one
29168
+ * accessory, not across the bridge, and one camera has one privacy switch.
29169
+ */
29170
+ function privacyServiceName() {
29171
+ return hapServiceName([PRIVACY_SUFFIX], PRIVACY_SUFFIX);
29172
+ }
29173
+ /**
29174
+ * Deliberately not localised, and deliberately not a translation table.
29175
+ *
29176
+ * The device half of the name is the operator's own text and arrives in the
29177
+ * operator's language. This half names a camstack capability (`privacy-mask`)
29178
+ * and there is no locale in an addon's context to resolve it against; the word
29179
+ * is also identical in the operator's language. A translation layer for one
29180
+ * word would be the kind of leftover that reads as verification.
29181
+ */
29182
+ var PRIVACY_SUFFIX = "Privacy";
29183
+ /**
29184
+ * An accessory child (siren, floodlight, spotlight) rendered as a service on
29185
+ * the parent camera.
29186
+ *
29187
+ * The child's OWN stored name wins. It is the string the operator typed, in
29188
+ * the operator's language, and an early rule threw it away: `role` was
29189
+ * consulted first and title-cased, so every siren on the fleet published as
29190
+ * the English word "Siren" no matter what the operator had called it.
29191
+ *
29192
+ * Providers name children both ways — "Sirena" and "Videocamera cucina
29193
+ * Sirena". The parent half is now REMOVED rather than added, because the
29194
+ * service is published inside the parent camera's own accessory and iOS
29195
+ * already shows it there. The result must be one form, not two.
29196
+ */
29197
+ function childServiceName(parentName, child) {
29198
+ const own = withoutParent(child.name.trim(), parentName);
29199
+ if (own.length > 0) return hapServiceName([own], own);
29200
+ return hapServiceName([typeof child.role === "string" ? titleCase(child.role) : ""], CHILD_FALLBACK);
29201
+ }
29202
+ /**
29203
+ * Last resort for a child that carries neither a name nor a role. Better than
29204
+ * the parent's name, which would publish a service indistinguishable from the
29205
+ * accessory holding it — the exact defect this module keeps being asked to fix.
29206
+ *
29207
+ * English, like the role slugs it stands in for ("Floodlight", "Siren"): the
29208
+ * only strings this module invents are English, and inventing one Italian word
29209
+ * would be a localisation layer that localises nothing.
29210
+ */
29211
+ var CHILD_FALLBACK = "Accessory";
29212
+ /**
29213
+ * A PTZ action switch: the bare action, "Preset ingresso" / "Pan Left" /
29214
+ * "Autotrack".
29215
+ *
29216
+ * The labels themselves stay in `ptz-labels.ts` — they name a HomeKit control,
29217
+ * not a device. This function exists only to put the operator-typed half of a
29218
+ * preset name through the same sanitisation everything else gets; it no longer
29219
+ * qualifies the label with the camera, because all eight PTZ services live on
29220
+ * that camera's accessory and are unique among themselves.
29221
+ */
29222
+ function ptzServiceName(actionLabel) {
29223
+ return hapServiceName([actionLabel], actionLabel);
29224
+ }
29225
+ /**
29226
+ * Drop `parentName` from the front of `name`.
29227
+ *
29228
+ * A PREFIX only. "Videocamera cucina Sirena" → "Sirena"; "Sirena" is already
29229
+ * bare and untouched. A parent name appearing anywhere else in the child's
29230
+ * name is left alone — cutting from the middle of a string the operator typed
29231
+ * would mangle it, and this function must never make a label WORSE.
29232
+ *
29233
+ * Returns `name` unchanged when stripping would leave nothing: a child the
29234
+ * operator called exactly what the camera is called still needs a label.
29235
+ */
29236
+ function withoutParent(name, parentName) {
29237
+ const needle = parentName.trim();
29238
+ if (needle.length === 0) return name;
29239
+ if (!name.toLowerCase().startsWith(needle.toLowerCase())) return name;
29240
+ const rest = name.slice(needle.length).trim();
29241
+ return rest.length > 0 ? rest : name;
29242
+ }
29243
+ /**
29244
+ * Truncate to the HAP ceiling and shave any leading/trailing character the
29245
+ * pattern forbids. Returns `null` when nothing usable is left — the caller
29246
+ * decides what to do with that, because "fall back" and "drop the part" are
29247
+ * different answers.
29248
+ */
29249
+ function trimToHapName(value) {
29250
+ let out = value.length > 64 ? value.slice(0, 64) : value;
29251
+ while (out.length > 0 && !isAlphanumeric(out[out.length - 1])) out = out.slice(0, -1);
29252
+ while (out.length > 0 && !isAlphanumeric(out[0])) out = out.slice(1);
29253
+ return isHapServiceName(out) ? out : null;
29254
+ }
29255
+ function isAlphanumeric(ch) {
29256
+ return ch !== void 0 && /[\p{L}\p{N}]/u.test(ch);
29257
+ }
29258
+ function titleCase(raw) {
29259
+ return raw.split(/[-_\s]+/u).filter((part) => part.length > 0).map((part) => part.charAt(0).toUpperCase() + part.slice(1)).join(" ");
29260
+ }
27946
29261
  //#endregion
27947
29262
  //#region src/mappers/builders/battery.ts
27948
29263
  /**
@@ -27967,12 +29282,12 @@ var LOW_BATTERY_THRESHOLD_PCT = 20;
27967
29282
  async function buildBattery(bctx) {
27968
29283
  const { ctx, accessory, proxy, numericDeviceId, displayName } = bctx;
27969
29284
  const log = ctx.logger.withTags({ deviceId: numericDeviceId });
27970
- const service = accessory.addService(Service.Battery, displayName);
29285
+ const service = accessory.addService(Service.Battery, hapServiceName([displayName], `Camera ${numericDeviceId}`));
27971
29286
  try {
27972
29287
  const status = await proxy.battery?.getStatus({});
27973
29288
  if (status) applyToService(service, status);
27974
29289
  } catch (err) {
27975
- log.debug("export-hap: battery getStatus hydrate failed (non-fatal)", { meta: { error: errMsg$9(err) } });
29290
+ log.debug("export-hap: battery getStatus hydrate failed (non-fatal)", { meta: { error: errMsg$10(err) } });
27976
29291
  }
27977
29292
  const unsubscribes = [];
27978
29293
  if (proxy.state.battery) {
@@ -27998,7 +29313,7 @@ function applyToService(service, status) {
27998
29313
  const lowBattery = pct <= LOW_BATTERY_THRESHOLD_PCT ? Characteristic.StatusLowBattery.BATTERY_LEVEL_LOW : Characteristic.StatusLowBattery.BATTERY_LEVEL_NORMAL;
27999
29314
  service.updateCharacteristic(Characteristic.StatusLowBattery, lowBattery);
28000
29315
  }
28001
- function errMsg$9(err) {
29316
+ function errMsg$10(err) {
28002
29317
  return err instanceof Error ? err.message : String(err);
28003
29318
  }
28004
29319
  //#endregion
@@ -38329,6 +39644,856 @@ function makeRtcpGate(socket, timeoutMs) {
38329
39644
  socket.on("message", onMessage);
38330
39645
  });
38331
39646
  }
39647
+ /** V/P/RC byte + PT byte + 16-bit length. */
39648
+ var RTCP_HEADER_BYTES = 4;
39649
+ /** SSRC of the packet sender, first field of both SR and RR bodies. */
39650
+ var RTCP_SENDER_SSRC_BYTES = 4;
39651
+ /** NTP + RTP timestamp, packet count, octet count — present only on an SR. */
39652
+ var RTCP_SENDER_INFO_BYTES = 20;
39653
+ /** One report block: SSRC_n, loss, highest seq, jitter, LSR, DLSR. */
39654
+ var RTCP_REPORT_BLOCK_BYTES = 24;
39655
+ /** `fraction lost` is an 8-bit fixed-point fraction of 256. */
39656
+ var FRACTION_LOST_DENOMINATOR = 256;
39657
+ /** `delay since last SR` counts 1/65536 of a second. */
39658
+ var DLSR_UNITS_PER_SECOND = 65536;
39659
+ /** `cumulative number of packets lost` is a SIGNED 24-bit field. */
39660
+ var SIGNED_24_SIGN_BIT = 8388608;
39661
+ var SIGNED_24_MODULUS = 16777216;
39662
+ function readSigned24(packet, at) {
39663
+ const raw = packet[at] << 16 | packet[at + 1] << 8 | packet[at + 2];
39664
+ return raw >= SIGNED_24_SIGN_BIT ? raw - SIGNED_24_MODULUS : raw;
39665
+ }
39666
+ function readReportBlock(packet, at) {
39667
+ const fractionLostRaw = packet[at + 4];
39668
+ const dlsr = packet.readUInt32BE(at + 20);
39669
+ return {
39670
+ aboutSsrc: packet.readUInt32BE(at),
39671
+ fractionLostRaw,
39672
+ fractionLostPct: Math.round(fractionLostRaw / FRACTION_LOST_DENOMINATOR * 1e3) / 10,
39673
+ cumulativePacketsLost: readSigned24(packet, at + 5),
39674
+ extendedHighestSequence: packet.readUInt32BE(at + 8),
39675
+ jitter: packet.readUInt32BE(at + 12),
39676
+ lastSrTimestamp: packet.readUInt32BE(at + 16),
39677
+ delaySinceLastSrMs: Math.round(dlsr / DLSR_UNITS_PER_SECOND * 1e3)
39678
+ };
39679
+ }
39680
+ /**
39681
+ * Walk a (possibly compound) RTCP datagram and extract every report block.
39682
+ *
39683
+ * An RR is routinely bundled behind an SDES, and a controller with nothing to
39684
+ * report yet sends an RR with a reception-report count of zero. Both are
39685
+ * normal; neither is a failure. What IS a failure is a packet whose declared
39686
+ * length runs past the buffer or whose report count exceeds its own body —
39687
+ * those get named so a decrypt fault cannot masquerade as silence.
39688
+ */
39689
+ function parseCompoundRtcp(packet) {
39690
+ if (packet.length === 0) return {
39691
+ ok: false,
39692
+ failure: "empty",
39693
+ atOffset: 0
39694
+ };
39695
+ const packetTypes = [];
39696
+ const reports = [];
39697
+ let offset = 0;
39698
+ while (offset < packet.length) {
39699
+ if (packet.length - offset < RTCP_HEADER_BYTES) return {
39700
+ ok: false,
39701
+ failure: "short-header",
39702
+ atOffset: offset
39703
+ };
39704
+ const firstByte = packet[offset];
39705
+ if ((firstByte >> 6 & 3) !== 2) return {
39706
+ ok: false,
39707
+ failure: "bad-version",
39708
+ atOffset: offset
39709
+ };
39710
+ const reportCount = firstByte & 31;
39711
+ const packetType = packet[offset + 1];
39712
+ const totalBytes = (packet.readUInt16BE(offset + 2) + 1) * 4;
39713
+ if (offset + totalBytes > packet.length) return {
39714
+ ok: false,
39715
+ failure: "length-overrun",
39716
+ atOffset: offset
39717
+ };
39718
+ packetTypes.push(packetType);
39719
+ if (packetType === 201 || packetType === 200) {
39720
+ const senderSsrcAt = offset + RTCP_HEADER_BYTES;
39721
+ const blocksAt = senderSsrcAt + RTCP_SENDER_SSRC_BYTES + (packetType === 200 ? RTCP_SENDER_INFO_BYTES : 0);
39722
+ if (blocksAt + reportCount * RTCP_REPORT_BLOCK_BYTES > offset + totalBytes) return {
39723
+ ok: false,
39724
+ failure: "truncated-body",
39725
+ atOffset: offset
39726
+ };
39727
+ const blocks = [];
39728
+ for (let index = 0; index < reportCount; index += 1) blocks.push(readReportBlock(packet, blocksAt + index * RTCP_REPORT_BLOCK_BYTES));
39729
+ reports.push({
39730
+ packetType,
39731
+ reporterSsrc: packet.readUInt32BE(senderSsrcAt),
39732
+ blocks
39733
+ });
39734
+ }
39735
+ offset += totalBytes;
39736
+ }
39737
+ return {
39738
+ ok: true,
39739
+ packetTypes,
39740
+ reports
39741
+ };
39742
+ }
39743
+ function emptyReceiverReportTally() {
39744
+ return {
39745
+ reportsParsed: 0,
39746
+ blocksParsed: 0,
39747
+ unreadable: 0,
39748
+ lastFractionLostPct: null,
39749
+ worstFractionLostPct: null,
39750
+ lastCumulativePacketsLost: null,
39751
+ maxCumulativePacketsLost: null,
39752
+ lastJitter: null,
39753
+ maxJitter: null,
39754
+ lastExtendedHighestSequence: null
39755
+ };
39756
+ }
39757
+ function maxOrValue(previous, next) {
39758
+ return previous === null ? next : Math.max(previous, next);
39759
+ }
39760
+ /** Fold one report block into the tally. Returns a new tally. */
39761
+ function applyReceiverReportBlock(tally, block) {
39762
+ return {
39763
+ ...tally,
39764
+ blocksParsed: tally.blocksParsed + 1,
39765
+ lastFractionLostPct: block.fractionLostPct,
39766
+ worstFractionLostPct: maxOrValue(tally.worstFractionLostPct, block.fractionLostPct),
39767
+ lastCumulativePacketsLost: block.cumulativePacketsLost,
39768
+ maxCumulativePacketsLost: maxOrValue(tally.maxCumulativePacketsLost, block.cumulativePacketsLost),
39769
+ lastJitter: block.jitter,
39770
+ maxJitter: maxOrValue(tally.maxJitter, block.jitter),
39771
+ lastExtendedHighestSequence: block.extendedHighestSequence
39772
+ };
39773
+ }
39774
+ /** Book an RTCP datagram we could not read. Counted, never discarded silently. */
39775
+ function recordUnreadableRtcp(tally) {
39776
+ return {
39777
+ ...tally,
39778
+ unreadable: tally.unreadable + 1
39779
+ };
39780
+ }
39781
+ /**
39782
+ * Parse one decrypted RTCP datagram and fold it into a leg's tally.
39783
+ *
39784
+ * This is the seam the delegate calls: it owns SRTCP decryption and logging,
39785
+ * this owns everything that can be asserted from bytes alone. A malformed
39786
+ * packet returns a failure and an incremented `unreadable` — it never throws,
39787
+ * because this runs on a UDP `message` handler where a throw would take the
39788
+ * session with it.
39789
+ */
39790
+ function ingestDecryptedRtcp(plaintext, tally) {
39791
+ const parsed = parseCompoundRtcp(plaintext);
39792
+ if (!parsed.ok) return {
39793
+ tally: recordUnreadableRtcp(tally),
39794
+ reports: [],
39795
+ failure: parsed.failure
39796
+ };
39797
+ let next = tally;
39798
+ for (const report of parsed.reports) {
39799
+ if (report.blocks.length === 0) continue;
39800
+ next = {
39801
+ ...next,
39802
+ reportsParsed: next.reportsParsed + 1
39803
+ };
39804
+ for (const block of report.blocks) next = applyReceiverReportBlock(next, block);
39805
+ }
39806
+ return {
39807
+ tally: next,
39808
+ reports: parsed.reports,
39809
+ failure: null
39810
+ };
39811
+ }
39812
+ function classifyConnection(input) {
39813
+ if (input.negotiatedWidth < 640) return "watch";
39814
+ if (input.audioPacketTimeMs >= 60) return "remote";
39815
+ return input.viaHomeHub ? "home-hub" : "local";
39816
+ }
39817
+ /**
39818
+ * The slot each class asks for.
39819
+ *
39820
+ * ## `local` takes the camera's best stream — settled by measurement
39821
+ *
39822
+ * This function was pinned to `low` for EVERY class by one number. On 615/high,
39823
+ * 3840x2160 pass-through:
39824
+ *
39825
+ * durationMs=30820 videoPacketsForwarded=93 videoKeyframes=1
39826
+ * audioPacketsForwarded=1497 lost=0
39827
+ *
39828
+ * Three video datagrams a second, one key frame in half a minute, while the
39829
+ * AUDIO leg of the *same* ffmpeg ran perfectly. It read as "our path cannot
39830
+ * carry a high-bitrate stream".
39831
+ *
39832
+ * **It was not HomeKit, not 4K and not SRTP. The loopback UDP socket ffmpeg
39833
+ * writes its RTP into had no `SO_RCVBUF` at all** (2026-08-07). It ran on
39834
+ * `net.core.rmem_default`, 212 992 B — about a fifth of one 4K IDR, which
39835
+ * arrives as ~750 datagrams at `pkt_size=1378` in a single burst. The kernel
39836
+ * discarded the overflow, and a datagram dropped there never reaches a
39837
+ * `message` handler, so it lowered the forwarded count exactly like a packet
39838
+ * ffmpeg never wrote and the controller reported no loss for it either. Audio,
39839
+ * a few hundred bytes every 20 ms, never filled the buffer. That is the whole
39840
+ * asymmetry. See `stream-socket-buffer.ts`.
39841
+ *
39842
+ * With an 8 MiB buffer (granted — this hub's `net.core.rmem_max` is 16 MiB),
39843
+ * the same camera and the same slot, session
39844
+ * `12308100-7dbe-4ad1-b277-1f046ba54ec2` on 2026-08-07:
39845
+ *
39846
+ * selectedProfile=high transcode=false slotMeasuredKbps=5097
39847
+ * videoPacketsForwarded=6512 durationMs=9867 (~660/s, was ~3/s)
39848
+ * msToFirstKeyframe=858 deliveredFps=24 worstFractionLostPct=0.4
39849
+ * videoLoopRcvbufBytes=16777216 clamped=false
39850
+ *
39851
+ * Operator: loaded instantly, and visibly not the low stream. A 220x increase
39852
+ * in delivered packet rate from sizing one socket.
39853
+ *
39854
+ * ## Why the remote classes stay `low`
39855
+ *
39856
+ * Not caution left over from the freeze — a different, UNMEASURED question.
39857
+ * `watch`, `remote` and `home-hub` all send video across a link whose budget
39858
+ * nothing here has measured; the buffer fix says something about a loopback hop
39859
+ * inside one host and nothing whatsoever about a WAN. 4K pass-through at ~5 Mbps
39860
+ * to a phone on LTE is a decision that needs its own evidence, and `watch` has
39861
+ * a panel under 640 px wide that could not use the pixels anyway. Raise these
39862
+ * only with a measurement of the remote link, not by analogy with this one.
39863
+ *
39864
+ * `mid` remains excluded from every class, unrelated to all of the above: it is
39865
+ * a 10 fps stream on this fleet and it has never rendered under any combination
39866
+ * tried.
39867
+ */
39868
+ function slotForConnection(connection) {
39869
+ switch (connection) {
39870
+ case "watch": return "low";
39871
+ case "remote": return "low";
39872
+ case "home-hub": return "low";
39873
+ case "local": return "high";
39874
+ }
39875
+ }
39876
+ //#endregion
39877
+ //#region src/mappers/builders/stream-bitrate.ts
39878
+ /**
39879
+ * Send a stream that FITS the rate HomeKit negotiated. (R5)
39880
+ *
39881
+ * The controller's own Receiver Reports, read on the live hub on 2026-08-06,
39882
+ * closed a year of guessing: one 19.27 s session on `615/mid` forwarded 737
39883
+ * video packets at `mtu=1378` — roughly **421 kbps** — against a negotiated
39884
+ * `max_bit_rate` of **299**, and iOS reported losing **450 of those 737
39885
+ * packets (61 %)**, worst fraction lost 51.2 %, peak jitter 3.03 s. Under
39886
+ * `-c:v copy` the accessory has no lever at all: it forwards whatever the
39887
+ * camera's encoder produces, at whatever cadence it produces it.
39888
+ *
39889
+ * So the fix has two halves, and this module owns both:
39890
+ *
39891
+ * 1. **Choose a slot that fits.** Among the slots that can be passed through
39892
+ * (H.264) and whose rate is known to be within budget, the existing
39893
+ * resolution-closest picker decides — so the "which slot serves which
39894
+ * resolution" opinion stays single, exactly as D51 requires.
39895
+ * 2. **Transcode only when none does**, with a real cap
39896
+ * (`-b:v` / `-maxrate` / `-bufsize`) at the negotiated rate.
39897
+ *
39898
+ * ## Where the authoritative rate comes from, and why it is NOT the obvious one
39899
+ *
39900
+ * `webrtcSession.listStreams` reports a `bitrateKbps` per slot and it is a
39901
+ * **measured flow rate**, which is meaningless for a slot nobody is consuming.
39902
+ * Live on 2026-08-06 it reported `mid = 9 kbps` for the very slot that had just
39903
+ * delivered ~421 kbps, `low = 5 kbps`, and `high = 5441 kbps` (high was being
39904
+ * consumed, hence plausible). Selecting on that reading would admit every slot.
39905
+ *
39906
+ * The authority is therefore the camera's **configured** encoder rate, from
39907
+ * `streamParams.getStatus` — `main` / `sub` / `ext`, each carrying the
39908
+ * `bitrate` the operator (or the vendor default) set. On 615 that is
39909
+ * `main 8192`, `sub 2048`, `ext 2048` kbps. It is mapped onto a profile slot
39910
+ * through the slot's assigned cam-stream, matched on resolution and frame
39911
+ * rate; an ambiguous or absent match is reported as **unknown**, never as a
39912
+ * number.
39913
+ *
39914
+ * This inverts D51's ordering — there, `measured` outranks `published` — and
39915
+ * the inversion is deliberate:
39916
+ *
39917
+ * - a frame rate is a stable property of the source and a measurement of it
39918
+ * is the *best* evidence;
39919
+ * - a bitrate under VBR is an envelope. A measurement is a **lower bound**
39920
+ * on it, and a lower bound can prove a slot does NOT fit but can never
39921
+ * prove that it does.
39922
+ *
39923
+ * So `measured` is kept, and used only in the direction it is sound in.
39924
+ * Everything here is pure; the cap reads live in `stream-bitrate-probe.ts`.
39925
+ */
39926
+ /**
39927
+ * Fraction of the negotiated ceiling we actually aim the encoder at.
39928
+ *
39929
+ * `max_bit_rate` is what the controller budgeted for the stream; what crosses
39930
+ * the wire is the encoded payload PLUS its packetisation. At `mtu = 1378` each
39931
+ * packet carries a 12-byte RTP header and a 10-byte SRTP auth tag, and the
39932
+ * datagram adds 8 (UDP) + 20 (IPv4) — 50 bytes on ~1378, i.e. **3.6 %**. The
39933
+ * remaining ~6 % is margin for a VBV overshoot inside the buffer window.
39934
+ * Reserving it is the difference between "at the ceiling" and "over it".
39935
+ */
39936
+ var BITRATE_HEADROOM = .9;
39937
+ /** Encoder slots a `stream-params` provider exposes, in the cap's own order. */
39938
+ var ENCODER_PROFILE_KEYS = [
39939
+ "main",
39940
+ "sub",
39941
+ "ext"
39942
+ ];
39943
+ /**
39944
+ * Resolve every profile slot's rate from the camera's configuration, with the
39945
+ * broker's flow reading kept alongside it as a lower bound.
39946
+ *
39947
+ * A slot with neither simply carries two nulls — absence is never rendered as
39948
+ * a number, because a wrong number here silently re-creates the overshoot this
39949
+ * module exists to end.
39950
+ */
39951
+ function resolveProfileBitrates(input) {
39952
+ const camStreamById = /* @__PURE__ */ new Map();
39953
+ for (const stream of input.camStreams) camStreamById.set(stream.camStreamId, stream);
39954
+ const measuredByProfile = /* @__PURE__ */ new Map();
39955
+ for (const choice of input.choices) {
39956
+ if (choice.target.kind !== "profile") continue;
39957
+ const plausible = plausibleMeasured(choice.bitrateKbps);
39958
+ if (plausible !== null) measuredByProfile.set(choice.target.profile, plausible);
39959
+ }
39960
+ const out = /* @__PURE__ */ new Map();
39961
+ for (const slot of input.slots) {
39962
+ const source = slot.sourceCamStreamId === null ? void 0 : camStreamById.get(slot.sourceCamStreamId);
39963
+ out.set(slot.profile, {
39964
+ profile: slot.profile,
39965
+ publishedKbps: publishedRateFor(slot, source, input.streamParams),
39966
+ measuredKbps: measuredByProfile.get(slot.profile) ?? null
39967
+ });
39968
+ }
39969
+ for (const [profile, measured] of measuredByProfile) {
39970
+ if (out.has(profile)) continue;
39971
+ out.set(profile, {
39972
+ profile,
39973
+ publishedKbps: null,
39974
+ measuredKbps: measured
39975
+ });
39976
+ }
39977
+ return out;
39978
+ }
39979
+ /**
39980
+ * The rate the encoder may actually use, or `null` when the controller
39981
+ * negotiated no usable ceiling.
39982
+ */
39983
+ function budgetForNegotiatedRate(negotiatedMaxBitrateKbps) {
39984
+ if (!Number.isFinite(negotiatedMaxBitrateKbps) || negotiatedMaxBitrateKbps <= 0) return null;
39985
+ const budget = Math.floor(negotiatedMaxBitrateKbps * BITRATE_HEADROOM);
39986
+ return budget > 0 ? budget : null;
39987
+ }
39988
+ /**
39989
+ * Does this slot fit?
39990
+ *
39991
+ * Only the CONFIGURED rate can answer yes. The measured rate is a lower bound,
39992
+ * so it is allowed to answer no — including against an optimistic publication.
39993
+ */
39994
+ function classifyBitrateFit(evidence, budgetKbps) {
39995
+ if (evidence === void 0) return "unknown";
39996
+ const { publishedKbps, measuredKbps } = evidence;
39997
+ if (measuredKbps !== null && measuredKbps > budgetKbps) return "over-budget";
39998
+ if (publishedKbps === null) return "unknown";
39999
+ return publishedKbps <= budgetKbps ? "fits" : "over-budget";
40000
+ }
40001
+ /**
40002
+ * Pick the stream to serve, and decide whether it can be passed through.
40003
+ *
40004
+ * The selection runs the SAME `pickPreferredRtspEntry` the advertisement is
40005
+ * derived from (D51) — only the candidate set narrows. When at least one
40006
+ * pass-through-capable slot fits the budget, the picker resolves the target
40007
+ * resolution among those and we copy. Otherwise the picker resolves among ALL
40008
+ * entries — so the transcode decodes the slot closest to the negotiated
40009
+ * resolution rather than the largest one on the camera — and we re-encode.
40010
+ *
40011
+ * A pinned `streamPreference` is never overridden by the budget: the pinned
40012
+ * slot is transcoded rather than swapped for a cheaper one.
40013
+ *
40014
+ * Returns `null` when nothing is publishable at all.
40015
+ */
40016
+ function selectStreamForBudget(input) {
40017
+ const budgetKbps = budgetForNegotiatedRate(input.negotiatedMaxBitrateKbps);
40018
+ const notes = fitNotes(input.entries, input.bitrates, budgetKbps);
40019
+ const effectivePref = input.pref === "auto" ? slotForConnection(input.connection) : input.pref;
40020
+ const fallback = pickPreferredRtspEntry(input.entries, effectivePref, input.deviceId, { targetResolution: input.targetResolution });
40021
+ if (fallback === null) return null;
40022
+ const fallbackProfile = toCamProfile$1(fallback.profileId);
40023
+ if (budgetKbps === null) {
40024
+ const base = {
40025
+ picked: fallback,
40026
+ profile: fallbackProfile,
40027
+ budgetKbps: null,
40028
+ notes
40029
+ };
40030
+ return canPassThrough(fallback.codec) ? {
40031
+ kind: "copy",
40032
+ reason: "no-negotiated-budget",
40033
+ ...base
40034
+ } : {
40035
+ kind: "transcode",
40036
+ reason: "source-codec",
40037
+ ...base
40038
+ };
40039
+ }
40040
+ const affordable = (input.pref !== "auto" && fallbackProfile === input.pref ? input.entries.filter((entry) => entry.profile === fallbackProfile) : input.entries).filter((entry) => {
40041
+ if (!canPassThrough(entry.codec)) return false;
40042
+ return classifyBitrateFit(input.bitrates.get(entry.profile), budgetKbps) === "fits";
40043
+ });
40044
+ if (affordable.length > 0) {
40045
+ const picked = pickPreferredRtspEntry(affordable, effectivePref, input.deviceId, { targetResolution: input.targetResolution });
40046
+ if (picked !== null) return {
40047
+ kind: "copy",
40048
+ reason: "source-fits-budget",
40049
+ picked,
40050
+ profile: toCamProfile$1(picked.profileId),
40051
+ budgetKbps,
40052
+ notes
40053
+ };
40054
+ }
40055
+ if (canPassThrough(fallback.codec)) return {
40056
+ kind: "copy",
40057
+ reason: "over-budget-tolerated",
40058
+ picked: fallback,
40059
+ profile: fallbackProfile,
40060
+ budgetKbps,
40061
+ notes
40062
+ };
40063
+ return {
40064
+ kind: "transcode",
40065
+ reason: transcodeReason(fallback, fallbackProfile, input.bitrates, budgetKbps),
40066
+ picked: fallback,
40067
+ profile: fallbackProfile,
40068
+ budgetKbps,
40069
+ notes
40070
+ };
40071
+ }
40072
+ /**
40073
+ * Fill in a codec the profile restream entry did not carry, from its broker
40074
+ * slot. `getProfileRtspEntries` has historically omitted it for legacy
40075
+ * entries, and a slot whose codec is unknown must not be mistaken for H.264.
40076
+ */
40077
+ function withSlotCodecs(entries, slots) {
40078
+ const codecByProfile = /* @__PURE__ */ new Map();
40079
+ for (const slot of slots) if (slot.codec !== void 0) codecByProfile.set(slot.profile, slot.codec);
40080
+ return entries.map((entry) => {
40081
+ if (entry.codec !== void 0) return entry;
40082
+ const codec = codecByProfile.get(entry.profile);
40083
+ return codec === void 0 ? entry : {
40084
+ ...entry,
40085
+ codec
40086
+ };
40087
+ });
40088
+ }
40089
+ /**
40090
+ * The video half of the ffmpeg plan, in the SHARED vocabulary
40091
+ * (`@camstack/types` `ffmpeg/invocation.ts`). This function used to emit
40092
+ * arguments; it now describes them, and `buildFfmpegArgs` emits every one — the
40093
+ * repo keeps exactly one argv builder, and HomeKit stopped being an exception
40094
+ * to that (D67, `scripts/check-ffmpeg-primitive.ts` Rule 1).
40095
+ *
40096
+ * Nothing about the RESULT changed except the rescale spelling: `-s WxH` became
40097
+ * `-vf scale=W:H`. Equivalent for a plain rescale, and worth knowing because
40098
+ * the two are NOT interchangeable once another `-vf` is in play.
40099
+ *
40100
+ * Pass-through carries `-bsf:v dump_extra` so every IDR inlines its own
40101
+ * SPS/PPS — sources that publish parameter sets only in the RTSP SDP hand iOS
40102
+ * a keyframe it cannot decode otherwise.
40103
+ *
40104
+ * The transcode's cap is three flags, not one: `-b:v` is an average and on its
40105
+ * own permits exactly the burst that was measured. `-maxrate` plus a
40106
+ * **one-second** `-bufsize` bounds any one-second window at the negotiated
40107
+ * rate, which is also the only lever available on the 3.03 s peak jitter — the
40108
+ * VBV window is what forces x264 to size a key frame to fit rather than
40109
+ * emitting it as one tight burst. That window is {@link RATE_CONTROL_TIGHT},
40110
+ * the shared constant whose whole reason to exist is HomeKit's per-second
40111
+ * budget; the browser and Echo use the relaxed two-second one.
40112
+ *
40113
+ * **The encoder stays `libx264`, deliberately.** `h264_vaapi` / `h264_qsv`
40114
+ * carry their own rate-control model, do not accept `-profile:v baseline`, and
40115
+ * emit parameter sets on their own schedule rather than x264's — which puts the
40116
+ * two load-bearing flags below back in play, with no hardware here to prove
40117
+ * they still hold. Hardware DECODE is where the measured cost is.
40118
+ */
40119
+ function buildVideoPlan(input) {
40120
+ if (!input.transcode) return {
40121
+ kind: "copy",
40122
+ bitstreamFilter: "dump_extra"
40123
+ };
40124
+ return {
40125
+ kind: "encode",
40126
+ encoder: "libx264",
40127
+ scale: {
40128
+ mode: "exact",
40129
+ width: input.width,
40130
+ height: input.height
40131
+ },
40132
+ preset: "ultrafast",
40133
+ tune: "zerolatency",
40134
+ profile: "baseline",
40135
+ level: "3.1",
40136
+ pixelFormat: "yuv420p",
40137
+ fps: input.fps,
40138
+ gopFrames: Math.max(1, Math.round(input.fps * 4)),
40139
+ ...input.budgetKbps === null ? {} : {
40140
+ bitrateKbps: input.budgetKbps,
40141
+ rateControl: RATE_CONTROL_TIGHT
40142
+ },
40143
+ bitstreamFilter: "dump_extra"
40144
+ };
40145
+ }
40146
+ /** Compact `mid=2048pub/9meas:over-budget` rendering for a single log field. */
40147
+ function formatFitNotes(notes) {
40148
+ return notes.map((n) => `${n.profile}=${n.publishedKbps ?? "?"}pub/${n.measuredKbps ?? "?"}meas:${n.verdict}`);
40149
+ }
40150
+ function fitNotes(entries, bitrates, budgetKbps) {
40151
+ return entries.map((entry) => {
40152
+ const evidence = bitrates.get(entry.profile);
40153
+ return {
40154
+ profile: entry.profile,
40155
+ verdict: budgetKbps === null ? "unknown" : classifyBitrateFit(evidence, budgetKbps),
40156
+ publishedKbps: evidence?.publishedKbps ?? null,
40157
+ measuredKbps: evidence?.measuredKbps ?? null
40158
+ };
40159
+ });
40160
+ }
40161
+ function transcodeReason(picked, profile, bitrates, budgetKbps) {
40162
+ if (!canPassThrough(picked.codec)) return "source-codec";
40163
+ if (profile === null) return "unknown-bitrate";
40164
+ return classifyBitrateFit(bitrates.get(profile), budgetKbps) === "over-budget" ? "over-budget" : "unknown-bitrate";
40165
+ }
40166
+ /**
40167
+ * iOS Home renders only H.264 over the classic HAP SRTP path, so an H.265
40168
+ * source can never be passed through. An UNKNOWN codec is treated the same
40169
+ * way: guessing H.264 is how a camera that changed its encoder ends up
40170
+ * shipping bytes no controller can decode.
40171
+ */
40172
+ function canPassThrough(codec) {
40173
+ if (codec === void 0) return false;
40174
+ const lower = codec.toLowerCase();
40175
+ if (lower.includes("h265") || lower.includes("hevc")) return false;
40176
+ return lower.includes("h264") || lower.includes("avc");
40177
+ }
40178
+ function plausibleMeasured(value) {
40179
+ if (value === null || !Number.isFinite(value)) return null;
40180
+ return value >= 64 ? value : null;
40181
+ }
40182
+ /**
40183
+ * Map a profile slot onto the camera encoder feeding it, and read that
40184
+ * encoder's configured bitrate.
40185
+ *
40186
+ * The link is the slot's assigned cam-stream: its resolution (and frame rate,
40187
+ * when two encoders share a resolution) identifies which of `main`/`sub`/`ext`
40188
+ * produces it. Vendor-neutral on purpose — the cam-stream ids (`native:main`,
40189
+ * `native:slot-3`, …) are provider strings and matching on them would work for
40190
+ * exactly one provider. An ambiguous match returns `null`.
40191
+ */
40192
+ function publishedRateFor(slot, source, streamParams) {
40193
+ if (streamParams === null) return null;
40194
+ const resolution = source?.resolution ?? slot.resolution;
40195
+ if (resolution === void 0) return null;
40196
+ const byResolution = ENCODER_PROFILE_KEYS.map((key) => encoderConfig(streamParams, key)).filter((cfg) => cfg !== null && cfg.width === resolution.width && cfg.height === resolution.height);
40197
+ if (byResolution.length === 1) return positiveOrNull(byResolution[0]?.bitrate);
40198
+ const fps = source?.fps;
40199
+ if (fps === void 0) return null;
40200
+ const byFps = byResolution.filter((cfg) => cfg !== null && Math.floor(cfg.framerate) === Math.floor(fps));
40201
+ return byFps.length === 1 ? positiveOrNull(byFps[0]?.bitrate) : null;
40202
+ }
40203
+ function encoderConfig(status, key) {
40204
+ return status[key] ?? null;
40205
+ }
40206
+ function positiveOrNull(value) {
40207
+ if (value === void 0 || !Number.isFinite(value) || value <= 0) return null;
40208
+ return value;
40209
+ }
40210
+ var CAM_PROFILES$1 = [
40211
+ "high",
40212
+ "mid",
40213
+ "low"
40214
+ ];
40215
+ /**
40216
+ * `PickedStream.profileId` is the brokerId suffix, which for a profile-keyed
40217
+ * entry IS the profile name. Anything else addresses a raw cam-stream and must
40218
+ * not be coerced into a profile.
40219
+ */
40220
+ function toCamProfile$1(profileId) {
40221
+ return CAM_PROFILES$1.find((p) => p === profileId) ?? null;
40222
+ }
40223
+ //#endregion
40224
+ //#region src/mappers/builders/deadline.ts
40225
+ /**
40226
+ * Bound a piece of optional work in time.
40227
+ *
40228
+ * HomeKit answers `Selected RTP Stream Configuration` inside a write handler
40229
+ * hap-nodejs expects back quickly, and the start path behind it makes six
40230
+ * sequential cross-process cap calls into a stream-broker that regularly
40231
+ * freezes for two to three seconds at a time. Measured on the live hub: the
40232
+ * controller negotiated at :11, gave up at 9.1 s, and the bitrate fit resolved
40233
+ * at :32 — twenty-one seconds — with the start then failing on `Not running`
40234
+ * because the session it was preparing no longer existed.
40235
+ *
40236
+ * The evidence those calls gather is genuinely optional: an absent reading
40237
+ * classifies as `unknown`, and the tolerated branch still picks a slot. So the
40238
+ * right trade under load is to answer with less evidence rather than late, and
40239
+ * this makes that trade explicit at each call site instead of leaving it to
40240
+ * whatever the broker's latency happens to be.
40241
+ *
40242
+ * A late failure from work we stopped waiting on is swallowed on purpose: the
40243
+ * probe keeps running after the deadline fires, and an unhandled rejection
40244
+ * from an abandoned probe would take the process down over a reading nobody is
40245
+ * using any more.
40246
+ */
40247
+ var TIMED_OUT = Symbol("deadline:timed-out");
40248
+ var FAILED = Symbol("deadline:failed");
40249
+ async function withDeadline(work, ms, fallback, onTimeout) {
40250
+ let timer;
40251
+ const guard = new Promise((resolve) => {
40252
+ timer = setTimeout(() => resolve(TIMED_OUT), ms);
40253
+ });
40254
+ try {
40255
+ const settled = await Promise.race([work.catch(() => FAILED), guard]);
40256
+ if (settled === TIMED_OUT) {
40257
+ onTimeout();
40258
+ return fallback;
40259
+ }
40260
+ return settled === FAILED ? fallback : settled;
40261
+ } finally {
40262
+ if (timer !== void 0) clearTimeout(timer);
40263
+ work.catch(() => void 0);
40264
+ }
40265
+ }
40266
+ //#endregion
40267
+ //#region src/mappers/builders/stream-bitrate-probe.ts
40268
+ /**
40269
+ * Total budget for the rate evidence, not per call — the point is to bound
40270
+ * what the CONTROLLER waits for, and it waits for the sum.
40271
+ */
40272
+ var BITRATE_EVIDENCE_BUDGET_MS = 1500;
40273
+ var NO_EVIDENCE = {
40274
+ camStreams: null,
40275
+ streamParams: null,
40276
+ choices: null
40277
+ };
40278
+ /**
40279
+ * The last evidence that actually arrived, per device.
40280
+ *
40281
+ * Falling back to NO evidence on a slow read was not a neutral degradation: it
40282
+ * changed WHICH SLOT the picker chose. Measured on 615 within forty seconds,
40283
+ * same camera, same negotiated 1280x720:
40284
+ *
40285
+ * 10:22:11 mid 10 fps
40286
+ * 10:22:17 low 24 fps
40287
+ * 10:22:26 mid 10 fps
40288
+ * 10:22:50 low 24 fps
40289
+ *
40290
+ * With evidence, `low` classifies as a fit and wins; without it every slot is
40291
+ * `unknown` and the fallback takes `mid`. So the stream a controller received
40292
+ * depended on whether a cap read beat a 1500 ms timer — a coin flip, and one
40293
+ * that hands iOS a different profile on each retry.
40294
+ *
40295
+ * A rate is a property of the camera's encoder configuration, which changes
40296
+ * when an operator changes it and not otherwise. Yesterday's reading is a far
40297
+ * better answer than no reading, and the ONE case that must still see fresh
40298
+ * numbers — the operator lowering a substream — is a deliberate act followed
40299
+ * by a new session, by which time the background read has long landed.
40300
+ */
40301
+ var lastGoodEvidence = /* @__PURE__ */ new Map();
40302
+ /**
40303
+ * Resolve every profile slot's rate. Never throws and never outlives its
40304
+ * budget: a slow read falls back to this device's last good reading, and only
40305
+ * a device that has never answered at all ends up `unknown`.
40306
+ */
40307
+ async function probeProfileBitrates(input) {
40308
+ const deviceId = input.bctx.numericDeviceId;
40309
+ const evidence = await gatherRateEvidence(input.bctx.proxy, input.log, lastGoodEvidence.get(deviceId));
40310
+ if (evidence.camStreams !== null || evidence.streamParams !== null) lastGoodEvidence.set(deviceId, evidence);
40311
+ return resolveProfileBitrates({
40312
+ slots: input.slots,
40313
+ camStreams: evidence.camStreams ?? [],
40314
+ streamParams: evidence.streamParams,
40315
+ choices: evidence.choices ?? []
40316
+ });
40317
+ }
40318
+ /**
40319
+ * Issue the three reads CONCURRENTLY under one budget.
40320
+ *
40321
+ * Exported so the concurrency and the budget can be asserted directly: run in
40322
+ * sequence these latencies add, and adding them is what cost a session.
40323
+ */
40324
+ async function gatherRateEvidence(proxy, log, lastGood) {
40325
+ const startedAt = Date.now();
40326
+ const evidence = await withDeadline(Promise.all([
40327
+ probe$1(() => proxy.cameraStreams?.getCameraStreams({}), "cameraStreams.getCameraStreams", log),
40328
+ probe$1(() => proxy.streamParams?.getStatus({}), "streamParams.getStatus", log),
40329
+ probe$1(() => proxy.webrtcSession?.listStreams({}), "webrtcSession.listStreams", log)
40330
+ ]).then(([camStreams, streamParams, choices]) => ({
40331
+ camStreams,
40332
+ streamParams,
40333
+ choices
40334
+ })), BITRATE_EVIDENCE_BUDGET_MS, lastGood ?? NO_EVIDENCE, () => {
40335
+ log.warn("export-hap: rate evidence ABANDONED on its budget", { meta: {
40336
+ budgetMs: BITRATE_EVIDENCE_BUDGET_MS,
40337
+ fellBackTo: lastGood === void 0 ? "no-evidence" : "last-good",
40338
+ consequence: lastGood === void 0 ? "every slot rate reads UNKNOWN; the picker loses its rate preference" : "the previous reading decides the fit, so the chosen slot stays STABLE"
40339
+ } });
40340
+ });
40341
+ const elapsedMs = Date.now() - startedAt;
40342
+ if (elapsedMs > 1500 / 2) log.info("export-hap: rate evidence was slow", { meta: {
40343
+ elapsedMs,
40344
+ budgetMs: BITRATE_EVIDENCE_BUDGET_MS
40345
+ } });
40346
+ return evidence;
40347
+ }
40348
+ async function probe$1(call, label, log) {
40349
+ try {
40350
+ const pending = call();
40351
+ if (pending === void 0) {
40352
+ log.info("export-hap: bitrate probe skipped — cap not bound on this device", { meta: { call: label } });
40353
+ return null;
40354
+ }
40355
+ return await pending;
40356
+ } catch (err) {
40357
+ log.warn("export-hap: bitrate probe failed — the slot rate stays UNKNOWN", { meta: {
40358
+ call: label,
40359
+ error: err instanceof Error ? err.message : String(err)
40360
+ } });
40361
+ return null;
40362
+ }
40363
+ }
40364
+ //#endregion
40365
+ //#region src/mappers/builders/stream-ffmpeg-args.ts
40366
+ /**
40367
+ * The ffmpeg PLAN for one HomeKit streaming session.
40368
+ *
40369
+ * This file used to assemble the argument vector by hand. It no longer emits a
40370
+ * single argument: it describes the session as an {@link FfmpegInvocation} and
40371
+ * `buildFfmpegArgs` (`@camstack/types` `ffmpeg/invocation.ts`) emits every one.
40372
+ * The repo keeps exactly ONE argv builder — `scripts/check-ffmpeg-primitive.ts`
40373
+ * Rule 1 refuses a second, and HomeKit was the last exception (D67).
40374
+ *
40375
+ * ## What moved behind the primitive, and what stayed here
40376
+ *
40377
+ * MOVED — everything that describes an ENCODE, because it is the same job every
40378
+ * other live egress does and the repo had five disagreeing copies of it: the
40379
+ * encoder, preset, tune, profile, level, pixel format, rate, GOP, the tight VBV
40380
+ * window ({@link RATE_CONTROL_TIGHT}), the bitstream filter, and the Opus block
40381
+ * ({@link HAP_AUDIO_BASE}).
40382
+ *
40383
+ * STAYED — everything that is a HAP PROTOCOL fact and belongs to no other
40384
+ * consumer: the payload types, the SSRCs (and their signed-int32 coercion), the
40385
+ * MTU baked into each `rtp://…?pkt_size=` target, the loopback ports the
40386
+ * JS-side SRTP encrypt reads from, and the negotiated audio sample rate and
40387
+ * packet time.
40388
+ *
40389
+ * ## The two flags this file exists to protect
40390
+ *
40391
+ * `-g` and `-bsf:v dump_extra` were two of the four causes of the year-long
40392
+ * failure, and both live in the encode plan now. They are asserted by token
40393
+ * AND by position in `__tests__/stream-ffmpeg-argv.spec.ts`, on both the copy
40394
+ * and the encode branch, so the move behind the primitive cannot quietly drop
40395
+ * either. Every other comment below records something learned the expensive
40396
+ * way; deleting one loses the reason a flag is there.
40397
+ */
40398
+ /**
40399
+ * Opus encoder targets — kept low because:
40400
+ * - Camera audio is overwhelmingly speech / ambient noise; 24 kbps mono
40401
+ * is the published "fullband speech" sweet spot for libopus (well
40402
+ * above the 20 kbps "wideband speech" floor).
40403
+ * - HAP audio is one-shot live (no buffering on the controller side),
40404
+ * so under-shooting the bitrate is cheaper than over-shooting it and
40405
+ * hitting jitter.
40406
+ * - Mono / low-delay profile matches Apple Home's published Opus decoder
40407
+ * expectations for camera accessories.
40408
+ *
40409
+ * The numbers themselves live in `@camstack/types` `ffmpeg/encode-defaults.ts`
40410
+ * now, alongside every other live-egress constant, so the five sets that used
40411
+ * to disagree about Opus channel count can be diffed in one place. Re-exported
40412
+ * here because the session telemetry reports the bitrate it dialled.
40413
+ */
40414
+ var OPUS_BITRATE_KBPS = 24;
40415
+ /**
40416
+ * The Opus plane, per session.
40417
+ *
40418
+ * Re-encoded regardless of source codec: the source pool is a mix of
40419
+ * PCM_MULAW, PCM_ALAW, G.711 and AAC depending on driver, and Apple Home
40420
+ * expects Opus on the wire.
40421
+ *
40422
+ * `sampleRateHz` and `frameDurationMs` are NEGOTIATED — the controller picks
40423
+ * them — which is why the shared {@link HAP_AUDIO_BASE} leaves both out and
40424
+ * they are filled in here.
40425
+ *
40426
+ * CRITICAL on the sample rate: encode at the rate iOS asked for, never a
40427
+ * constant. iOS's `AudioStreamingSamplerate` enum surfaces as 8 / 16 / 24 kHz;
40428
+ * encoding at 24 when iOS asked for 16 produces RTP timestamps stepping by 480
40429
+ * samples/packet against a clock expecting 320 — the SRTP frames decrypt
40430
+ * cleanly but the speaker stays mute, because the timestamps slide out of the
40431
+ * AV-sync window before the first Opus frame renders. The same request value
40432
+ * drives `audioIntervalScale` in the re-stamping pass, so the two MUST come
40433
+ * from one source.
40434
+ *
40435
+ * On the frame duration: libopus emits exactly one RTP packet per Opus frame at
40436
+ * that duration, and matching HAP's `packet_time` (20 ms on LAN, 30/40/60 on
40437
+ * LTE) is what keeps the 1:1 frame↔packet mapping the controller expects.
40438
+ */
40439
+ function audioPlan(input) {
40440
+ return {
40441
+ ...HAP_AUDIO_BASE,
40442
+ sampleRateHz: input.audioSampleRateKhz * 1e3,
40443
+ frameDurationMs: input.audioPacketTimeMs,
40444
+ vbvBufferKbits: 96
40445
+ };
40446
+ }
40447
+ /**
40448
+ * Two outputs from one input: video SRTP and audio SRTP, one process, one
40449
+ * lifetime, one kill signal. The shared builder's `rtp-outputs` sink maps each
40450
+ * plane explicitly (`-an -map 0:v:0` / `-vn -map 0:a:0?`) so ffmpeg never
40451
+ * guesses which stream belongs where, and `0:a:0?` makes the audio optional so
40452
+ * a source with no microphone skips it instead of failing the invocation.
40453
+ */
40454
+ /**
40455
+ * How long ffmpeg may inspect the broker's restream before emitting.
40456
+ *
40457
+ * Not zero. A zero-length probe makes ffmpeg trust the SDP completely, and an
40458
+ * RTSP source that announces a track it then never sends would leave the
40459
+ * mapping wrong with no way to notice. 200 ms and 64 KB is far below the
40460
+ * shortest key-frame interval on this fleet while still letting the demuxer
40461
+ * see real packets — enough to be honest, short enough that nobody watches it.
40462
+ */
40463
+ var HAP_INPUT_PROBE = {
40464
+ analyzeDurationUs: 2e5,
40465
+ probeSizeBytes: 64 * 1024
40466
+ };
40467
+ function buildSessionInvocation(input) {
40468
+ return {
40469
+ logLevel: "warning",
40470
+ decodeHwAccel: input.decode.hwaccel,
40471
+ input: {
40472
+ url: input.rtspUrl,
40473
+ rtspTransport: "tcp",
40474
+ analyzeDurationUs: HAP_INPUT_PROBE.analyzeDurationUs,
40475
+ probeSizeBytes: HAP_INPUT_PROBE.probeSizeBytes,
40476
+ ...input.decode.extraInputArgs.length > 0 ? { extraArgs: input.decode.extraInputArgs } : {}
40477
+ },
40478
+ video: input.video,
40479
+ audio: audioPlan(input),
40480
+ threadCount: 0,
40481
+ outputArgs: [],
40482
+ sink: {
40483
+ kind: "rtp-outputs",
40484
+ video: {
40485
+ url: input.videoTarget,
40486
+ payloadType: input.videoPayloadType,
40487
+ ssrc: input.videoSsrcSigned
40488
+ },
40489
+ audio: {
40490
+ url: input.audioTarget,
40491
+ payloadType: input.audioPayloadType,
40492
+ ssrc: input.audioSsrcSigned
40493
+ }
40494
+ }
40495
+ };
40496
+ }
38332
40497
  /**
38333
40498
  * The resolutions we offer, before rates are attached. Same list the delegate
38334
40499
  * advertised before R2 — only the frame rate changes, so a controller that had
@@ -38468,6 +40633,355 @@ function toCamProfile(profileId) {
38468
40633
  return CAM_PROFILES.find((p) => p === profileId) ?? null;
38469
40634
  }
38470
40635
  //#endregion
40636
+ //#region src/mappers/builders/h264-idr.ts
40637
+ /**
40638
+ * Does this RTP packet carry the start of an H.264 IDR?
40639
+ *
40640
+ * A pass-through session cannot manufacture a key frame on demand — it can
40641
+ * only forward the one the camera decides to emit. So the number that decides
40642
+ * whether a controller sees a picture or a loader is *how long it waited for
40643
+ * the first IDR*, and until now nothing measured it: a session could report
40644
+ * a thousand packets forwarded, zero loss, and a blank screen, with no field
40645
+ * distinguishing "the stream is broken" from "the next key frame is 20
40646
+ * seconds away".
40647
+ *
40648
+ * That is the whole reason this exists, so it is deliberately narrow: a
40649
+ * boolean per packet, no state, no allocation, and it never throws. It runs on
40650
+ * every forwarded video packet, and a parser that throws on a malformed packet
40651
+ * would take the media path down with it.
40652
+ */
40653
+ /** NAL unit type carrying a coded slice of an IDR picture (RFC 6184 §5.2). */
40654
+ var NAL_TYPE_IDR = 5;
40655
+ /** Single-time aggregation packet — several NALs in one RTP payload. */
40656
+ var NAL_TYPE_STAP_A = 24;
40657
+ /** Fragmentation units: one NAL spread over several RTP payloads. */
40658
+ var NAL_TYPE_FU_A = 28;
40659
+ var NAL_TYPE_FU_B = 29;
40660
+ var RTP_MIN_HEADER_BYTES = 12;
40661
+ var NAL_TYPE_MASK = 31;
40662
+ /** FU header start bit — set only on the FIRST fragment of a fragmented NAL. */
40663
+ var FU_START_BIT = 128;
40664
+ function rtpPacketCarriesIdr(packet) {
40665
+ const payloadStart = rtpPayloadOffset(packet);
40666
+ if (payloadStart === null) return false;
40667
+ const firstPayloadByte = packet[payloadStart];
40668
+ if (firstPayloadByte === void 0) return false;
40669
+ const nalType = firstPayloadByte & NAL_TYPE_MASK;
40670
+ if (nalType === NAL_TYPE_FU_A || nalType === NAL_TYPE_FU_B) {
40671
+ const fuHeader = packet[payloadStart + 1];
40672
+ if (fuHeader === void 0) return false;
40673
+ if ((fuHeader & FU_START_BIT) === 0) return false;
40674
+ return (fuHeader & NAL_TYPE_MASK) === NAL_TYPE_IDR;
40675
+ }
40676
+ if (nalType === NAL_TYPE_STAP_A) return stapContainsIdr(packet, payloadStart + 1);
40677
+ return nalType === NAL_TYPE_IDR;
40678
+ }
40679
+ /**
40680
+ * Byte offset of the RTP payload, or `null` when the packet is too short to
40681
+ * hold one. The variable-length parts are what make this worth a function:
40682
+ * a fixed offset of 12 is right for every packet ffmpeg emits today and wrong
40683
+ * the moment one carries a CSRC list or a header extension.
40684
+ */
40685
+ function rtpPayloadOffset(packet) {
40686
+ if (packet.length <= RTP_MIN_HEADER_BYTES) return null;
40687
+ const flags = packet[0];
40688
+ if (flags === void 0) return null;
40689
+ const csrcCount = flags & 15;
40690
+ const hasExtension = (flags & 16) !== 0;
40691
+ let offset = RTP_MIN_HEADER_BYTES + csrcCount * 4;
40692
+ if (hasExtension) {
40693
+ if (offset + 4 > packet.length) return null;
40694
+ const words = packet.readUInt16BE(offset + 2);
40695
+ offset += 4 + words * 4;
40696
+ }
40697
+ return offset < packet.length ? offset : null;
40698
+ }
40699
+ /** Walk a STAP-A's `[size][nal]` pairs looking for an IDR. */
40700
+ function stapContainsIdr(packet, start) {
40701
+ let offset = start;
40702
+ while (offset + 2 <= packet.length) {
40703
+ const size = packet.readUInt16BE(offset);
40704
+ offset += 2;
40705
+ if (size === 0 || offset + size > packet.length) return false;
40706
+ const nalHeader = packet[offset];
40707
+ if (nalHeader === void 0) return false;
40708
+ if ((nalHeader & NAL_TYPE_MASK) === NAL_TYPE_IDR) return true;
40709
+ offset += size;
40710
+ }
40711
+ return false;
40712
+ }
40713
+ /**
40714
+ * How long after spawn an exit still counts as "hardware init failed".
40715
+ *
40716
+ * Same window the ffmpeg decoder addon uses for its own cascade. A hardware
40717
+ * context that cannot be created fails within milliseconds; anything that ran
40718
+ * longer produced no frames for a different reason, and re-spawning it in
40719
+ * software would just hide that reason.
40720
+ */
40721
+ var HW_DECODE_FALLBACK_WINDOW_MS = 4e3;
40722
+ /** Backends whose decode binds to a DRM render node. */
40723
+ var RENDER_NODE_BACKENDS = ["vaapi", "qsv"];
40724
+ /**
40725
+ * Values of the decoder cap's `hwaccel` field that are operator CHOICES rather
40726
+ * than devices.
40727
+ */
40728
+ var HWACCEL_NON_BACKEND_CHOICES = ["auto", "none"];
40729
+ /**
40730
+ * Every backend the decoder cap can publish, taken from the cap's own UI option
40731
+ * list so this module cannot drift from it. A hand-written copy of the union is
40732
+ * exactly the "hand-written cap interface that rots silently" this repo has
40733
+ * been bitten by.
40734
+ */
40735
+ var HWACCEL_BACKENDS = new Set(HWACCEL_OPTIONS.map((option) => option.value).filter((value) => !HWACCEL_NON_BACKEND_CHOICES.includes(value)));
40736
+ /** Is this string a hardware backend, as opposed to `auto`, `none` or junk? */
40737
+ function isHwAccelBackend(value) {
40738
+ return HWACCEL_BACKENDS.has(value);
40739
+ }
40740
+ /**
40741
+ * Decide how this session decodes, and produce the ffmpeg input-side flags.
40742
+ *
40743
+ * Every path that ends in software carries a named reason, because a session
40744
+ * that quietly stopped using the GPU and a session that never had one look
40745
+ * identical in a CPU graph.
40746
+ */
40747
+ function selectHwDecode(input) {
40748
+ if (!input.transcode) return software("pass-through");
40749
+ if (input.hardwareAlreadyFailed === true) return software("hardware-attempt-failed");
40750
+ if (input.reading === null) return software("no-decoder-reading");
40751
+ const chosen = (input.reading.hwaccel ?? "").trim();
40752
+ if (chosen === "none") return software("operator-disabled");
40753
+ if (chosen !== "" && chosen !== "auto") return isHwAccelBackend(chosen) ? hardware(chosen, "operator", input) : software("unrecognised-backend");
40754
+ const probed = (input.reading.probedBestHwaccel ?? "").trim();
40755
+ if (probed === "" || probed === "none") return software("not-probed");
40756
+ return isHwAccelBackend(probed) ? hardware(probed, "probed", input) : software("unrecognised-backend");
40757
+ }
40758
+ /**
40759
+ * Did this ffmpeg exit look like a failed hardware init, rather than a
40760
+ * teardown or a fault software would hit too?
40761
+ *
40762
+ * All five conditions are necessary. Dropping the controller-stop check in
40763
+ * particular would respawn on every normal teardown, because iOS restarts a
40764
+ * session it is not enjoying and that is indistinguishable at the exit code.
40765
+ */
40766
+ function shouldRetryInSoftware(input) {
40767
+ if (!input.usedHardware) return false;
40768
+ if (input.hardwareAlreadyFailed) return false;
40769
+ if (input.stopRequestedByController) return false;
40770
+ if (input.videoPacketsForwarded > 0) return false;
40771
+ return input.runtimeMs <= HW_DECODE_FALLBACK_WINDOW_MS;
40772
+ }
40773
+ function software(reason) {
40774
+ return {
40775
+ kind: "software",
40776
+ reason,
40777
+ hwaccel: null,
40778
+ extraInputArgs: [],
40779
+ args: []
40780
+ };
40781
+ }
40782
+ function hardware(backend, source, input) {
40783
+ if (input.recentlyFailedBackend === backend) return software("hardware-attempt-failed");
40784
+ const { hwaccel, extraInputArgs } = decodePlan(backend, input);
40785
+ return {
40786
+ kind: "hardware",
40787
+ backend,
40788
+ source,
40789
+ hwaccel,
40790
+ extraInputArgs,
40791
+ args: [
40792
+ "-hwaccel",
40793
+ hwaccel,
40794
+ ...extraInputArgs
40795
+ ]
40796
+ };
40797
+ }
40798
+ /**
40799
+ * The input-side decode configuration, and nothing else.
40800
+ *
40801
+ * No `-hwaccel_output_format`: the decoded frames have to land in system
40802
+ * memory for libx264 to scale and encode them. Setting it would keep them on
40803
+ * the GPU, which only pays off with a GPU scale filter — and that is the
40804
+ * decoder addon's job, not a two-output SRTP session's.
40805
+ *
40806
+ * The two halves are returned SEPARATELY because the shared argv builder emits
40807
+ * `-hwaccel` itself (it is the only function allowed to, so the flag cannot
40808
+ * drift past `-i`) and takes everything else as the input plan's `extraArgs`.
40809
+ */
40810
+ function decodePlan(backend, input) {
40811
+ if (backend === "videotoolbox" && input.platform === "darwin") return {
40812
+ hwaccel: "auto",
40813
+ extraInputArgs: []
40814
+ };
40815
+ return {
40816
+ hwaccel: backend,
40817
+ extraInputArgs: RENDER_NODE_BACKENDS.includes(backend) ? ["-hwaccel_device", input.renderDevice ?? "/dev/dri/renderD128"] : []
40818
+ };
40819
+ }
40820
+ //#endregion
40821
+ //#region src/mappers/builders/stream-hwaccel-probe.ts
40822
+ /**
40823
+ * The real read: `decoder.getInfo`, pinned to the LOCAL node.
40824
+ *
40825
+ * Pinned explicitly rather than left to routing, because an unpinned singleton
40826
+ * cap answers from whichever node owns it and would report the WRONG host's
40827
+ * hardware.
40828
+ */
40829
+ function decoderInfoSourceFromContext(ctx) {
40830
+ return {
40831
+ localNodeId: ctx.kernel?.localNodeId,
40832
+ readInfo: (nodeId) => ctx.api.decoder.getInfo.query(void 0, nodePin(nodeId))
40833
+ };
40834
+ }
40835
+ /**
40836
+ * Read this node's decode-hwaccel state, or `null` when nothing answered.
40837
+ *
40838
+ * Never throws. `null` means "we do not know", which
40839
+ * {@link import('./stream-hwaccel.js').selectHwDecode} turns into software —
40840
+ * the safe direction, because a guess here costs the whole stream.
40841
+ */
40842
+ async function probeDecoderHwaccel(input) {
40843
+ const { source, log, memo } = input;
40844
+ const memoised = memo.read();
40845
+ if (memoised !== void 0) return memoised;
40846
+ const nodeId = source.localNodeId;
40847
+ if (nodeId === void 0 || nodeId.length === 0) {
40848
+ log.warn("export-hap: hwaccel probe skipped — no local node id, decoding in SOFTWARE");
40849
+ return null;
40850
+ }
40851
+ try {
40852
+ const info = await source.readInfo(nodeId);
40853
+ if (info === null || info === void 0) {
40854
+ log.info("export-hap: hwaccel probe returned nothing — decoding in SOFTWARE", { meta: { nodeId } });
40855
+ memo.write(null);
40856
+ return null;
40857
+ }
40858
+ const reading = {
40859
+ hwaccel: info.hwaccel ?? null,
40860
+ probedBestHwaccel: info.probedBestHwaccel ?? null
40861
+ };
40862
+ memo.write(reading);
40863
+ return reading;
40864
+ } catch (err) {
40865
+ log.warn("export-hap: hwaccel probe failed — decoding in SOFTWARE", { meta: {
40866
+ nodeId,
40867
+ error: err instanceof Error ? err.message : String(err)
40868
+ } });
40869
+ memo.write(null);
40870
+ return null;
40871
+ }
40872
+ }
40873
+ /**
40874
+ * What we ask for on the VIDEO loopback socket.
40875
+ *
40876
+ * Generous on purpose: the cost is virtual address space the kernel only
40877
+ * commits as datagrams actually queue, and the failure it prevents is a black
40878
+ * tile. Sized well above {@link KEYFRAME_BURST_FLOOR_BYTES} so a slow drain
40879
+ * (the JS forwarder is on the same event loop as everything else this addon
40880
+ * does) still has headroom.
40881
+ */
40882
+ var VIDEO_LOOPBACK_RCVBUF_BYTES = 8 * 1024 * 1024;
40883
+ /**
40884
+ * What we ask for on the AUDIO loopback socket.
40885
+ *
40886
+ * Audio never bursts — that is the control in this experiment, and it is why
40887
+ * the two legs get different numbers rather than one shared constant. If audio
40888
+ * ever starts dropping at the same buffer that carries video fine, the cause is
40889
+ * not burst size.
40890
+ */
40891
+ var AUDIO_LOOPBACK_RCVBUF_BYTES = 1024 * 1024;
40892
+ function errMsg$9(err) {
40893
+ return err instanceof Error ? err.message : String(err);
40894
+ }
40895
+ /**
40896
+ * Set `SO_RCVBUF` and READ IT BACK.
40897
+ *
40898
+ * Never throws: a platform that refuses the option must cost the buffer, never
40899
+ * the session. The read-back is the point — a request the kernel clamped and a
40900
+ * request it honoured are indistinguishable at the call site.
40901
+ */
40902
+ function applyReceiveBuffer(socket, requestedBytes) {
40903
+ let error = null;
40904
+ try {
40905
+ socket.setRecvBufferSize(requestedBytes);
40906
+ } catch (err) {
40907
+ error = errMsg$9(err);
40908
+ }
40909
+ let effectiveBytes = null;
40910
+ try {
40911
+ effectiveBytes = socket.getRecvBufferSize();
40912
+ } catch (err) {
40913
+ if (error === null) error = errMsg$9(err);
40914
+ }
40915
+ return {
40916
+ requestedBytes,
40917
+ effectiveBytes,
40918
+ clamped: effectiveBytes !== null && effectiveBytes < requestedBytes,
40919
+ sufficientForKeyframeBurst: effectiveBytes !== null && effectiveBytes >= 2097152,
40920
+ error
40921
+ };
40922
+ }
40923
+ /** Where the kernel publishes per-socket UDP counters, by address family. */
40924
+ var PROC_NET_UDP = {
40925
+ ipv4: "/proc/net/udp",
40926
+ ipv6: "/proc/net/udp6"
40927
+ };
40928
+ /**
40929
+ * The per-socket `drops` count for `port`, out of a `/proc/net/udp` table.
40930
+ *
40931
+ * Pure so the format assumption is pinned by a test rather than by a live
40932
+ * kernel. Returns `null` when the port has no row — which is NOT the same as
40933
+ * zero drops, and the two must never collapse: `0` is evidence the buffer held,
40934
+ * `null` is the absence of evidence.
40935
+ */
40936
+ function parseUdpSocketDrops(table, port) {
40937
+ const lines = table.split("\n");
40938
+ for (const line of lines) {
40939
+ const fields = line.trim().split(/\s+/);
40940
+ if (fields.length < 13) continue;
40941
+ const local = fields[1];
40942
+ if (local === void 0) continue;
40943
+ const hexPort = local.split(":")[1];
40944
+ if (hexPort === void 0) continue;
40945
+ const parsedPort = Number.parseInt(hexPort, 16);
40946
+ if (!Number.isFinite(parsedPort) || parsedPort !== port) continue;
40947
+ const drops = Number(fields[fields.length - 1]);
40948
+ return Number.isFinite(drops) ? drops : null;
40949
+ }
40950
+ return null;
40951
+ }
40952
+ /**
40953
+ * Fold a fresh drop sample into the one already held.
40954
+ *
40955
+ * A socket that has been CLOSED disappears from `/proc/net/udp`, so a resample
40956
+ * after teardown returns `null` — "I can no longer look", which must never
40957
+ * erase "I looked and it was 0". The first live session that proved the buffer
40958
+ * fix reported `videoLoopKernelDrops=null` for exactly this reason: on a
40959
+ * controller `stop` the sockets are closed synchronously while the summary is
40960
+ * emitted later from ffmpeg's `exit` handler.
40961
+ */
40962
+ function mergeDropSample(previous, sampled) {
40963
+ return sampled ?? previous;
40964
+ }
40965
+ /**
40966
+ * Read the kernel's drop counter for a bound local UDP port.
40967
+ *
40968
+ * Linux only — `null` on every other platform and on every read failure, which
40969
+ * is honest: "we could not look" and "nothing was dropped" are different
40970
+ * answers and this returns the first as `null`.
40971
+ *
40972
+ * Synchronous on purpose. It is called at the session heartbeat (5 s) and once
40973
+ * at teardown, against a memory-backed pseudo-file; making it async would mean
40974
+ * the SUMMARY line — the one line this experiment is read from — could not
40975
+ * carry a fresh count, which is the only reason it exists.
40976
+ */
40977
+ function readUdpSocketDrops(port, ipVersion) {
40978
+ try {
40979
+ return parseUdpSocketDrops(readFileSync(PROC_NET_UDP[ipVersion], "utf8"), port);
40980
+ } catch {
40981
+ return null;
40982
+ }
40983
+ }
40984
+ //#endregion
38471
40985
  //#region src/mappers/builders/stream-telemetry.ts
38472
40986
  /**
38473
40987
  * Every branch on the streaming path that discards work, and its starting
@@ -38488,6 +41002,12 @@ var ZERO_DROP_COUNTERS = {
38488
41002
  "rtcp-no-srtcp": 0,
38489
41003
  /** Outbound RTCP: building or encrypting the Sender Report failed. */
38490
41004
  "rtcp-encrypt-failed": 0,
41005
+ /** Inbound RTCP: the leg has no SRTCP context, so the controller's report is unreadable. */
41006
+ "inbound-rtcp-no-srtcp": 0,
41007
+ /** Inbound RTCP: SRTCP decryption of a controller packet failed. */
41008
+ "inbound-rtcp-decrypt-failed": 0,
41009
+ /** Inbound RTCP: the decrypted bytes did not parse as RTCP. */
41010
+ "inbound-rtcp-parse-failed": 0,
38491
41011
  /** Upstream: packet shorter than an RTP header. */
38492
41012
  "upstream-short-packet": 0,
38493
41013
  /** Upstream: no inbound SRTP context (init failed at prepareStream). */
@@ -38543,6 +41063,21 @@ function classifyInboundPacket(packet) {
38543
41063
  return "rtp";
38544
41064
  }
38545
41065
  /**
41066
+ * Loss at or above this is not a start-up transient.
41067
+ *
41068
+ * A stream whose first key frame is slow reliably produces one report in the
41069
+ * low single digits; a transmit-side defect produces tens of percent, because
41070
+ * whatever makes a packet unusable makes most packets unusable. The threshold
41071
+ * sits between those two regimes rather than at any measured boundary — it is
41072
+ * a reading aid, and the raw `worstFractionLostPct` is always in the line
41073
+ * beside it.
41074
+ */
41075
+ var TRANSMIT_SUSPECT_FRACTION_LOST_PCT = 5;
41076
+ function lossVerdict(tally) {
41077
+ if (tally.blocksParsed === 0 || tally.worstFractionLostPct === null) return "no-reports";
41078
+ return tally.worstFractionLostPct >= TRANSMIT_SUSPECT_FRACTION_LOST_PCT ? "transmit-suspect" : "decode-suspect";
41079
+ }
41080
+ /**
38546
41081
  * Build the meta for `export-hap: stream session summary` — the single line a
38547
41082
  * future session greps to answer "why did this session die".
38548
41083
  */
@@ -38563,8 +41098,17 @@ function summariseSession(snapshot) {
38563
41098
  selectedBrokerId: slot?.brokerId ?? null,
38564
41099
  advertisedFps: slot?.advertisedFps ?? null,
38565
41100
  advertisedFpsSource: slot?.advertisedFpsSource ?? null,
41101
+ deliveredFps: slot?.deliveredFps ?? null,
38566
41102
  transcode: slot?.transcode ?? null,
41103
+ fitReason: slot?.fitReason ?? null,
41104
+ slotPublishedKbps: slot?.publishedKbps ?? null,
41105
+ slotMeasuredKbps: slot?.measuredKbps ?? null,
41106
+ encodeBudgetKbps: slot?.budgetKbps ?? null,
41107
+ fitNotes: slot?.fitNotes ?? [],
38567
41108
  videoPacketsForwarded: snapshot.videoPacketsForwarded,
41109
+ msToFirstKeyframe: snapshot.firstKeyframeAtMs === null || snapshot.startedAtMs === null ? null : snapshot.firstKeyframeAtMs - snapshot.startedAtMs,
41110
+ videoKeyframes: snapshot.videoKeyframes,
41111
+ maxKeyframeGapMs: snapshot.maxKeyframeGapMs,
38568
41112
  audioPacketsForwarded: snapshot.audioPacketsForwarded,
38569
41113
  videoRtcpSrSent: snapshot.videoRtcpSrSent,
38570
41114
  audioRtcpSrSent: snapshot.audioRtcpSrSent,
@@ -38574,12 +41118,26 @@ function summariseSession(snapshot) {
38574
41118
  audioRtpReceived: snapshot.audioRtpReceived,
38575
41119
  videoGate: formatRtcpGate(snapshot.videoGate),
38576
41120
  audioGate: formatRtcpGate(snapshot.audioGate),
41121
+ videoReceiverReports: snapshot.videoReceiverReports,
41122
+ audioReceiverReports: snapshot.audioReceiverReports,
41123
+ videoLossVerdict: lossVerdict(snapshot.videoReceiverReports),
41124
+ audioLossVerdict: lossVerdict(snapshot.audioReceiverReports),
38577
41125
  mediaStarved: snapshot.videoPacketsForwarded === 0,
41126
+ videoLoopRcvbufRequestedBytes: snapshot.videoLoopback.rcvbufRequestedBytes,
41127
+ videoLoopRcvbufBytes: snapshot.videoLoopback.rcvbufEffectiveBytes,
41128
+ videoLoopRcvbufClamped: snapshot.videoLoopback.rcvbufClamped,
41129
+ videoLoopKernelDrops: snapshot.videoLoopback.kernelDrops,
41130
+ videoLoopKernelDropped: (snapshot.videoLoopback.kernelDrops ?? 0) > 0,
41131
+ audioLoopRcvbufBytes: snapshot.audioLoopback.rcvbufEffectiveBytes,
41132
+ audioLoopKernelDrops: snapshot.audioLoopback.kernelDrops,
41133
+ audioLoopKernelDropped: (snapshot.audioLoopback.kernelDrops ?? 0) > 0,
38578
41134
  drops: nonZeroDrops(snapshot.drops)
38579
41135
  };
38580
41136
  }
38581
41137
  //#endregion
38582
41138
  //#region src/mappers/builders/camera-streams.ts
41139
+ /** A decoder that is slow to describe itself costs hardware decode, not the session. */
41140
+ var HWACCEL_PROBE_BUDGET_MS = 1e3;
38583
41141
  var SRTP_KEY_LEN = 16;
38584
41142
  var SRTP_SALT_LEN = 14;
38585
41143
  /**
@@ -38593,8 +41151,17 @@ var SRTP_SALT_LEN = 14;
38593
41151
  * make this one act.
38594
41152
  */
38595
41153
  var SESSION_HEARTBEAT_MS = 5e3;
38596
- var OPUS_BITRATE_KBPS = 24;
38597
- var OPUS_CHANNELS = 1;
41154
+ /**
41155
+ * Floor between two `export-hap: controller receiver report` lines on one leg.
41156
+ *
41157
+ * The controller sends several Receiver Reports a second — 21 and 34 in two
41158
+ * ~10 s sessions on 2026-08-06 — and every one of them at `info` would drown
41159
+ * the very line it is meant to make findable. The FIRST report on each leg is
41160
+ * always logged, in full and under its own message; after that this throttles
41161
+ * to the heartbeat's cadence so a session still produces a running record of
41162
+ * what the controller thinks without becoming one.
41163
+ */
41164
+ var RECEIVER_REPORT_LOG_INTERVAL_MS = 5e3;
38598
41165
  function buildCameraStreamingDelegate(bctx, advertised) {
38599
41166
  const { ctx, numericDeviceId } = bctx;
38600
41167
  const log = ctx.logger.withTags({ deviceId: numericDeviceId });
@@ -38654,6 +41221,7 @@ function buildCameraStreamingDelegate(bctx, advertised) {
38654
41221
  const hadFfmpeg = session.ffmpeg !== null;
38655
41222
  killFfmpeg(session, ctx, numericDeviceId);
38656
41223
  stopHeartbeat(session);
41224
+ sampleLoopbackDrops(session);
38657
41225
  if (!hadFfmpeg) logSessionSummary(session, log, "accessory-dispose-no-ffmpeg");
38658
41226
  await closeIntercomTalkSession(session, bctx).catch(() => void 0);
38659
41227
  closeSocket(session.videoUdp);
@@ -38682,8 +41250,10 @@ async function prepareStream(request, sessions, bctx) {
38682
41250
  const localIp = pickLocalInterfaceIp(request.targetAddress, ipVersion);
38683
41251
  const videoUdp = await bindUdp(ipVersion, localIp);
38684
41252
  const audioUdp = await bindUdp(ipVersion, localIp);
38685
- const videoLoopUdp = await bindLoopback(ipVersion);
38686
- const audioLoopUdp = await bindLoopback(ipVersion);
41253
+ const videoLoop = await bindLoopback(ipVersion, VIDEO_LOOPBACK_RCVBUF_BYTES);
41254
+ const audioLoop = await bindLoopback(ipVersion, AUDIO_LOOPBACK_RCVBUF_BYTES);
41255
+ const videoLoopUdp = videoLoop.socket;
41256
+ const audioLoopUdp = audioLoop.socket;
38687
41257
  const localVideoPort = videoUdp.address().port;
38688
41258
  const localAudioPort = audioUdp.address().port;
38689
41259
  if (request.video.srtp_key.length !== SRTP_KEY_LEN || request.video.srtp_salt.length !== SRTP_SALT_LEN || request.audio.srtp_key.length !== SRTP_KEY_LEN || request.audio.srtp_salt.length !== SRTP_SALT_LEN) {
@@ -38702,7 +41272,7 @@ async function prepareStream(request, sessions, bctx) {
38702
41272
  },
38703
41273
  profile: import_src.ProtectionProfileAes128CmHmacSha1_80
38704
41274
  });
38705
- const makeOutSrtcp = (key, salt) => new import_src.SrtcpSession({
41275
+ const makeSrtcp = (key, salt) => new import_src.SrtcpSession({
38706
41276
  keys: {
38707
41277
  localMasterKey: key,
38708
41278
  localMasterSalt: salt,
@@ -38713,13 +41283,17 @@ async function prepareStream(request, sessions, bctx) {
38713
41283
  });
38714
41284
  let videoOutSrtp;
38715
41285
  let videoOutSrtcp;
41286
+ let videoInSrtcp;
38716
41287
  let audioOutSrtp;
38717
41288
  let audioOutSrtcp;
41289
+ let audioInSrtcp;
38718
41290
  try {
38719
41291
  videoOutSrtp = makeOutSrtp(request.video.srtp_key, request.video.srtp_salt);
38720
- videoOutSrtcp = makeOutSrtcp(request.video.srtp_key, request.video.srtp_salt);
41292
+ videoOutSrtcp = makeSrtcp(request.video.srtp_key, request.video.srtp_salt);
41293
+ videoInSrtcp = makeSrtcp(request.video.srtp_key, request.video.srtp_salt);
38721
41294
  audioOutSrtp = makeOutSrtp(request.audio.srtp_key, request.audio.srtp_salt);
38722
- audioOutSrtcp = makeOutSrtcp(request.audio.srtp_key, request.audio.srtp_salt);
41295
+ audioOutSrtcp = makeSrtcp(request.audio.srtp_key, request.audio.srtp_salt);
41296
+ audioInSrtcp = makeSrtcp(request.audio.srtp_key, request.audio.srtp_salt);
38723
41297
  } catch (err) {
38724
41298
  closeSocket(videoUdp);
38725
41299
  closeSocket(audioUdp);
@@ -38755,12 +41329,20 @@ async function prepareStream(request, sessions, bctx) {
38755
41329
  drops: emptyDropCounters(),
38756
41330
  videoPacketsForwarded: 0,
38757
41331
  audioPacketsForwarded: 0,
41332
+ videoKeyframes: 0,
41333
+ firstKeyframeAtMs: null,
41334
+ lastKeyframeAtMs: null,
41335
+ maxKeyframeGapMs: 0,
38758
41336
  videoRtcpSrSent: 0,
38759
41337
  audioRtcpSrSent: 0,
38760
41338
  videoRtcpReceived: 0,
38761
41339
  audioRtcpReceived: 0,
38762
41340
  videoRtpReceived: 0,
38763
41341
  audioRtpReceived: 0,
41342
+ videoReceiverReports: emptyReceiverReportTally(),
41343
+ audioReceiverReports: emptyReceiverReportTally(),
41344
+ videoRrLoggedAt: 0,
41345
+ audioRrLoggedAt: 0,
38764
41346
  videoGate: null,
38765
41347
  audioGate: null,
38766
41348
  heartbeat: null,
@@ -38772,6 +41354,7 @@ async function prepareStream(request, sessions, bctx) {
38772
41354
  videoLoopUdp,
38773
41355
  videoOutSrtp,
38774
41356
  videoOutSrtcp,
41357
+ videoInSrtcp,
38775
41358
  videoOutPacketCount: 0,
38776
41359
  videoOutOctetCount: 0,
38777
41360
  videoOutLastRtpTimestamp: 0,
@@ -38786,8 +41369,15 @@ async function prepareStream(request, sessions, bctx) {
38786
41369
  audioLoopUdp,
38787
41370
  audioOutSrtp,
38788
41371
  audioOutSrtcp,
41372
+ audioInSrtcp,
38789
41373
  audioSendGate: null,
38790
41374
  ipVersion,
41375
+ videoLoopRcvbuf: videoLoop.buffer,
41376
+ audioLoopRcvbuf: audioLoop.buffer,
41377
+ videoLoopPort: videoLoopUdp.address().port,
41378
+ audioLoopPort: audioLoopUdp.address().port,
41379
+ videoLoopKernelDrops: null,
41380
+ audioLoopKernelDrops: null,
38791
41381
  ffmpeg: null,
38792
41382
  lastStartParams: null,
38793
41383
  upstreamAudioSrtp,
@@ -38827,6 +41417,7 @@ async function prepareStream(request, sessions, bctx) {
38827
41417
  });
38828
41418
  videoLoopUdp.on("message", (rtpPacket) => {
38829
41419
  session.videoPacketsForwarded += 1;
41420
+ if (rtpPacketCarriesIdr(rtpPacket)) recordKeyframe(session);
38830
41421
  if (session.videoPacketsForwarded === 1) tagLog.info("export-hap: first video packet from ffmpeg", { meta: {
38831
41422
  sessionId: session.sessionId,
38832
41423
  bytes: rtpPacket.length
@@ -38842,6 +41433,8 @@ async function prepareStream(request, sessions, bctx) {
38842
41433
  bctx.ctx.logger.withTags({ deviceId: bctx.numericDeviceId }).debug("export-hap: incoming-audio handler error (dropped)", { meta: { error: errMsg$8(err) } });
38843
41434
  });
38844
41435
  });
41436
+ logLoopbackBuffer(tagLog, request.sessionID, "video", videoLoop.buffer);
41437
+ logLoopbackBuffer(tagLog, request.sessionID, "audio", audioLoop.buffer);
38845
41438
  tagLog.info("export-hap: stream prepared", { meta: {
38846
41439
  sessionId: request.sessionID,
38847
41440
  controllerAddress: request.targetAddress,
@@ -38922,12 +41515,45 @@ function sameIpv4Subnet(a, mask, b) {
38922
41515
  return true;
38923
41516
  }
38924
41517
  /**
41518
+ * Report one loopback socket's receive buffer.
41519
+ *
41520
+ * `warn` when the video leg cannot hold a 4K key-frame burst: that is the state
41521
+ * in which this addon silently drops most of a key frame and the tile stays
41522
+ * black, and it was invisible for the whole life of this code path.
41523
+ */
41524
+ function logLoopbackBuffer(log, sessionId, leg, outcome) {
41525
+ const meta = {
41526
+ sessionId,
41527
+ leg,
41528
+ requestedBytes: outcome.requestedBytes,
41529
+ effectiveBytes: outcome.effectiveBytes,
41530
+ clamped: outcome.clamped,
41531
+ sufficientForKeyframeBurst: outcome.sufficientForKeyframeBurst,
41532
+ error: outcome.error
41533
+ };
41534
+ if (leg === "video" && !outcome.sufficientForKeyframeBurst) {
41535
+ log.warn("export-hap: loopback receive buffer is TOO SMALL for a key-frame burst — raise net.core.rmem_max on the host", { meta });
41536
+ return;
41537
+ }
41538
+ log.info("export-hap: loopback receive buffer", { meta });
41539
+ }
41540
+ /**
38925
41541
  * Resolve once-per-session: the local IP we bind iOS-facing sockets to
38926
41542
  * AND its mate on `127.0.0.1` for the ffmpeg loopback path. Both go
38927
41543
  * through the bounded-wait `dgram.bind` pattern.
41544
+ *
41545
+ * `SO_RCVBUF` is set AFTER the bind and read back, never assumed. Until
41546
+ * 2026-08-07 nothing set it at all, so these sockets ran on
41547
+ * `net.core.rmem_default` (212 992 B on this hub) — about a fifth of one 4K
41548
+ * key frame, which arrives as ~750 datagrams in one burst. See
41549
+ * `stream-socket-buffer.ts` for the measurement.
38928
41550
  */
38929
- async function bindLoopback(ipVersion) {
38930
- return bindUdp(ipVersion, ipVersion === "ipv6" ? "::1" : "127.0.0.1");
41551
+ async function bindLoopback(ipVersion, requestedRcvbufBytes) {
41552
+ const socket = await bindUdp(ipVersion, ipVersion === "ipv6" ? "::1" : "127.0.0.1");
41553
+ return {
41554
+ socket,
41555
+ buffer: applyReceiveBuffer(socket, requestedRcvbufBytes)
41556
+ };
38931
41557
  }
38932
41558
  /** Book a named drop. Every silent `return` on the streaming path routes here. */
38933
41559
  function drop(session, reason) {
@@ -38962,6 +41588,135 @@ function countInbound(session, leg, packet, log) {
38962
41588
  leg,
38963
41589
  bytes: packet.length
38964
41590
  } });
41591
+ if (kind === "rtcp") readControllerRtcp(session, leg, packet, log);
41592
+ }
41593
+ /** Fold a decrypted RTCP datagram into the leg's tally. Immutable, per `ReceiverReportTally`. */
41594
+ function storeReceiverReports(session, leg, tally) {
41595
+ if (leg === "video") session.videoReceiverReports = tally;
41596
+ else session.audioReceiverReports = tally;
41597
+ }
41598
+ /**
41599
+ * Decrypt one inbound RTCP datagram and read the controller's Receiver Report.
41600
+ *
41601
+ * This is the datum the 2026-08-06 telemetry left missing. That round proved
41602
+ * iOS DOES send us RTCP on the video leg — 21 and 34 packets across two
41603
+ * sessions, the gate opening on a real controller packet at ~550 ms — which
41604
+ * killed the long-standing "iOS never probes us" belief. But counting packets
41605
+ * says only that the controller spoke; a Receiver Report says WHAT it said,
41606
+ * and that splits the remaining causes into two disjoint families that nothing
41607
+ * else in this system can tell apart. See `LossVerdict`.
41608
+ *
41609
+ * Runs on a UDP `message` handler, so nothing here may throw: a controller
41610
+ * that sends one deformed datagram must not take the session with it. Every
41611
+ * failure books a named drop and increments `unreadable`, because a report we
41612
+ * could not read and a report that never came must never look alike.
41613
+ */
41614
+ function readControllerRtcp(session, leg, packet, log) {
41615
+ const srtcp = leg === "video" ? session.videoInSrtcp : session.audioInSrtcp;
41616
+ const tally = leg === "video" ? session.videoReceiverReports : session.audioReceiverReports;
41617
+ if (!srtcp) {
41618
+ drop(session, "inbound-rtcp-no-srtcp");
41619
+ storeReceiverReports(session, leg, recordUnreadableRtcp(tally));
41620
+ logUnreadableRtcp(session, leg, "no-srtcp-context", log);
41621
+ return;
41622
+ }
41623
+ let plaintext;
41624
+ try {
41625
+ plaintext = srtcp.decrypt(packet);
41626
+ } catch (err) {
41627
+ drop(session, "inbound-rtcp-decrypt-failed");
41628
+ storeReceiverReports(session, leg, recordUnreadableRtcp(tally));
41629
+ logUnreadableRtcp(session, leg, `decrypt: ${errMsg$8(err)}`, log);
41630
+ return;
41631
+ }
41632
+ const outcome = ingestDecryptedRtcp(plaintext, tally);
41633
+ storeReceiverReports(session, leg, outcome.tally);
41634
+ if (outcome.failure !== null) {
41635
+ drop(session, "inbound-rtcp-parse-failed");
41636
+ logUnreadableRtcp(session, leg, `parse: ${outcome.failure}`, log);
41637
+ return;
41638
+ }
41639
+ const isFirst = tally.reportsParsed === 0 && outcome.tally.reportsParsed > 0;
41640
+ logReceiverReports(session, leg, outcome.reports, isFirst, log);
41641
+ }
41642
+ /** Throttled per leg — a controller sending nothing but garbage must not become the log. */
41643
+ function logUnreadableRtcp(session, leg, reason, log) {
41644
+ const tally = leg === "video" ? session.videoReceiverReports : session.audioReceiverReports;
41645
+ if (!shouldLogReceiverReport(session, leg)) return;
41646
+ log.warn("export-hap: inbound RTCP could not be read", { meta: {
41647
+ sessionId: session.sessionId,
41648
+ leg,
41649
+ reason,
41650
+ unreadable: tally.unreadable
41651
+ } });
41652
+ }
41653
+ /**
41654
+ * True at most once per `RECEIVER_REPORT_LOG_INTERVAL_MS` per leg. Stamps the
41655
+ * leg on the way out so the caller cannot forget to.
41656
+ */
41657
+ function shouldLogReceiverReport(session, leg) {
41658
+ const now = Date.now();
41659
+ if (now < (leg === "video" ? session.videoRrLoggedAt : session.audioRrLoggedAt) + RECEIVER_REPORT_LOG_INTERVAL_MS) return false;
41660
+ if (leg === "video") session.videoRrLoggedAt = now;
41661
+ else session.audioRrLoggedAt = now;
41662
+ return true;
41663
+ }
41664
+ /**
41665
+ * Emit the controller's own numbers.
41666
+ *
41667
+ * The FIRST report on a leg gets its own message: that one arriving at all is
41668
+ * the proof iOS is engaged with the stream, and it was worth a year of
41669
+ * argument. Everything after it is throttled to the heartbeat's cadence.
41670
+ */
41671
+ function logReceiverReports(session, leg, reports, isFirst, log) {
41672
+ const tally = leg === "video" ? session.videoReceiverReports : session.audioReceiverReports;
41673
+ const block = reports.flatMap((report) => report.blocks).at(-1);
41674
+ if (!block) return;
41675
+ const reporter = reports.find((report) => report.blocks.length > 0);
41676
+ const meta = {
41677
+ sessionId: session.sessionId,
41678
+ leg,
41679
+ reporterSsrc: reporter?.reporterSsrc ?? null,
41680
+ aboutSsrc: block.aboutSsrc,
41681
+ fractionLostPct: block.fractionLostPct,
41682
+ cumulativePacketsLost: block.cumulativePacketsLost,
41683
+ extendedHighestSequence: block.extendedHighestSequence,
41684
+ jitter: block.jitter,
41685
+ delaySinceLastSrMs: block.delaySinceLastSrMs,
41686
+ reportsParsed: tally.reportsParsed,
41687
+ worstFractionLostPct: tally.worstFractionLostPct
41688
+ };
41689
+ if (isFirst) {
41690
+ shouldLogReceiverReport(session, leg);
41691
+ log.info("export-hap: FIRST RTCP Receiver Report from controller — iOS is receiving and reporting", { meta });
41692
+ return;
41693
+ }
41694
+ if (!shouldLogReceiverReport(session, leg)) return;
41695
+ log.info("export-hap: controller receiver report", { meta });
41696
+ }
41697
+ /**
41698
+ * Record a forwarded key frame. Kept separate from the packet counter because
41699
+ * the interesting quantity is TIMING, not a tally: the first arrival dates the
41700
+ * moment the controller could begin decoding, and the widest gap says how long
41701
+ * a mid-GOP join can be expected to stare at a loader.
41702
+ */
41703
+ function recordKeyframe(session) {
41704
+ const now = Date.now();
41705
+ session.videoKeyframes += 1;
41706
+ if (session.firstKeyframeAtMs === null) session.firstKeyframeAtMs = now;
41707
+ else if (session.lastKeyframeAtMs !== null) session.maxKeyframeGapMs = Math.max(session.maxKeyframeGapMs, now - session.lastKeyframeAtMs);
41708
+ session.lastKeyframeAtMs = now;
41709
+ }
41710
+ /**
41711
+ * Refresh the kernel's per-socket drop counters.
41712
+ *
41713
+ * Called immediately before every line that reports them, because a stale
41714
+ * sample on the summary would answer the experiment's central question with
41715
+ * data from five seconds earlier. Cheap: `/proc/net/udp` is memory-backed.
41716
+ */
41717
+ function sampleLoopbackDrops(session) {
41718
+ session.videoLoopKernelDrops = mergeDropSample(session.videoLoopKernelDrops, readUdpSocketDrops(session.videoLoopPort, session.ipVersion));
41719
+ session.audioLoopKernelDrops = mergeDropSample(session.audioLoopKernelDrops, readUdpSocketDrops(session.audioLoopPort, session.ipVersion));
38965
41720
  }
38966
41721
  /** Snapshot every counter into the summary meta. */
38967
41722
  function sessionSummaryMeta(session) {
@@ -38973,6 +41728,9 @@ function sessionSummaryMeta(session) {
38973
41728
  selectedSlot: session.selectedSlot,
38974
41729
  videoPacketsForwarded: session.videoPacketsForwarded,
38975
41730
  audioPacketsForwarded: session.audioPacketsForwarded,
41731
+ videoKeyframes: session.videoKeyframes,
41732
+ firstKeyframeAtMs: session.firstKeyframeAtMs,
41733
+ maxKeyframeGapMs: session.maxKeyframeGapMs,
38976
41734
  videoRtcpSrSent: session.videoRtcpSrSent,
38977
41735
  audioRtcpSrSent: session.audioRtcpSrSent,
38978
41736
  videoRtcpReceived: session.videoRtcpReceived,
@@ -38981,9 +41739,23 @@ function sessionSummaryMeta(session) {
38981
41739
  audioRtpReceived: session.audioRtpReceived,
38982
41740
  videoGate: session.videoGate,
38983
41741
  audioGate: session.audioGate,
41742
+ videoReceiverReports: session.videoReceiverReports,
41743
+ audioReceiverReports: session.audioReceiverReports,
38984
41744
  drops: session.drops,
38985
41745
  ffmpegExit: session.ffmpegExit,
38986
- stopRequestedByController: session.stopRequestedByController
41746
+ stopRequestedByController: session.stopRequestedByController,
41747
+ videoLoopback: {
41748
+ rcvbufRequestedBytes: session.videoLoopRcvbuf.requestedBytes,
41749
+ rcvbufEffectiveBytes: session.videoLoopRcvbuf.effectiveBytes,
41750
+ rcvbufClamped: session.videoLoopRcvbuf.clamped,
41751
+ kernelDrops: session.videoLoopKernelDrops
41752
+ },
41753
+ audioLoopback: {
41754
+ rcvbufRequestedBytes: session.audioLoopRcvbuf.requestedBytes,
41755
+ rcvbufEffectiveBytes: session.audioLoopRcvbuf.effectiveBytes,
41756
+ rcvbufClamped: session.audioLoopRcvbuf.clamped,
41757
+ kernelDrops: session.audioLoopKernelDrops
41758
+ }
38987
41759
  });
38988
41760
  }
38989
41761
  /**
@@ -38997,6 +41769,7 @@ function armHeartbeat(session, log) {
38997
41769
  const timer = setInterval(() => {
38998
41770
  const forwarded = session.videoPacketsForwarded - lastVideo;
38999
41771
  lastVideo = session.videoPacketsForwarded;
41772
+ sampleLoopbackDrops(session);
39000
41773
  log.info("export-hap: stream heartbeat", { meta: {
39001
41774
  ...sessionSummaryMeta(session),
39002
41775
  videoPacketsSinceLastBeat: forwarded,
@@ -39016,7 +41789,15 @@ function stopHeartbeat(session) {
39016
41789
  * a HomeKit session died. It carries the negotiated parameters, the slot we
39017
41790
  * dialled and the rate we had promised for it, packet counts in both
39018
41791
  * directions on both legs, each leg's gate outcome, ffmpeg's exit, who asked
39019
- * for the teardown, and every named drop.
41792
+ * for the teardown, every named drop — and, since 2026-08-06, what the
41793
+ * controller itself reported receiving.
41794
+ *
41795
+ * Read `videoLossVerdict` first. `transmit-suspect` says the controller is not
41796
+ * getting our packets intact and nothing past the wire matters;
41797
+ * `decode-suspect` says it got them and rendered nothing anyway;
41798
+ * `no-reports` says we still cannot tell, and `videoReceiverReports.unreadable`
41799
+ * then distinguishes "the controller said nothing" from "we could not read what
41800
+ * it said".
39020
41801
  *
39021
41802
  * Emitted from the ffmpeg `exit` handler — the only place the exit code is
39022
41803
  * known — and directly from the teardown paths when there is no ffmpeg to wait
@@ -39027,6 +41808,7 @@ function stopHeartbeat(session) {
39027
41808
  */
39028
41809
  function logSessionSummary(session, log, trigger) {
39029
41810
  session.endedAtMs = Date.now();
41811
+ sampleLoopbackDrops(session);
39030
41812
  log.info("export-hap: stream session summary", { meta: {
39031
41813
  ...sessionSummaryMeta(session),
39032
41814
  trigger
@@ -39211,6 +41993,7 @@ async function handleStreamRequest(request, sessions, bctx, advertised) {
39211
41993
  const hadFfmpeg = session.ffmpeg !== null;
39212
41994
  killFfmpeg(session, bctx.ctx, bctx.numericDeviceId);
39213
41995
  stopHeartbeat(session);
41996
+ sampleLoopbackDrops(session);
39214
41997
  if (!hadFfmpeg) logSessionSummary(session, log, "controller-stop-no-ffmpeg");
39215
41998
  await closeIntercomTalkSession(session, bctx).catch(() => void 0);
39216
41999
  closeSocket(session.videoUdp);
@@ -39320,7 +42103,7 @@ async function handleStreamRequest(request, sessions, bctx, advertised) {
39320
42103
  async function startFfmpegForSession(bctx, session, sessionId, video, advertised) {
39321
42104
  const { ctx, proxy, numericDeviceId, options } = bctx;
39322
42105
  const startLog = ctx.logger.withTags({ deviceId: numericDeviceId });
39323
- const entries = await proxy.cameraStreams?.getProfileRtspEntries({}) ?? [];
42106
+ const [entries, brokerStreams] = await Promise.all([(async () => await proxy.cameraStreams?.getProfileRtspEntries({}) ?? [])(), (async () => await proxy.cameraStreams?.getBrokerStreams({}) ?? [])()]);
39324
42107
  if (entries.length === 0) {
39325
42108
  startLog.warn("export-hap: stream start DROPPED — device publishes no profile RTSP entries", { meta: {
39326
42109
  sessionId,
@@ -39329,11 +42112,29 @@ async function startFfmpegForSession(bctx, session, sessionId, video, advertised
39329
42112
  throw new Error(`export-hap: no profile RTSP entries for device ${numericDeviceId}`);
39330
42113
  }
39331
42114
  const pref = options.hapDeviceSettings.streamPreference;
39332
- const picked = pickPreferredRtspEntry(entries, pref, numericDeviceId, { targetResolution: {
39333
- width: video.width,
39334
- height: video.height
39335
- } });
39336
- if (!picked) {
42115
+ const bitrates = await probeProfileBitrates({
42116
+ bctx,
42117
+ slots: brokerStreams,
42118
+ log: startLog
42119
+ });
42120
+ const connection = classifyConnection({
42121
+ negotiatedWidth: video.width,
42122
+ audioPacketTimeMs: session.negotiated?.audioPacketTimeMs ?? 20,
42123
+ viaHomeHub: false
42124
+ });
42125
+ const fit = selectStreamForBudget({
42126
+ entries: withSlotCodecs(entries, brokerStreams),
42127
+ deviceId: numericDeviceId,
42128
+ pref,
42129
+ connection,
42130
+ targetResolution: {
42131
+ width: video.width,
42132
+ height: video.height
42133
+ },
42134
+ negotiatedMaxBitrateKbps: video.max_bit_rate,
42135
+ bitrates
42136
+ });
42137
+ if (fit === null) {
39337
42138
  startLog.warn("export-hap: stream start DROPPED — no ENABLED profile RTSP entry", { meta: {
39338
42139
  sessionId,
39339
42140
  streamPreference: pref,
@@ -39342,14 +42143,18 @@ async function startFfmpegForSession(bctx, session, sessionId, video, advertised
39342
42143
  } });
39343
42144
  throw new Error(`export-hap: no enabled RTSP entries for device ${numericDeviceId}`);
39344
42145
  }
42146
+ const picked = fit.picked;
39345
42147
  const rtspUrl = picked.url;
39346
- const slot = (await proxy.cameraStreams?.getBrokerStreams({}) ?? []).find((s) => s.brokerId === picked.brokerId);
42148
+ const slot = brokerStreams.find((s) => s.profile === fit.profile);
39347
42149
  const codec = (picked.codec ?? slot?.codec ?? "").toLowerCase();
39348
- const needsTranscode = codec.includes("h265") || codec.includes("hevc");
42150
+ const needsTranscode = fit.kind === "transcode";
39349
42151
  const pickedProfile = toKnownProfile(picked.profileId);
39350
42152
  const resolvedFps = pickedProfile === null ? void 0 : advertised.fpsByProfile.get(pickedProfile);
39351
42153
  const advertisedFps = resolvedFps?.fps ?? video.fps;
39352
42154
  const advertisedFpsSource = resolvedFps?.source ?? "assumed";
42155
+ const deliveredFps = needsTranscode ? video.fps : advertisedFps;
42156
+ const slotEvidence = pickedProfile === null ? void 0 : bitrates.get(pickedProfile);
42157
+ const fitNotes = formatFitNotes(fit.notes);
39353
42158
  session.selectedSlot = {
39354
42159
  profile: pickedProfile,
39355
42160
  brokerId: picked.brokerId,
@@ -39357,14 +42162,33 @@ async function startFfmpegForSession(bctx, session, sessionId, video, advertised
39357
42162
  height: picked.resolution?.height ?? null,
39358
42163
  advertisedFps,
39359
42164
  advertisedFpsSource,
42165
+ deliveredFps,
39360
42166
  codec: codec.length > 0 ? codec : "unknown",
39361
- transcode: needsTranscode
42167
+ transcode: needsTranscode,
42168
+ fitReason: fit.reason,
42169
+ publishedKbps: slotEvidence?.publishedKbps ?? null,
42170
+ measuredKbps: slotEvidence?.measuredKbps ?? null,
42171
+ budgetKbps: fit.budgetKbps,
42172
+ fitNotes
39362
42173
  };
39363
- if (advertisedFps !== video.fps) startLog.warn("export-hap: negotiated fps does NOT match the slot we are about to dial", { meta: {
42174
+ startLog.info("export-hap: stream bitrate fit resolved", { meta: {
42175
+ sessionId,
42176
+ decision: fit.kind,
42177
+ reason: fit.reason,
42178
+ negotiatedMaxBitrateKbps: video.max_bit_rate,
42179
+ encodeBudgetKbps: fit.budgetKbps,
42180
+ profile: pickedProfile,
42181
+ brokerId: picked.brokerId,
42182
+ slotPublishedKbps: slotEvidence?.publishedKbps ?? null,
42183
+ slotMeasuredKbps: slotEvidence?.measuredKbps ?? null,
42184
+ candidates: fitNotes
42185
+ } });
42186
+ if (deliveredFps !== video.fps) startLog.warn("export-hap: negotiated fps does NOT match the rate we are about to deliver", { meta: {
39364
42187
  sessionId,
39365
42188
  negotiatedFps: video.fps,
39366
42189
  slotFps: advertisedFps,
39367
42190
  slotFpsSource: advertisedFpsSource,
42191
+ deliveredFps,
39368
42192
  profile: pickedProfile,
39369
42193
  brokerId: picked.brokerId,
39370
42194
  transcode: needsTranscode
@@ -39373,101 +42197,90 @@ async function startFfmpegForSession(bctx, session, sessionId, video, advertised
39373
42197
  const audioLoopPort = session.audioLoopUdp.address().port;
39374
42198
  const videoTarget = `rtp://127.0.0.1:${videoLoopPort}?pkt_size=${video.mtu}`;
39375
42199
  const audioTarget = `rtp://127.0.0.1:${audioLoopPort}?pkt_size=${video.mtu}`;
39376
- const videoArgs = needsTranscode ? [
39377
- "-c:v",
39378
- "libx264",
39379
- "-preset",
39380
- "ultrafast",
39381
- "-tune",
39382
- "zerolatency",
39383
- "-pix_fmt",
39384
- "yuv420p",
39385
- "-r",
39386
- String(video.fps),
39387
- "-s",
39388
- `${video.width}x${video.height}`,
39389
- "-b:v",
39390
- `${video.max_bit_rate}k`,
39391
- "-bufsize",
39392
- `${video.max_bit_rate * 2}k`,
39393
- "-maxrate",
39394
- `${video.max_bit_rate}k`,
39395
- "-profile:v",
39396
- "baseline",
39397
- "-level",
39398
- "3.1"
39399
- ] : [
39400
- "-c:v",
39401
- "copy",
39402
- "-bsf:v",
39403
- "dump_extra"
39404
- ];
42200
+ const videoPlan = buildVideoPlan({
42201
+ transcode: needsTranscode,
42202
+ width: video.width,
42203
+ height: video.height,
42204
+ fps: deliveredFps,
42205
+ budgetKbps: fit.budgetKbps
42206
+ });
42207
+ const hwDecode = selectHwDecode({
42208
+ transcode: needsTranscode,
42209
+ reading: needsTranscode ? await withDeadline(probeDecoderHwaccel({
42210
+ source: decoderInfoSourceFromContext(ctx),
42211
+ log: startLog,
42212
+ memo: options.decodeMemos.reading
42213
+ }), HWACCEL_PROBE_BUDGET_MS, null, () => startLog.warn("export-hap: hwaccel probe ABANDONED on its budget — decoding in SOFTWARE", { meta: { budgetMs: HWACCEL_PROBE_BUDGET_MS } })) : null,
42214
+ platform: process.platform,
42215
+ recentlyFailedBackend: options.decodeMemos.failedBackend.read() ?? null
42216
+ });
42217
+ logDecodePath(startLog, sessionId, hwDecode, needsTranscode);
39405
42218
  const videoSsrcSigned = session.videoSsrc | 0;
39406
42219
  const audioSsrcSigned = video.audio_ssrc | 0;
39407
- const args = [
39408
- "-hide_banner",
39409
- "-loglevel",
39410
- "warning",
39411
- "-rtsp_transport",
39412
- "tcp",
39413
- "-i",
42220
+ const buildArgs = (decode) => buildFfmpegArgs(buildSessionInvocation({
42221
+ decode,
39414
42222
  rtspUrl,
39415
- "-an",
39416
- "-map",
39417
- "0:v:0",
39418
- ...videoArgs,
39419
- "-payload_type",
39420
- String(video.pt),
39421
- "-ssrc",
39422
- String(videoSsrcSigned),
39423
- "-f",
39424
- "rtp",
42223
+ video: videoPlan,
39425
42224
  videoTarget,
39426
- "-vn",
39427
- "-map",
39428
- "0:a:0?",
39429
- "-af",
39430
- "aresample=async=1000:first_pts=0",
39431
- "-c:a",
39432
- "libopus",
39433
- "-application",
39434
- "lowdelay",
39435
- "-frame_duration",
39436
- String(video.packet_time ?? 20),
39437
- "-flags",
39438
- "+global_header",
39439
- "-ar",
39440
- String((video.sample_rate ?? 16) * 1e3),
39441
- "-b:a",
39442
- `${OPUS_BITRATE_KBPS}k`,
39443
- "-bufsize",
39444
- `${OPUS_BITRATE_KBPS * 4}k`,
39445
- "-ac",
39446
- String(OPUS_CHANNELS),
39447
- "-payload_type",
39448
- String(video.audio_pt),
39449
- "-ssrc",
39450
- String(audioSsrcSigned),
39451
- "-f",
39452
- "rtp",
39453
- audioTarget
39454
- ];
42225
+ audioTarget,
42226
+ videoPayloadType: video.pt,
42227
+ videoSsrcSigned,
42228
+ audioPayloadType: video.audio_pt,
42229
+ audioSsrcSigned,
42230
+ audioPacketTimeMs: video.packet_time ?? 20,
42231
+ audioSampleRateKhz: video.sample_rate ?? 16
42232
+ }));
39455
42233
  const log = ctx.logger.withTags({ deviceId: numericDeviceId });
39456
- const proc = spawn("ffmpeg", args, { stdio: [
39457
- "ignore",
39458
- "ignore",
39459
- "pipe"
39460
- ] });
39461
- session.ffmpeg = proc;
39462
- proc.stderr?.on("data", (chunk) => {
39463
- const line = chunk.toString("utf8").trim();
39464
- if (!line) return;
39465
- log.info("export-hap: ffmpeg", { meta: {
39466
- sessionId,
39467
- line
39468
- } });
39469
- });
39470
- proc.once("exit", (code, signal) => {
42234
+ let hardwareAlreadyFailed = false;
42235
+ const spawnFfmpeg = (decision) => {
42236
+ const spawnedAtMs = Date.now();
42237
+ const usedHardware = decision.kind === "hardware";
42238
+ const proc = spawn("ffmpeg", buildArgs(decision), { stdio: [
42239
+ "ignore",
42240
+ "ignore",
42241
+ "pipe"
42242
+ ] });
42243
+ session.ffmpeg = proc;
42244
+ proc.stderr?.on("data", (chunk) => {
42245
+ const line = chunk.toString("utf8").trim();
42246
+ if (!line) return;
42247
+ log.info("export-hap: ffmpeg", { meta: {
42248
+ sessionId,
42249
+ line
42250
+ } });
42251
+ });
42252
+ proc.once("exit", (code, signal) => {
42253
+ if (shouldRetryInSoftware({
42254
+ usedHardware,
42255
+ hardwareAlreadyFailed,
42256
+ stopRequestedByController: session.stopRequestedByController,
42257
+ videoPacketsForwarded: session.videoPacketsForwarded,
42258
+ runtimeMs: Date.now() - spawnedAtMs
42259
+ })) {
42260
+ hardwareAlreadyFailed = true;
42261
+ if (decision.kind === "hardware") options.decodeMemos.failedBackend.write(decision.backend);
42262
+ log.warn("export-hap: hardware decode FAILED at init — respawning ffmpeg in software", { meta: {
42263
+ sessionId,
42264
+ backend: decision.kind === "hardware" ? decision.backend : null,
42265
+ backendSource: decision.kind === "hardware" ? decision.source : null,
42266
+ code,
42267
+ signal,
42268
+ runtimeMs: Date.now() - spawnedAtMs
42269
+ } });
42270
+ if (session.ffmpeg === proc) session.ffmpeg = null;
42271
+ spawnFfmpeg(software("hardware-attempt-failed"));
42272
+ return;
42273
+ }
42274
+ onFfmpegExit(proc, code, signal);
42275
+ });
42276
+ proc.once("error", (err) => {
42277
+ log.warn("export-hap: ffmpeg spawn failed", { meta: {
42278
+ sessionId,
42279
+ error: err.message
42280
+ } });
42281
+ });
42282
+ };
42283
+ const onFfmpegExit = (proc, code, signal) => {
39471
42284
  session.ffmpegExit = {
39472
42285
  code,
39473
42286
  signal
@@ -39484,13 +42297,8 @@ async function startFfmpegForSession(bctx, session, sessionId, video, advertised
39484
42297
  else log.warn("export-hap: ffmpeg exited without a controller stop", { meta });
39485
42298
  if (session.ffmpeg === proc) session.ffmpeg = null;
39486
42299
  logSessionSummary(session, log, session.teardownTrigger ?? "ffmpeg-exit");
39487
- });
39488
- proc.once("error", (err) => {
39489
- log.warn("export-hap: ffmpeg spawn failed", { meta: {
39490
- sessionId,
39491
- error: err.message
39492
- } });
39493
- });
42300
+ };
42301
+ spawnFfmpeg(hwDecode);
39494
42302
  log.info("export-hap: stream started", { meta: {
39495
42303
  sessionId,
39496
42304
  transcode: needsTranscode,
@@ -39501,8 +42309,38 @@ async function startFfmpegForSession(bctx, session, sessionId, video, advertised
39501
42309
  negotiated: `${video.width}x${video.height}@${video.fps} ${video.max_bit_rate}kbps`,
39502
42310
  slotFps: advertisedFps,
39503
42311
  slotFpsSource: advertisedFpsSource,
42312
+ deliveredFps,
42313
+ fitReason: fit.reason,
42314
+ encodeBudgetKbps: fit.budgetKbps,
39504
42315
  audioCodec: "opus",
39505
- audioBitrateKbps: OPUS_BITRATE_KBPS
42316
+ audioBitrateKbps: OPUS_BITRATE_KBPS,
42317
+ videoDecode: hwDecode.kind === "hardware" ? hwDecode.backend : "software"
42318
+ } });
42319
+ }
42320
+ /**
42321
+ * Say which decode path was resolved, and WHY, once per session.
42322
+ *
42323
+ * At `info` on purpose — Loki carries `info+`, and "did this session use the
42324
+ * GPU" is the first question anyone asks of a transcoding hub. The software
42325
+ * branch is the one that must never be silent: it is a real cost being paid,
42326
+ * and every reason it can be reached is a different fix.
42327
+ */
42328
+ function logDecodePath(log, sessionId, decision, transcode) {
42329
+ if (decision.kind === "hardware") {
42330
+ log.info("export-hap: video decode path resolved — HARDWARE", { meta: {
42331
+ sessionId,
42332
+ backend: decision.backend,
42333
+ backendSource: decision.source,
42334
+ decodeArgs: decision.args
42335
+ } });
42336
+ return;
42337
+ }
42338
+ const level = transcode ? "warn" : "info";
42339
+ const message = level === "warn" ? "export-hap: video decode path resolved — SOFTWARE, this transcode costs a core" : "export-hap: video decode path resolved — none needed";
42340
+ log[level](message, { meta: {
42341
+ sessionId,
42342
+ reason: decision.reason,
42343
+ transcode
39506
42344
  } });
39507
42345
  }
39508
42346
  /**
@@ -39680,16 +42518,87 @@ function errMsg$8(err) {
39680
42518
  return err instanceof Error ? err.message : String(err);
39681
42519
  }
39682
42520
  //#endregion
42521
+ //#region src/mappers/builders/doorbell-delivery.ts
42522
+ function isRecord(value) {
42523
+ return typeof value === "object" && value !== null;
42524
+ }
42525
+ function numberOrNull(value) {
42526
+ return typeof value === "number" ? value : null;
42527
+ }
42528
+ function isConnectionLike(value) {
42529
+ return isRecord(value) && typeof value["hasEventNotifications"] === "function";
42530
+ }
42531
+ function isIterable(value) {
42532
+ return isRecord(value) && typeof value[Symbol.iterator] === "function";
42533
+ }
42534
+ /** `accessory._server.httpServer.connections`, or null at any missing hop. */
42535
+ function readConnections(accessory) {
42536
+ if (!isRecord(accessory)) return null;
42537
+ const server = accessory["_server"];
42538
+ if (!isRecord(server)) return null;
42539
+ const httpServer = server["httpServer"];
42540
+ if (!isRecord(httpServer)) return null;
42541
+ const connections = httpServer["connections"];
42542
+ return isIterable(connections) ? connections : null;
42543
+ }
42544
+ /**
42545
+ * Probe how far a ring on `characteristic` of `accessory` can travel RIGHT NOW.
42546
+ * Pure with respect to HAP state — it only reads. Never throws.
42547
+ */
42548
+ function describeDoorbellDelivery(accessory, characteristic) {
42549
+ const aid = isRecord(accessory) ? numberOrNull(accessory["aid"]) : null;
42550
+ const iid = isRecord(characteristic) ? numberOrNull(characteristic["iid"]) : null;
42551
+ const serverPublished = isRecord(accessory) && isRecord(accessory["_server"]);
42552
+ const connections = readConnections(accessory);
42553
+ if (connections === null) return {
42554
+ aid,
42555
+ iid,
42556
+ serverPublished,
42557
+ connectionCount: 0,
42558
+ subscriberCount: 0
42559
+ };
42560
+ let connectionCount = 0;
42561
+ let subscriberCount = 0;
42562
+ for (const connection of connections) {
42563
+ connectionCount += 1;
42564
+ if (aid === null || iid === null) continue;
42565
+ if (isConnectionLike(connection) && connection.hasEventNotifications(aid, iid)) subscriberCount += 1;
42566
+ }
42567
+ return {
42568
+ aid,
42569
+ iid,
42570
+ serverPublished,
42571
+ connectionCount,
42572
+ subscriberCount
42573
+ };
42574
+ }
42575
+ /**
42576
+ * True when the ring provably reached nobody: no connection is subscribed to
42577
+ * the characteristic, so hap-nodejs dropped every event frame silently. The
42578
+ * caller must say so out loud — this is a branch that discards work.
42579
+ */
42580
+ function ringReachedNobody(report) {
42581
+ return report.subscriberCount === 0;
42582
+ }
42583
+ //#endregion
39683
42584
  //#region src/mappers/builders/doorbell.ts
39684
42585
  async function buildDoorbell(input) {
39685
42586
  const { bctx, controller } = input;
39686
42587
  const { ctx, numericDeviceId } = bctx;
42588
+ const log = ctx.logger.withTags({ deviceId: numericDeviceId });
42589
+ log.info("export-hap: doorbell forward armed — HomeKit will ring on doorbell.onPressed");
39687
42590
  const unsubscribe = ctx.eventBus.subscribe({ category: EventCategory.DoorbellOnPressed }, (event) => {
39688
- if (event.data.deviceId !== numericDeviceId) return;
42591
+ if (event.data?.deviceId !== numericDeviceId) return;
39689
42592
  try {
42593
+ const delivery = describeDoorbellDelivery(bctx.accessory, bctx.accessory.getService(Service.Doorbell)?.getCharacteristic(Characteristic.ProgrammableSwitchEvent) ?? null);
39690
42594
  controller.ringDoorbell();
42595
+ if (ringReachedNobody(delivery)) {
42596
+ log.warn("export-hap: doorbell rang but NO HomeKit controller is subscribed — the press was dropped before it left the hub (no home hub connected, or the accessory was republished and iOS has not re-subscribed yet)", { meta: { ...delivery } });
42597
+ return;
42598
+ }
42599
+ log.info("export-hap: doorbell SINGLE_PRESS pushed to HomeKit", { meta: { ...delivery } });
39691
42600
  } catch (err) {
39692
- ctx.logger.withTags({ deviceId: numericDeviceId }).warn("export-hap: ringDoorbell() failed", { meta: { error: errMsg$7(err) } });
42601
+ log.warn("export-hap: ringDoorbell() failed", { meta: { error: errMsg$7(err) } });
39693
42602
  }
39694
42603
  });
39695
42604
  return { async dispose() {
@@ -39727,7 +42636,7 @@ async function buildIntercom(input) {
39727
42636
  var RESET_DEBOUNCE_MS = 5e3;
39728
42637
  async function buildMotionSensor(bctx) {
39729
42638
  const { ctx, accessory, proxy, numericDeviceId, displayName } = bctx;
39730
- const motionService = accessory.addService(Service.MotionSensor, displayName);
42639
+ const motionService = accessory.addService(Service.MotionSensor, hapServiceName([displayName], `Camera ${numericDeviceId}`));
39731
42640
  motionService.setCharacteristic(Characteristic.MotionDetected, false);
39732
42641
  try {
39733
42642
  const detected = await proxy.motion?.isDetected({});
@@ -39765,6 +42674,78 @@ function errMsg$6(err) {
39765
42674
  return err instanceof Error ? err.message : String(err);
39766
42675
  }
39767
42676
  //#endregion
42677
+ //#region src/mappers/builders/service-label.ts
42678
+ /**
42679
+ * The ONE place a secondary service on the camera accessory gets its label.
42680
+ *
42681
+ * A "secondary service" here is a Switch or Lightbulb published alongside the
42682
+ * camera on the same accessory — the privacy switch, each accessory child
42683
+ * (siren, floodlight), each PTZ action. iOS Home renders these as their own
42684
+ * controls, and the operator has seen them as "Interruttore 1", "Interruttore
42685
+ * 2" through three separate rounds of fixes.
42686
+ *
42687
+ * ## Why `Name` alone cannot rename anything
42688
+ *
42689
+ * Two facts about hap-nodejs 2.1.7, both measured against the installed copy
42690
+ * rather than reasoned about:
42691
+ *
42692
+ * 1. `accessory.addService(Type, displayName, subtype)` ALREADY writes
42693
+ * `displayName` to `Characteristic.Name` (`Service` constructor). So every
42694
+ * round of this bug — including the one that moved the label onto
42695
+ * `ConfiguredName` — shipped with `Name` correctly set. "iOS had no name
42696
+ * to render" was never true.
42697
+ * 2. The mDNS configuration number (`c#`) is a sha1 over
42698
+ * `internalHAPRepresentation(false)`, which OMITS characteristic VALUES.
42699
+ * Changing the string in `Name` therefore does not bump `c#`, a paired
42700
+ * controller gets no signal to re-read `/accessories`, and the name it
42701
+ * cached at first enumeration stands forever.
42702
+ *
42703
+ * `Name` is also declared `pr` only — paired read, no write, no notify. It is
42704
+ * the seed a controller seeds its database from once; it is not a channel.
42705
+ *
42706
+ * ## Why `ConfiguredName`
42707
+ *
42708
+ * `ConfiguredName` (`000000E3`) is declared `pr | pw | ev` — the only name
42709
+ * characteristic a controller may write and may subscribe to. It is what iOS
42710
+ * 16+ reads for a service the user can rename, and adding it CHANGES the
42711
+ * accessory structure, so `c#` does bump and the controller re-reads.
42712
+ *
42713
+ * It was removed once because hap-nodejs logged
42714
+ *
42715
+ * ```
42716
+ * Characteristic not in required or optional characteristic section for
42717
+ * service Switch. Adding anyway.
42718
+ * ```
42719
+ *
42720
+ * That line is a WARNING, not a rejection: `Service.getCharacteristic` calls
42721
+ * `addCharacteristic` unconditionally and only then emits the warning. The
42722
+ * characteristic was always present and always published. hap-nodejs'
42723
+ * per-service optional lists simply predate `ConfiguredName` being valid on
42724
+ * any service.
42725
+ *
42726
+ * Registering it with {@link Service.addOptionalCharacteristic} first takes
42727
+ * the branch above the warning, so the accessory still builds with ZERO
42728
+ * characteristic warnings — which is what `service-naming.spec.ts` asserts.
42729
+ *
42730
+ * ## Scope
42731
+ *
42732
+ * Switch- and Lightbulb-shaped services only. `Service.MotionSensor` on a
42733
+ * camera accessory is not a separately named tile in iOS Home, so giving it a
42734
+ * writable name would be a guess, and this module does not guess.
42735
+ */
42736
+ /**
42737
+ * Publish `name` as both the immutable `Name` and the controller-visible
42738
+ * `ConfiguredName` of `service`.
42739
+ *
42740
+ * `name` must already be HAP-valid — build it with `service-names.ts`, which
42741
+ * cannot return a string hap-nodejs' `checkName` would warn about.
42742
+ */
42743
+ function applyServiceLabel(service, name) {
42744
+ service.setCharacteristic(Characteristic.Name, name);
42745
+ if (!service.optionalCharacteristics.some((characteristic) => characteristic.UUID === Characteristic.ConfiguredName.UUID)) service.addOptionalCharacteristic(Characteristic.ConfiguredName);
42746
+ service.setCharacteristic(Characteristic.ConfiguredName, name);
42747
+ }
42748
+ //#endregion
39768
42749
  //#region src/mappers/builders/privacy-switch.ts
39769
42750
  /**
39770
42751
  * Privacy-mask switch builder — turns the camstack `privacy-mask` cap's
@@ -39785,9 +42766,9 @@ async function buildPrivacySwitch(bctx) {
39785
42766
  const { ctx, accessory, proxy, numericDeviceId } = bctx;
39786
42767
  const log = ctx.logger.withTags({ deviceId: numericDeviceId });
39787
42768
  const subtype = "privacy-mask";
39788
- const configuredName = "Privacy";
39789
- const service = accessory.addService(Service.Switch, configuredName, subtype);
39790
- service.setCharacteristic(Characteristic.ConfiguredName, configuredName);
42769
+ const serviceName = privacyServiceName();
42770
+ const service = accessory.addService(Service.Switch, serviceName, subtype);
42771
+ applyServiceLabel(service, serviceName);
39791
42772
  try {
39792
42773
  const status = await proxy.privacyMask?.getStatus({});
39793
42774
  if (status && typeof status.enabled === "boolean") service.updateCharacteristic(Characteristic.On, status.enabled);
@@ -39877,14 +42858,13 @@ function ptzPresetLabel(presetName) {
39877
42858
  * `proxy.ptzAutotrack.setEnabled({enabled})`. Initial value is
39878
42859
  * hydrated from `getStatus({})`.
39879
42860
  *
39880
- * Naming: each switch uses a BARE per-action label ("Preset stanza",
39881
- * "Pan Left", "Autotrack") set as BOTH the service name AND its
39882
- * `ConfiguredName`, mirroring `child-switch.ts` / `privacy-switch.ts`.
39883
- * iOS Home renders sibling services on an accessory by their
39884
- * `ConfiguredName`. The old `${displayName} <action>` form (em-dash
39885
- * U+2014 + redundant camera prefix) was rejected by HAP-NodeJS as an
39886
- * invalid `Name` characteristic, so iOS discarded it and showed generic
39887
- * "Interruttore N".
42861
+ * Naming: the bare action "Preset stanza", "Pan Left", "Autotrack" — built
42862
+ * by `ptzServiceName` and published through `applyServiceLabel`, which writes
42863
+ * it to BOTH `Name` and `ConfiguredName`. The camera name is deliberately not
42864
+ * prefixed these eight services live on that camera's accessory and iOS
42865
+ * shows them there. THREE rounds of this bug have been through this file;
42866
+ * `service-label.ts` records what each got wrong, and why only the writable
42867
+ * characteristic can rename a service after pairing.
39888
42868
  */
39889
42869
  var MOMENTARY_RESET_MS = 1e3;
39890
42870
  async function buildPtz(bctx) {
@@ -39900,10 +42880,10 @@ async function buildPtz(bctx) {
39900
42880
  };
39901
42881
  const presets = await readPresets(bctx);
39902
42882
  for (const preset of presets) {
39903
- const label = ptzPresetLabel(preset.name);
42883
+ const label = ptzServiceName(ptzPresetLabel(preset.name));
39904
42884
  const subtype = `ptz-preset-${preset.id}`;
39905
42885
  const service = accessory.addService(Service.Switch, label, subtype);
39906
- service.setCharacteristic(Characteristic.ConfiguredName, label);
42886
+ applyServiceLabel(service, label);
39907
42887
  service.getCharacteristic(Characteristic.On).onSet(async (value) => {
39908
42888
  if (value !== true) return;
39909
42889
  try {
@@ -39918,9 +42898,9 @@ async function buildPtz(bctx) {
39918
42898
  });
39919
42899
  }
39920
42900
  if (proxy.ptz) for (const dir of PTZ_DIRECTIONS) {
39921
- const label = dir.label;
42901
+ const label = ptzServiceName(dir.label);
39922
42902
  const service = accessory.addService(Service.Switch, label, dir.subtype);
39923
- service.setCharacteristic(Characteristic.ConfiguredName, label);
42903
+ applyServiceLabel(service, label);
39924
42904
  service.getCharacteristic(Characteristic.On).onSet(async (value) => {
39925
42905
  if (value !== true) return;
39926
42906
  try {
@@ -39967,9 +42947,9 @@ async function tryBuildAutotrack(bctx) {
39967
42947
  const { ctx, accessory, proxy, numericDeviceId } = bctx;
39968
42948
  if (!proxy.ptzAutotrack) return { async dispose() {} };
39969
42949
  const log = ctx.logger.withTags({ deviceId: numericDeviceId });
39970
- const label = PTZ_AUTOTRACK_LABEL;
42950
+ const label = ptzServiceName(PTZ_AUTOTRACK_LABEL);
39971
42951
  const service = accessory.addService(Service.Switch, label, "ptz-autotrack");
39972
- service.setCharacteristic(Characteristic.ConfiguredName, label);
42952
+ applyServiceLabel(service, label);
39973
42953
  try {
39974
42954
  const status = await proxy.ptzAutotrack.getStatus({});
39975
42955
  if (status && typeof status.enabled === "boolean") service.updateCharacteristic(Characteristic.On, status.enabled);
@@ -40096,7 +43076,7 @@ async function buildChildSwitch(bctx, subtype, deviceType) {
40096
43076
  const isLightingDevice = deviceType === DeviceType.Light || deviceType === DeviceType.Generic;
40097
43077
  const useLightbulb = hasBrightness && isLightingDevice;
40098
43078
  const service = useLightbulb ? accessory.addService(Service.Lightbulb, displayName, subtype) : accessory.addService(Service.Switch, displayName, subtype);
40099
- service.setCharacteristic(Characteristic.ConfiguredName, displayName);
43079
+ applyServiceLabel(service, displayName);
40100
43080
  try {
40101
43081
  const switchStatus = await proxy.switch?.getStatus({});
40102
43082
  if (switchStatus && typeof switchStatus.on === "boolean") service.updateCharacteristic(Characteristic.On, switchStatus.on);
@@ -40168,7 +43148,7 @@ async function buildChildServicesFor(input) {
40168
43148
  accessory: parentCtx.accessory,
40169
43149
  proxy: childProxy,
40170
43150
  numericDeviceId: child.id,
40171
- displayName: formatChildServiceName(parentDisplayName, child),
43151
+ displayName: childServiceName(parentDisplayName, child),
40172
43152
  options
40173
43153
  };
40174
43154
  const subtype = `child-${child.id}`;
@@ -40199,21 +43179,6 @@ async function listChildren(ctx, parentNumericId) {
40199
43179
  }
40200
43180
  }
40201
43181
  /**
40202
- * Service name displayed inside the camera tile detail in iOS Home.
40203
- * Prefer the child's own role ("Siren", "Floodlight") when meaningful
40204
- * — the camera name is already implied by the surrounding Accessory.
40205
- * Falls back to the child's stored device name when role is empty.
40206
- */
40207
- function formatChildServiceName(parentName, child) {
40208
- const role = child.role && child.role.length > 0 ? toTitleCase(child.role) : null;
40209
- if (role) return role;
40210
- if (child.name.toLowerCase().includes(parentName.toLowerCase())) {
40211
- const stripped = child.name.replace(new RegExp(`\\b${escapeRegex(parentName)}\\b`, "i"), "").replace(/\s+[—-]\s+/, " ").trim();
40212
- if (stripped.length > 0) return stripped;
40213
- }
40214
- return child.name;
40215
- }
40216
- /**
40217
43182
  * Coerce the raw `child.type` string (from `deviceManager.getChildren`)
40218
43183
  * to a `DeviceType` enum value. Unknown / mis-cased values fall back to
40219
43184
  * `Generic` so an unrecognised driver behaves like the safest existing
@@ -40225,12 +43190,6 @@ function asDeviceType(raw) {
40225
43190
  for (const value of Object.values(DeviceType)) if (value === lower) return value;
40226
43191
  return DeviceType.Generic;
40227
43192
  }
40228
- function escapeRegex(s) {
40229
- return s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
40230
- }
40231
- function toTitleCase(raw) {
40232
- return raw.split("-").filter((part) => part.length > 0).map((part) => part.charAt(0).toUpperCase() + part.slice(1)).join(" ");
40233
- }
40234
43193
  function errMsg$2(err) {
40235
43194
  return err instanceof Error ? err.message : String(err);
40236
43195
  }
@@ -40357,6 +43316,118 @@ function pickMapperKind(_capabilities) {
40357
43316
  return "camera";
40358
43317
  }
40359
43318
  //#endregion
43319
+ //#region src/mappers/builders/stream-hwaccel-memo.ts
43320
+ /**
43321
+ * The two bounded memos HomeKit's decode path owns.
43322
+ *
43323
+ * ## Why they exist
43324
+ *
43325
+ * Everything D67 was actually about — one argv builder, one set of constants,
43326
+ * one hwaccel authority — HomeKit already had. What it did NOT have were the
43327
+ * two things the broker gained alongside them:
43328
+ *
43329
+ * 1. **A memo.** `probeDecoderHwaccel` issued a cross-process
43330
+ * `decoder.getInfo` per SESSION. iOS starts sessions in bursts — one on
43331
+ * record was started three times in 16 s — and every one of those paid a
43332
+ * cap call on a hub whose main thread is the scarce resource.
43333
+ * 2. **Failure feedback.** When HomeKit's hardware child died at init and
43334
+ * `shouldRetryInSoftware` saved the session, HomeKit told nobody. The next
43335
+ * session re-picked the same corpse and paid the same two-second death.
43336
+ * `EgressTranscodeManager` fixed exactly this for its own children
43337
+ * (26a522cd5) by reporting the dead backend into the broker's 60 s memo.
43338
+ *
43339
+ * ## Both ride `HwAccelCache`, deliberately
43340
+ *
43341
+ * `createHwAccelCache` from `@camstack/types` is the primitive the broker's own
43342
+ * `egressHwAccelCache` is built from, and the discipline it encodes is the
43343
+ * point: **caller-owned, never a module global** — a module global would
43344
+ * outlive an addon respawn and survive an operator changing the decoder
43345
+ * backend. Same TTL as the broker's, so "has hardware come back yet" cannot
43346
+ * answer differently depending on which consumer asked.
43347
+ *
43348
+ * ## The cross-process gap, stated honestly
43349
+ *
43350
+ * These memos are scoped to the `export-hap` PROCESS. When HomeKit's vaapi
43351
+ * child dies, the broker's next child still pays its own two-second death, and
43352
+ * vice versa — because addons may never import each other and there is no
43353
+ * capability for "this backend is dead on this node right now". Closing that
43354
+ * would need a new cap surface, which Phase 0 explicitly does not take. What is
43355
+ * closed here is HomeKit's own repetition of the cost, across cameras and
43356
+ * across sessions.
43357
+ */
43358
+ /**
43359
+ * The window both memos answer for.
43360
+ *
43361
+ * 60 s, the same as `stream-broker-manager`'s `egressHwAccelCache`. Long enough
43362
+ * that a burst of session restarts pays one read; short enough that an operator
43363
+ * who changes the decoder backend, or a host whose accelerator recovers, is
43364
+ * obeyed on the next session rather than after an addon respawn.
43365
+ */
43366
+ var HAP_DECODE_MEMO_TTL_MS = 6e4;
43367
+ /**
43368
+ * Separator inside the encoded reading. A control character, because a backend
43369
+ * name is `[a-z0-9]+` and the decoder's non-backend choices are `auto` /
43370
+ * `none` / `''`, none of which can contain one — so the split is total.
43371
+ */
43372
+ var READING_SEPARATOR = "";
43373
+ /**
43374
+ * Marks a `null` FIELD, distinct from an EMPTY one.
43375
+ *
43376
+ * `probedBestHwaccel: ''` means the decoder answered and has never probed
43377
+ * (=> `not-probed`); `null` means the field was absent altogether. Encoding
43378
+ * both as `''` would lose a distinction `selectHwDecode` acts on.
43379
+ */
43380
+ var NULL_FIELD = "\0";
43381
+ function encodeField(value) {
43382
+ return value === null ? NULL_FIELD : value;
43383
+ }
43384
+ function decodeField(value) {
43385
+ return value === NULL_FIELD ? null : value;
43386
+ }
43387
+ /**
43388
+ * A reading as ONE `string | null`, which is what {@link HwAccelCache} stores.
43389
+ *
43390
+ * The cache's three states are exactly the three a memoised reading needs:
43391
+ * `undefined` (never asked, or expired), `null` (asked, and the decoder could
43392
+ * not be reached), and a value. Encoding into the one cache rather than
43393
+ * splitting across two is what keeps those three from skewing — two caches
43394
+ * written together can still be READ across an expiry boundary.
43395
+ */
43396
+ function encodeDecoderReading(reading) {
43397
+ if (reading === null) return null;
43398
+ return `${encodeField(reading.hwaccel)}${READING_SEPARATOR}${encodeField(reading.probedBestHwaccel)}`;
43399
+ }
43400
+ function decodeDecoderReading(value) {
43401
+ if (value === null) return null;
43402
+ const [hwaccel = NULL_FIELD, probed = NULL_FIELD] = value.split(READING_SEPARATOR);
43403
+ return {
43404
+ hwaccel: decodeField(hwaccel),
43405
+ probedBestHwaccel: decodeField(probed)
43406
+ };
43407
+ }
43408
+ function createDecoderReadingMemo(options) {
43409
+ const cache = createHwAccelCache(options);
43410
+ return {
43411
+ read() {
43412
+ const cached = cache.read();
43413
+ return cached === void 0 ? void 0 : decodeDecoderReading(cached);
43414
+ },
43415
+ write(reading) {
43416
+ cache.write(encodeDecoderReading(reading));
43417
+ }
43418
+ };
43419
+ }
43420
+ function createHapDecodeMemos(now) {
43421
+ const options = {
43422
+ ttlMs: HAP_DECODE_MEMO_TTL_MS,
43423
+ ...now ? { now } : {}
43424
+ };
43425
+ return {
43426
+ reading: createDecoderReadingMemo(options),
43427
+ failedBackend: createHwAccelCache(options)
43428
+ };
43429
+ }
43430
+ //#endregion
40360
43431
  //#region src/reconcile/sync-state.ts
40361
43432
  function syncStateFromJson(json) {
40362
43433
  const map = /* @__PURE__ */ new Map();
@@ -40499,6 +43570,18 @@ var ExportHapAddon = class extends BaseAddon {
40499
43570
  pincode = "";
40500
43571
  /** Optional mDNS/bind interface (config.interfaceName), or undefined. */
40501
43572
  bind;
43573
+ /**
43574
+ * What this PROCESS remembers about decode hardware, shared by every camera
43575
+ * mapper: the decoder addon's per-node reading (60 s), and the backend that
43576
+ * last died at init (60 s).
43577
+ *
43578
+ * Owned here rather than as a module global for the reason `HwAccelCache`
43579
+ * itself records — a module global outlives an addon respawn and survives an
43580
+ * operator changing the decoder backend. Owned here rather than per mapper
43581
+ * because the whole point is that camera B does not re-pay camera A's failed
43582
+ * hardware init.
43583
+ */
43584
+ decodeMemos = createHapDecodeMemos();
40502
43585
  constructor() {
40503
43586
  super({ ...DEFAULT_CONFIG });
40504
43587
  }
@@ -40640,13 +43723,14 @@ var ExportHapAddon = class extends BaseAddon {
40640
43723
  const mapperKind = pickMapperKind(capabilities);
40641
43724
  if (!mapperKind) throw new Error(`export-hap: no mapper for capabilities ${JSON.stringify(capabilities ?? [])}`);
40642
43725
  const displayName = await this.resolveDisplayName(deviceId);
40643
- const baseEntry = {
43726
+ const previous = this.config.exposed.find((e) => e.deviceId === deviceId);
43727
+ const baseEntry = carryForward({
40644
43728
  deviceId,
40645
43729
  displayName,
40646
43730
  mapperKind,
40647
- addedAt: Date.now(),
43731
+ addedAt: previous?.addedAt ?? Date.now(),
40648
43732
  ...capabilities ? { capabilities: [...capabilities] } : {}
40649
- };
43733
+ }, previous, ["settings", "capabilities"]);
40650
43734
  const attached = await this.attachMapper(baseEntry);
40651
43735
  const finalEntry = {
40652
43736
  ...baseEntry,
@@ -40665,13 +43749,13 @@ var ExportHapAddon = class extends BaseAddon {
40665
43749
  childCount: attached.childAccessoryUuids.length
40666
43750
  } });
40667
43751
  }
40668
- async unexposeDevice(deviceId) {
43752
+ async unexposeDevice(deviceId, options = {}) {
40669
43753
  const numericId = Number.parseInt(deviceId, 10);
40670
43754
  const log = this.ctx.logger.withTags({ deviceId: numericId });
40671
43755
  await this.detachMapper(deviceId);
40672
43756
  const next = this.config.exposed.filter((e) => e.deviceId !== deviceId);
40673
43757
  if (next.length !== this.config.exposed.length) await this.updateGlobalSettings({ exposed: next });
40674
- clearPairingFiles(uuid.generate(`camstack:camera:${numericId}`), this.ctx.logger);
43758
+ if (options.clearPairing !== false) clearPairingFiles(uuid.generate(`camstack:camera:${numericId}`), this.ctx.logger);
40675
43759
  await this.forgetFingerprint(numericId);
40676
43760
  log.info("export-hap: unexposed device");
40677
43761
  }
@@ -40684,6 +43768,7 @@ var ExportHapAddon = class extends BaseAddon {
40684
43768
  displayName: entry.displayName,
40685
43769
  options: {
40686
43770
  ptzPulseMs: this.config.ptzPulseMs,
43771
+ decodeMemos: this.decodeMemos,
40687
43772
  hapDeviceSettings: { streamPreference: entrySettings.streamPreference ?? "auto" }
40688
43773
  }
40689
43774
  });
@@ -41074,9 +44159,8 @@ var ExportHapAddon = class extends BaseAddon {
41074
44159
  to: streamPreference
41075
44160
  } });
41076
44161
  try {
41077
- await this.unexposeDevice(deviceIdStr);
44162
+ await this.unexposeDevice(deviceIdStr, { clearPairing: false });
41078
44163
  await this.exposeDevice(deviceIdStr);
41079
- await this.updateEntrySettings(deviceIdStr, nextSettings);
41080
44164
  } catch (err) {
41081
44165
  log.warn("export-hap: failed to refresh accessory after streamPreference change", { meta: { error: errMsg(err) } });
41082
44166
  }