@camstack/addon-export-hap 1.2.13 → 1.2.15

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,8 +1,9 @@
1
1
  import { createRequire } from "node:module";
2
- import { createHash, randomBytes } from "node:crypto";
2
+ import { createHash, randomBytes, randomUUID } 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
- import { Accessory, AudioBitrate, AudioStreamingCodecType, AudioStreamingSamplerate, CameraController, Categories, Characteristic, DoorbellController, H264Level, H264Profile, HAPStorage, SRTPCryptoSuites, Service, uuid } from "@homebridge/hap-nodejs";
6
+ import { Accessory, AudioBitrate, AudioRecordingCodecType, AudioRecordingSamplerate, AudioStreamingCodecType, AudioStreamingSamplerate, CameraController, Categories, Characteristic, DoorbellController, H264Level, H264Profile, HAPStorage, HDSProtocolError, HDSProtocolSpecificErrorReason, MediaContainerType, SRTPCryptoSuites, Service, VideoCodecType, uuid } from "@homebridge/hap-nodejs";
6
7
  import * as fs from "node:fs/promises";
7
8
  import { createSocket } from "node:dgram";
8
9
  import { networkInterfaces } from "node:os";
@@ -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";
@@ -6472,7 +6510,20 @@ var BrokerStatsSchema = object({
6472
6510
  sampleRate: number(),
6473
6511
  channels: number(),
6474
6512
  supported: boolean()
6475
- }).nullable().optional()
6513
+ }).nullable().optional(),
6514
+ /**
6515
+ * BROKER-SIDE AUDIO MUTE (D83). `true` = this broker is deliberately
6516
+ * distributing none of the device's audio, on live or recording.
6517
+ *
6518
+ * Present so a silent camera can be told apart from a broken one on the
6519
+ * stream panel itself, without cross-referencing the switch group: a
6520
+ * broker holding an `audio` track descriptor while `audioMuted` is true is
6521
+ * working exactly as asked. `audioMutedDropped` counts the audio units
6522
+ * thrown away since the current dial — it is how you confirm from stats
6523
+ * alone that the mute is on the packet path and not merely persisted.
6524
+ */
6525
+ audioMuted: boolean().optional(),
6526
+ audioMutedDropped: number().optional()
6476
6527
  });
6477
6528
  /**
6478
6529
  * Exporter-facing "profile restream" entry. Returned by
@@ -6521,9 +6572,38 @@ var CAP_NODE_PIN_CONTEXT_KEY = "__camstackNodePin";
6521
6572
  /**
6522
6573
  * Build the tRPC request options that pin a single capability call to `nodeId`.
6523
6574
  * Pass as the second argument to `.query(input, …)` / `.mutate(input, …)`.
6575
+ *
6576
+ * ## The id is normalised here, and it has to be
6577
+ *
6578
+ * A forked addon reads its own node from `ctx.kernel.localNodeId`, and inside a
6579
+ * worker that value is a RUNNER id — `hub/export-hap`, not `hub`. Routing
6580
+ * compares a pin against real node ids, so such a pin matches nothing and the
6581
+ * call fails with `no provider registered for cap "…"`. The local-first
6582
+ * resolver already guarded against this (`localNodeId.split('/')[0]`), which
6583
+ * made the hazard invisible: unpinned calls worked, and only an explicit pin —
6584
+ * the thing you reach for when you specifically need THIS node — silently
6585
+ * addressed a node that does not exist.
6586
+ *
6587
+ * Cost of it being missing: `addon-export-hap` pinned `decoder.getInfo` to its
6588
+ * own node to read the host's hardware-decode backend. It never once answered,
6589
+ * so every HomeKit egress transcode decoded in SOFTWARE — including 4K H.265 —
6590
+ * while D67's whole premise was that the decoder addon is the authority on
6591
+ * hardware. The warn said `decoding in SOFTWARE` and read as "this node has no
6592
+ * hardware", which was false.
6593
+ *
6594
+ * Normalising in the ONE constructor fixes every caller at once, which is why
6595
+ * it is here and not at the call sites.
6524
6596
  */
6525
6597
  function nodePin(nodeId) {
6526
- return { context: { [CAP_NODE_PIN_CONTEXT_KEY]: nodeId } };
6598
+ return { context: { [CAP_NODE_PIN_CONTEXT_KEY]: toNodeId(nodeId) } };
6599
+ }
6600
+ /**
6601
+ * A runner id is `<nodeId>/<addonId>`; a node id has no slash. Taking the head
6602
+ * is idempotent, so passing an already-clean id costs nothing.
6603
+ */
6604
+ function toNodeId(idOrRunnerId) {
6605
+ const head = idOrRunnerId.split("/")[0];
6606
+ return head === void 0 || head.length === 0 ? idOrRunnerId : head;
6527
6607
  }
6528
6608
  /**
6529
6609
  * Output schema shared by the contribution + live methods.
@@ -6974,7 +7054,300 @@ method(object({ deviceId: number() }), array(StreamSourceEntrySchema)), method(o
6974
7054
  input: unknown()
6975
7055
  }), unknown(), { kind: "mutation" }), method(object({ deviceId: number() }), _void(), { kind: "mutation" }), method(object({ deviceId: number() }), unknown().nullable()), method(object({ deviceId: number() }), RawStateResultSchema.nullable(), { auth: "protected" });
6976
7056
  //#endregion
6977
- //#region ../types/dist/canonical-hash-7nfBbEqR.mjs
7057
+ //#region ../types/dist/fmp4-box-splitter-B53u9-Nu.mjs
7058
+ var AUDIO_ENCODER_BY_CODEC = {
7059
+ opus: "libopus",
7060
+ aac: "aac",
7061
+ pcmu: "pcm_mulaw",
7062
+ pcma: "pcm_alaw"
7063
+ };
7064
+ /**
7065
+ * Camera-microphone audio, per codec. Lives HERE rather than in
7066
+ * `encode-defaults.ts` only to avoid an import cycle (`encode-defaults` depends
7067
+ * on these types); it is re-exported from there, which is where to read it.
7068
+ *
7069
+ * Every source in this repo is a mono camera mic. The former broker preset
7070
+ * encoded Opus at `channels: 2`, spending bitrate duplicating one channel —
7071
+ * that is the value this consolidation changed.
7072
+ */
7073
+ var AUDIO_PRESETS = {
7074
+ aac: {
7075
+ kind: "encode",
7076
+ codec: "aac",
7077
+ bitrateKbps: 128,
7078
+ sampleRateHz: 48e3,
7079
+ channels: 1
7080
+ },
7081
+ opus: {
7082
+ kind: "encode",
7083
+ codec: "opus",
7084
+ bitrateKbps: 64,
7085
+ sampleRateHz: 48e3,
7086
+ channels: 1
7087
+ },
7088
+ pcmu: {
7089
+ kind: "encode",
7090
+ codec: "pcmu",
7091
+ sampleRateHz: 8e3,
7092
+ channels: 1
7093
+ }
7094
+ };
7095
+ /** `-hide_banner -loglevel <level>` — every ffmpeg site opens with this. */
7096
+ function logBannerArgs(level) {
7097
+ return [
7098
+ "-hide_banner",
7099
+ "-loglevel",
7100
+ level
7101
+ ];
7102
+ }
7103
+ /** `true` when the resolved value means "decode in software" (⇒ no `-hwaccel`). */
7104
+ function isSoftwareDecode(decodeHwAccel) {
7105
+ return !decodeHwAccel || decodeHwAccel === "none" || decodeHwAccel === "copy";
7106
+ }
7107
+ /**
7108
+ * Every INPUT option, in order, terminated by `-i <url>`. Nothing may be
7109
+ * appended to this list by a caller — that is the whole point of the function.
7110
+ */
7111
+ function buildInputArgs(input, decodeHwAccel) {
7112
+ const args = [];
7113
+ if (!isSoftwareDecode(decodeHwAccel)) args.push("-hwaccel", String(decodeHwAccel));
7114
+ if (input.extraArgs?.length) args.push(...input.extraArgs);
7115
+ if (input.analyzeDurationUs !== void 0) args.push("-analyzeduration", String(input.analyzeDurationUs));
7116
+ if (input.probeSizeBytes !== void 0) args.push("-probesize", String(input.probeSizeBytes));
7117
+ if (input.fflags?.length) for (const flag of input.fflags) args.push("-fflags", flag);
7118
+ if (input.rtspTransport) args.push("-rtsp_transport", input.rtspTransport);
7119
+ args.push("-i", input.url);
7120
+ return args;
7121
+ }
7122
+ /** The `-vf` filter args, or `[]` when a consumer `-vf` already claims the slot. */
7123
+ function buildVideoFilterArgs(scale, outputArgs) {
7124
+ if (!scale) return [];
7125
+ if (outputArgs.some((a) => a === "-vf")) return [];
7126
+ if (scale.mode === "exact") return ["-vf", `scale=${scale.width}:${scale.height}`];
7127
+ return ["-vf", `scale='min(${scale.width},iw)':'min(${scale.height},ih)':force_original_aspect_ratio=decrease:force_divisible_by=2`];
7128
+ }
7129
+ /** Rate-control args for an encode plan. */
7130
+ function buildRateControlArgs(video) {
7131
+ const kbps = video.bitrateKbps;
7132
+ if (kbps === void 0) return [];
7133
+ const rc = video.rateControl ?? {
7134
+ kind: "cap",
7135
+ vbvSeconds: 2
7136
+ };
7137
+ const bufsize = Math.max(1, Math.round(kbps * rc.vbvSeconds));
7138
+ return [
7139
+ ...rc.kind === "cbr" ? ["-b:v", `${kbps}k`] : [],
7140
+ "-maxrate",
7141
+ `${kbps}k`,
7142
+ "-bufsize",
7143
+ `${bufsize}k`
7144
+ ];
7145
+ }
7146
+ /** The whole video block (`-vf` … `-c:v` … knobs), after `-i`. */
7147
+ function buildVideoArgs(video, outputArgs) {
7148
+ if (video.kind === "copy") return [
7149
+ "-c:v",
7150
+ "copy",
7151
+ ...video.bitstreamFilter ? ["-bsf:v", video.bitstreamFilter] : []
7152
+ ];
7153
+ const args = [
7154
+ ...buildVideoFilterArgs(video.scale, outputArgs),
7155
+ "-c:v",
7156
+ video.encoder
7157
+ ];
7158
+ if (video.preset !== void 0) args.push("-preset", video.preset);
7159
+ if (video.tune !== void 0) args.push("-tune", video.tune);
7160
+ if (video.profile !== void 0) args.push("-profile:v", video.profile);
7161
+ if (video.level !== void 0) args.push("-level", video.level);
7162
+ if (video.pixelFormat !== void 0) args.push("-pix_fmt", video.pixelFormat);
7163
+ if (video.fps !== void 0) args.push("-r", String(video.fps));
7164
+ if (video.gopFrames !== void 0) args.push("-g", String(video.gopFrames));
7165
+ if (video.forceKeyFramesSeconds !== void 0) args.push("-force_key_frames", `expr:gte(t,n_forced*${video.forceKeyFramesSeconds})`);
7166
+ if (video.bf !== void 0) args.push("-bf", String(video.bf));
7167
+ args.push(...buildRateControlArgs(video));
7168
+ if (video.bitstreamFilter !== void 0) args.push("-bsf:v", video.bitstreamFilter);
7169
+ return args;
7170
+ }
7171
+ /** The whole audio block, after `-i`. */
7172
+ function buildAudioArgs(audio) {
7173
+ if (audio.kind === "none") return ["-an"];
7174
+ if (audio.kind === "copy") return ["-c:a", "copy"];
7175
+ const args = [];
7176
+ if (audio.filter !== void 0) args.push("-af", audio.filter);
7177
+ args.push("-c:a", AUDIO_ENCODER_BY_CODEC[audio.codec]);
7178
+ if (audio.application !== void 0) args.push("-application", audio.application);
7179
+ if (audio.frameDurationMs !== void 0) args.push("-frame_duration", String(audio.frameDurationMs));
7180
+ if (audio.globalHeader === true) args.push("-flags", "+global_header");
7181
+ if (audio.sampleRateHz !== void 0) args.push("-ar", String(audio.sampleRateHz));
7182
+ if (audio.bitrateKbps !== void 0) args.push("-b:a", `${audio.bitrateKbps}k`);
7183
+ if (audio.vbvBufferKbits !== void 0) args.push("-bufsize", `${audio.vbvBufferKbits}k`);
7184
+ if (audio.channels !== void 0) args.push("-ac", String(audio.channels));
7185
+ return args;
7186
+ }
7187
+ /** RTP output-leg args (`-payload_type`, `-ssrc`, `-sdp_file`, `-f rtp <url>`). */
7188
+ function buildRtpOutputArgs(out) {
7189
+ const args = [];
7190
+ if (out.payloadType !== void 0) args.push("-payload_type", String(out.payloadType));
7191
+ if (out.ssrc !== void 0) args.push("-ssrc", String(out.ssrc));
7192
+ if (out.sdpFile !== void 0) args.push("-sdp_file", out.sdpFile);
7193
+ args.push("-f", "rtp", out.url);
7194
+ return args;
7195
+ }
7196
+ /** `true` when the sink is a raw elementary bytestream that cannot mux audio. */
7197
+ function isElementaryVideoSink(sink) {
7198
+ return sink.kind === "stdout" && (sink.container === "h264" || sink.container === "hevc");
7199
+ }
7200
+ /**
7201
+ * The fragmented-MP4 muxer flags, in the order the recorder has proven them
7202
+ * (`recorder/addon/ffmpeg-args.ts` passes the same `movflags` string through
7203
+ * `-segment_format_options`, across every vendor in the fleet):
7204
+ *
7205
+ * - `frag_keyframe` — cut a fragment at each key frame, so every fragment
7206
+ * opens on a sync sample. HKSV's whole requirement.
7207
+ * - `empty_moov` — write `ftyp`+`moov` up front with no samples in it, which
7208
+ * is what makes the head a standalone INITIALISATION segment.
7209
+ * - `default_base_moof` — fragment offsets are self-relative, so a fragment is
7210
+ * demuxable without the bytes that preceded it. D31's byte-range read path
7211
+ * depends on exactly this property of the recorder's segments.
7212
+ */
7213
+ var FMP4_MOVFLAGS = "+frag_keyframe+empty_moov+default_base_moof";
7214
+ /**
7215
+ * The terminal sink args for every non-`rtp-outputs` sink. Exhaustive over the
7216
+ * union so a new member cannot fall through to `['-f', container, 'pipe:1']`,
7217
+ * which is what a plain `container` read would have done for `mp4` — a valid
7218
+ * argv that writes a NON-fragmented, unseekable-to-a-pipe MP4 and produces one
7219
+ * unusable byte stream.
7220
+ */
7221
+ function buildStdoutOrRtspSinkArgs(sink) {
7222
+ if (sink.kind === "rtsp-listen") return [
7223
+ "-f",
7224
+ "rtsp",
7225
+ "-rtsp_transport",
7226
+ "tcp",
7227
+ "-rtsp_flags",
7228
+ "listen",
7229
+ sink.url
7230
+ ];
7231
+ if (sink.kind === "rtp-outputs") return [];
7232
+ return sink.container === "mp4" ? buildFmp4SinkArgs(sink) : [
7233
+ "-f",
7234
+ sink.container,
7235
+ "pipe:1"
7236
+ ];
7237
+ }
7238
+ /**
7239
+ * How far BELOW the negotiated fragment length `-min_frag_duration` is set.
7240
+ *
7241
+ * `-min_frag_duration` refuses to cut before that much media has accumulated,
7242
+ * and then waits for the next key frame. Set to exactly `fragmentMs`, the
7243
+ * commonest camera configuration in existence — a key-frame grid EQUAL to the
7244
+ * requested fragment length — lands the deadline on the same instant as the key
7245
+ * frame, loses the race, and skips to the following one: **every fragment comes
7246
+ * out at twice the requested length.**
7247
+ *
7248
+ * Measured on the live fleet 2026-08-07, camera 615, `-c:v copy` (D84):
7249
+ *
7250
+ * | slot | GOP | `-min_frag_duration` | median gap |
7251
+ * | --- | --- | --- | --- |
7252
+ * | 1280×720 | 40 f @ 10 fps = 4.0 s | 4000 ms | **7944 ms** |
7253
+ * | 1280×720 | 40 f @ 10 fps = 4.0 s | 3600 ms | 3973 ms |
7254
+ * | 3840×2160 | 100 f @ 25 fps = 4.0 s | 4000 ms | 8042 ms |
7255
+ * | 3840×2160 | 100 f @ 25 fps = 4.0 s | 3600 ms | 3998 ms |
7256
+ *
7257
+ * A doubled fragment is not a cosmetic overshoot: HKSV requires every fragment
7258
+ * to be no longer than the length the controller SELECTED, so the shipped-but-
7259
+ * inert phase-1 sink would have violated the contract on its first real clip.
7260
+ *
7261
+ * 10 % is chosen against the two failures either side of it. Too small and
7262
+ * ordinary jitter (measured spread 3953-4096 ms) re-loses the race; too large
7263
+ * and a source with a key frame slightly EARLY than the grid gets cut there,
7264
+ * yielding a short fragment for no reason.
7265
+ */
7266
+ var FMP4_MIN_FRAG_MARGIN = .9;
7267
+ /** `-movflags … -min_frag_duration <us> -f mp4 pipe:1`. */
7268
+ function buildFmp4SinkArgs(sink) {
7269
+ return [
7270
+ "-movflags",
7271
+ FMP4_MOVFLAGS,
7272
+ "-min_frag_duration",
7273
+ String(Math.max(0, Math.round(sink.fragmentMs * FMP4_MIN_FRAG_MARGIN * 1e3))),
7274
+ "-f",
7275
+ "mp4",
7276
+ "pipe:1"
7277
+ ];
7278
+ }
7279
+ /**
7280
+ * A second output mapping source audio to RTP-over-UDP. `0:a:0?` makes the
7281
+ * audio optional so a source with no audio skips it instead of failing the
7282
+ * whole invocation.
7283
+ */
7284
+ function buildAudioSidecarArgs(sidecar) {
7285
+ return [
7286
+ "-map",
7287
+ "0:a:0?",
7288
+ ...buildAudioArgs(sidecar.codec === "pcma" ? {
7289
+ kind: "encode",
7290
+ codec: "pcma",
7291
+ sampleRateHz: 8e3,
7292
+ channels: 1
7293
+ } : AUDIO_PRESETS[sidecar.codec]),
7294
+ ...buildRtpOutputArgs({
7295
+ url: sidecar.rtpUrl,
7296
+ sdpFile: sidecar.sdpFile
7297
+ })
7298
+ ];
7299
+ }
7300
+ /**
7301
+ * Assemble the full ffmpeg argument list. Layout:
7302
+ *
7303
+ * -hide_banner -loglevel <level>
7304
+ * [-hwaccel <backend|auto>] ─┐ INPUT options — strictly before -i.
7305
+ * [<input.extraArgs>] │
7306
+ * [-fflags <flag>…] │
7307
+ * [-rtsp_transport tcp] │
7308
+ * -i <url> ─┘
7309
+ * <video block> <threads> <audio block> ─┐ OUTPUT options.
7310
+ * <consumer outputArgs verbatim> │
7311
+ * <sink> ─┘ terminal
7312
+ */
7313
+ function buildFfmpegArgs(inv) {
7314
+ const head = [...logBannerArgs(inv.logLevel), ...buildInputArgs(inv.input, inv.decodeHwAccel)];
7315
+ const threadArgs = inv.threadCount > 0 ? ["-threads", String(inv.threadCount)] : [];
7316
+ if (inv.sink.kind === "rtp-outputs") {
7317
+ const videoLeg = inv.sink.video ? [
7318
+ "-an",
7319
+ "-map",
7320
+ "0:v:0",
7321
+ ...buildVideoArgs(inv.video, inv.outputArgs),
7322
+ ...threadArgs,
7323
+ ...inv.outputArgs,
7324
+ ...buildRtpOutputArgs(inv.sink.video)
7325
+ ] : [];
7326
+ const audioLeg = inv.sink.audio ? [
7327
+ "-vn",
7328
+ "-map",
7329
+ "0:a:0?",
7330
+ ...buildAudioArgs(inv.audio),
7331
+ ...buildRtpOutputArgs(inv.sink.audio)
7332
+ ] : [];
7333
+ return [
7334
+ ...head,
7335
+ ...videoLeg,
7336
+ ...audioLeg
7337
+ ];
7338
+ }
7339
+ const audioArgs = isElementaryVideoSink(inv.sink) ? ["-an"] : buildAudioArgs(inv.audio);
7340
+ const sinkArgs = buildStdoutOrRtspSinkArgs(inv.sink);
7341
+ return [
7342
+ ...head,
7343
+ ...buildVideoArgs(inv.video, inv.outputArgs),
7344
+ ...threadArgs,
7345
+ ...audioArgs,
7346
+ ...inv.outputArgs,
7347
+ ...sinkArgs,
7348
+ ...inv.audioSidecar ? buildAudioSidecarArgs(inv.audioSidecar) : []
7349
+ ];
7350
+ }
6978
7351
  /**
6979
7352
  * Deterministic SHA-256 hash of an arbitrary serialisable value. The
6980
7353
  * canonical form sorts object keys alphabetically at every depth so two
@@ -7006,6 +7379,190 @@ function replaceWithSortedKeys(_key, value) {
7006
7379
  }
7007
7380
  return value;
7008
7381
  }
7382
+ var DEFAULT_MAX_UNIT_BYTES = 16 * 1024 * 1024;
7383
+ /** Header size for a normal box, and for one carrying a 64-bit `largesize`. */
7384
+ var BOX_HEADER_BYTES = 8;
7385
+ var LARGE_BOX_HEADER_BYTES = 16;
7386
+ var Fmp4BoxSplitter = class {
7387
+ maxUnitBytes;
7388
+ /** Bytes of the CURRENT unit plus any partial box after it. */
7389
+ buffer = new Uint8Array(0);
7390
+ /** Where the current unit starts inside {@link buffer}. */
7391
+ unitStart = 0;
7392
+ /** Where the box scanner has reached inside {@link buffer}. */
7393
+ cursor = 0;
7394
+ state = "init";
7395
+ nextSequence = 0;
7396
+ faultReason = null;
7397
+ interstitial = /* @__PURE__ */ new Set();
7398
+ constructor(options = {}) {
7399
+ this.maxUnitBytes = options.maxUnitBytes ?? DEFAULT_MAX_UNIT_BYTES;
7400
+ }
7401
+ /**
7402
+ * Non-null once the stream cannot be split. The splitter emits nothing
7403
+ * further, so a caller polls this to kill the child rather than watching a
7404
+ * silent stall — a fragmenter that quietly stops producing looks exactly like
7405
+ * a camera with no motion.
7406
+ */
7407
+ get fault() {
7408
+ return this.faultReason;
7409
+ }
7410
+ /** Bytes currently held. The memory bound, observable rather than asserted. */
7411
+ get pendingBytes() {
7412
+ return this.buffer.length - this.unitStart;
7413
+ }
7414
+ /**
7415
+ * Top-level box types seen BETWEEN fragments and discarded — `mfra`, `free`,
7416
+ * a stray `sidx`. Reported rather than dropped in silence: they are legal and
7417
+ * useless to a fragment consumer, but a type nobody expected showing up here
7418
+ * is the first symptom of a muxer that is not writing what we think it is.
7419
+ */
7420
+ get discardedInterstitialTypes() {
7421
+ return [...this.interstitial];
7422
+ }
7423
+ /**
7424
+ * Feed bytes; get back whatever units completed. Returns `[]` once faulted.
7425
+ */
7426
+ push(chunk) {
7427
+ if (this.faultReason !== null || chunk.length === 0) return [];
7428
+ this.append(chunk);
7429
+ if (this.pendingBytes > this.maxUnitBytes) return this.fail(`a single fMP4 unit exceeded ${this.maxUnitBytes} bytes — this stream is not fragmented`);
7430
+ return this.drainBoxes();
7431
+ }
7432
+ append(chunk) {
7433
+ if (this.buffer.length === 0) {
7434
+ this.buffer = chunk.slice();
7435
+ return;
7436
+ }
7437
+ const next = new Uint8Array(this.buffer.length + chunk.length);
7438
+ next.set(this.buffer, 0);
7439
+ next.set(chunk, this.buffer.length);
7440
+ this.buffer = next;
7441
+ }
7442
+ /** Consume every COMPLETE top-level box now in the buffer. */
7443
+ drainBoxes() {
7444
+ const units = [];
7445
+ for (;;) {
7446
+ const header = this.readHeader();
7447
+ if (this.faultReason !== null) return units;
7448
+ if (header === null) break;
7449
+ if (this.cursor + header.totalBytes > this.buffer.length) break;
7450
+ const boxStart = this.cursor;
7451
+ const boxEnd = boxStart + header.totalBytes;
7452
+ this.cursor = boxEnd;
7453
+ const unit = this.consumeBox(header.type, boxStart, boxEnd);
7454
+ if (this.faultReason !== null) return units;
7455
+ if (unit !== null) units.push(unit);
7456
+ }
7457
+ this.compact();
7458
+ return units;
7459
+ }
7460
+ /**
7461
+ * Apply one box to the state machine. Returns a unit when this box CLOSED
7462
+ * one, `null` otherwise.
7463
+ */
7464
+ consumeBox(type, boxStart, boxEnd) {
7465
+ if (this.state === "init") {
7466
+ if (type !== "moof") return null;
7467
+ if (boxStart === this.unitStart) {
7468
+ this.fail("a moof arrived before any initialisation box — there is no ftyp/moov to send");
7469
+ return null;
7470
+ }
7471
+ const init = this.emit("init", this.unitStart, boxStart);
7472
+ this.unitStart = boxStart;
7473
+ this.state = "fragment";
7474
+ return init;
7475
+ }
7476
+ if (this.state === "idle") {
7477
+ if (type !== "moof") {
7478
+ this.interstitial.add(type);
7479
+ this.unitStart = boxEnd;
7480
+ return null;
7481
+ }
7482
+ this.unitStart = boxStart;
7483
+ this.state = "fragment";
7484
+ return null;
7485
+ }
7486
+ if (type !== "mdat") return null;
7487
+ const fragment = this.emit("fragment", this.unitStart, boxEnd);
7488
+ this.unitStart = boxEnd;
7489
+ this.state = "idle";
7490
+ return fragment;
7491
+ }
7492
+ /**
7493
+ * Parse the header at {@link cursor}, or `null` when too few bytes have
7494
+ * arrived to know. Faults on a size the splitter cannot honour.
7495
+ */
7496
+ readHeader() {
7497
+ const available = this.buffer.length - this.cursor;
7498
+ if (available < BOX_HEADER_BYTES) return null;
7499
+ const view = new DataView(this.buffer.buffer, this.buffer.byteOffset, this.buffer.byteLength);
7500
+ const size = view.getUint32(this.cursor);
7501
+ const type = String.fromCharCode(this.buffer[this.cursor + 4] ?? 0, this.buffer[this.cursor + 5] ?? 0, this.buffer[this.cursor + 6] ?? 0, this.buffer[this.cursor + 7] ?? 0);
7502
+ if (size === 0) {
7503
+ this.fail(`box "${type}" declares size 0 (to EOF) — an unbounded box cannot be fragmented`);
7504
+ return null;
7505
+ }
7506
+ if (size === 1) {
7507
+ if (available < LARGE_BOX_HEADER_BYTES) return null;
7508
+ const large = view.getBigUint64(this.cursor + BOX_HEADER_BYTES);
7509
+ if (large > BigInt(this.maxUnitBytes)) {
7510
+ this.fail(`box "${type}" declares ${large} bytes, over the ${this.maxUnitBytes} byte bound`);
7511
+ return null;
7512
+ }
7513
+ return {
7514
+ type,
7515
+ totalBytes: Number(large)
7516
+ };
7517
+ }
7518
+ if (size < BOX_HEADER_BYTES) {
7519
+ this.fail(`box "${type}" declares an impossible size of ${size} bytes`);
7520
+ return null;
7521
+ }
7522
+ return {
7523
+ type,
7524
+ totalBytes: size
7525
+ };
7526
+ }
7527
+ emit(kind, start, end) {
7528
+ const sequence = this.nextSequence;
7529
+ this.nextSequence += 1;
7530
+ return {
7531
+ kind,
7532
+ data: this.buffer.slice(start, end),
7533
+ sequence
7534
+ };
7535
+ }
7536
+ /**
7537
+ * Drop everything already emitted or discarded. Without this the buffer is
7538
+ * the whole stream and the process dies in hours, not minutes.
7539
+ */
7540
+ compact() {
7541
+ if (this.unitStart === 0) return;
7542
+ this.buffer = this.buffer.slice(this.unitStart);
7543
+ this.cursor -= this.unitStart;
7544
+ this.unitStart = 0;
7545
+ }
7546
+ fail(reason) {
7547
+ this.faultReason = reason;
7548
+ this.buffer = new Uint8Array(0);
7549
+ this.unitStart = 0;
7550
+ this.cursor = 0;
7551
+ return [];
7552
+ }
7553
+ };
7554
+ //#endregion
7555
+ //#region ../types/dist/err-msg-IQTHeDzc.mjs
7556
+ /**
7557
+ import { errMsg } from '@camstack/types'
7558
+ * Extract a human-readable message from an unknown error value.
7559
+ * Replaces the ubiquitous `errMsg(err)` pattern.
7560
+ */
7561
+ function errMsg$12(err) {
7562
+ if (err instanceof Error) return err.message;
7563
+ if (typeof err === "string") return err;
7564
+ return String(err);
7565
+ }
7009
7566
  var EncodeProfileSchema = object({
7010
7567
  video: object({
7011
7568
  codec: _enum([
@@ -7095,6 +7652,34 @@ var BASE_LIVE_EGRESS_PROFILE = {
7095
7652
  };
7096
7653
  ({ ...BASE_LIVE_EGRESS_PROFILE }), { ...BASE_LIVE_EGRESS_PROFILE.video };
7097
7654
  ({ ...BASE_LIVE_EGRESS_PROFILE });
7655
+ /** VBV window for a consumer whose budget is enforced per second (HomeKit). */
7656
+ var RATE_CONTROL_TIGHT = {
7657
+ kind: "cbr",
7658
+ vbvSeconds: 1
7659
+ };
7660
+ var HAP_AUDIO_BASE = {
7661
+ kind: "encode",
7662
+ codec: "opus",
7663
+ bitrateKbps: 24,
7664
+ channels: 1,
7665
+ application: "lowdelay",
7666
+ globalHeader: true,
7667
+ filter: "aresample=async=1000:first_pts=0"
7668
+ };
7669
+ function createHwAccelCache(options) {
7670
+ const now = options.now ?? (() => Date.now());
7671
+ let value = null;
7672
+ let writtenAt = Number.NEGATIVE_INFINITY;
7673
+ return {
7674
+ read() {
7675
+ return now() - writtenAt < options.ttlMs ? value : void 0;
7676
+ },
7677
+ write(next) {
7678
+ value = next;
7679
+ writtenAt = now();
7680
+ }
7681
+ };
7682
+ }
7098
7683
  /**
7099
7684
  * Deep wiring healthcheck — snapshot of active reachability probes across
7100
7685
  * every declared capability + widget of every installed plugin, on every
@@ -7151,7 +7736,7 @@ object({
7151
7736
  * ## This file adds no state
7152
7737
  *
7153
7738
  * Every switch here is a VIEW onto an authority that already existed
7154
- * ([D61](../../../../docs/decisions/adr-0062.md)). The whole point of the
7739
+ * ([D62](../../../../docs/decisions/adr-0062.md)). The whole point of the
7155
7740
  * group is that there is exactly one place each function is turned off, and
7156
7741
  * the group routes to it:
7157
7742
  *
@@ -7162,6 +7747,53 @@ object({
7162
7747
  * | `audio-analysis` | `deviceManager.setWrapperActive('audio-analysis')` | `AudioSubscriptionController.subscribeAudioStream` returns `null` before opening the stream |
7163
7748
  * | `recording` | `recording.setDeviceConfig` → `RecordingConfig.enabled` | `band-decision.shouldRecord` returns false; the controller detaches the device |
7164
7749
  * | `notifications` | `notificationRules.setDeviceMuted` | `NotificationCenter.evaluateAndEnqueue` returns before any rule is evaluated |
7750
+ * | `privacy-mask` | `privacyMask.setMask({ enabled })` → the CAMERA | the camera blanks the masked regions itself; every stream and recording carries the black boxes |
7751
+ * | `device-audio` | `privacyMask.setAudioEnabled` → the CAMERA | the camera stops encoding an audio track at all; every consumer sees silent video |
7752
+ * | `broker-audio` | `streamBroker.setDeviceAudioMute` → `DeviceOverride.audioMuted` | `StreamBroker.setAudioMuted` drops the audio plane at the source: no `type:'audio'` packet leaves `fanOutEncoded`, no RTP reaches the restreamer, and the restreamer serves the video-only SDP |
7753
+ *
7754
+ * ## `device-audio` and `broker-audio` are two functions, not two knobs
7755
+ *
7756
+ * They look adjacent and they are not the same control ([D83](../../../../docs/decisions/adr-0083.md)):
7757
+ * `device-audio` writes the CAMERA, so it is hardware privacy — the microphone
7758
+ * genuinely stops, it survives CamStack entirely, and it costs a multi-second
7759
+ * encoder restart on every flip. `broker-audio` writes THIS server, so it is
7760
+ * instant, vendor-independent and reversible without touching the camera, and
7761
+ * a camera that ignores or lacks the ISAPI/Reolink control is still silenced.
7762
+ * D62 forbids a second switch that *disagrees* with the first; these two
7763
+ * cannot disagree, because neither reads the other's store — the camera holds
7764
+ * one, the broker holds the other, and each reports its own fact.
7765
+ *
7766
+ * ## The two switches whose authority is not on this server
7767
+ *
7768
+ * `privacy-mask` and `device-audio` write the CAMERA. That is not a loophole
7769
+ * in "the group stores nothing" — it is the purest form of it: the camera
7770
+ * holds the fact, every read is a read-through, and there is no server-side
7771
+ * copy that could drift. Their availability therefore cannot come from
7772
+ * `listBindableCapsForDeviceType` (a device-NATIVE cap carries no wrappers and
7773
+ * is filtered out there); it comes from the cap's own camera-probed
7774
+ * `privacyMask.getOptions()`, which is strictly more honest — it answers for
7775
+ * THIS camera rather than for the device type
7776
+ * ([D74](../../../../docs/decisions/adr-0074.md)).
7777
+ *
7778
+ * ## `privacy-mask` is the one row whose ON is not "the function is working"
7779
+ *
7780
+ * Every other switch means *this camera's function is doing its job*, so
7781
+ * `enabled: false` is a thing an operator took away. `privacy-mask` means **the
7782
+ * MASK is active** — `enabled: true` is video deliberately obscured. The
7783
+ * polarity is not a choice made here: `addon-export-hap`'s privacy `Switch`
7784
+ * (`builders/privacy-switch.ts`) already mirrors `patch.enabled` verbatim, and
7785
+ * a HomeKit toggle that disagreed with the app's toggle for the same camera is
7786
+ * worse than either surface not having one.
7787
+ *
7788
+ * Two consequences follow and both are load-bearing:
7789
+ *
7790
+ * - **It never counts as `switchedOff`.** `countsAsSwitchedOff` is `false` for
7791
+ * exactly this row. With the polarity above, every camera that has NOT drawn
7792
+ * a privacy mask would otherwise report `switchedOff: ['privacy-mask']` — the
7793
+ * normal, healthy state of most cameras rendered as an operator disablement.
7794
+ * - **Its cost line names BOTH directions.** `costWhenOff` is rendered
7795
+ * unconditionally by both clients, so for this row it has to read correctly
7796
+ * whichever way the switch is sitting.
7165
7797
  *
7166
7798
  * The wrapper-binding pair is not a new idea: `legacy-migrations.ts` already
7167
7799
  * migrated the legacy `audioEnabled` / `pipelineEnabled` /
@@ -7180,14 +7812,18 @@ object({
7180
7812
  * `CameraStatus.switchedOff`.
7181
7813
  */
7182
7814
  /**
7183
- * The five functions the operator named (2026-08-05). Deliberately NOT one id
7184
- * per pipeline step: face recognition and plate/LPR are per-step toggles on
7815
+ * The functions the operator named — five on 2026-08-05, plus the camera's own
7816
+ * microphone on 2026-08-07. Deliberately NOT one id per pipeline step: face
7817
+ * recognition and plate/LPR are per-step toggles on
7185
7818
  * `pipelineOrchestrator.setCameraStepToggle` and belong in the pipeline
7186
- * editor, not in a five-button safety group.
7819
+ * editor, not in a safety group.
7187
7820
  */
7188
7821
  var CameraSwitchIdSchema = _enum([
7189
7822
  "stream-broker",
7190
7823
  "object-detection",
7824
+ "privacy-mask",
7825
+ "device-audio",
7826
+ "broker-audio",
7191
7827
  "audio-analysis",
7192
7828
  "recording",
7193
7829
  "notifications"
@@ -7205,14 +7841,27 @@ var CameraSwitchAuthoritySchema = discriminatedUnion("kind", [
7205
7841
  capName: string()
7206
7842
  }),
7207
7843
  object({ kind: literal("recording-config") }),
7208
- object({ kind: literal("notification-mute") })
7844
+ object({ kind: literal("notification-mute") }),
7845
+ object({
7846
+ kind: literal("camera-audio"),
7847
+ capName: string()
7848
+ }),
7849
+ object({
7850
+ kind: literal("camera-mask"),
7851
+ capName: string()
7852
+ }),
7853
+ object({ kind: literal("broker-audio-mute") })
7209
7854
  ]);
7210
7855
  /**
7211
7856
  * Why a switch is not offered for this camera. Rendered instead of the
7212
7857
  * control, never as a dead control — an absent function and a broken one must
7213
7858
  * not look the same.
7214
7859
  */
7215
- var CameraSwitchUnavailableReasonSchema = _enum(["no-provider", "source-unreachable"]);
7860
+ var CameraSwitchUnavailableReasonSchema = _enum([
7861
+ "no-provider",
7862
+ "source-unreachable",
7863
+ "not-configured"
7864
+ ]);
7216
7865
  /**
7217
7866
  * One switch, resolved for one camera.
7218
7867
  *
@@ -9234,6 +9883,26 @@ var EgressTranscodeRequestSchema = object({
9234
9883
  "h264_mp4toannexb",
9235
9884
  "hevc_mp4toannexb"
9236
9885
  ]).optional(),
9886
+ /**
9887
+ * Publish the transcode as a LOCAL push cam stream, instead of leaving the
9888
+ * consumer to dial the returned url. The broker picks the id and returns it
9889
+ * as `camStreamId` — a caller-supplied one would be circular, since the
9890
+ * sharing key is computed FROM this request.
9891
+ *
9892
+ * The url is still returned and still the contract for a transcode pinned to
9893
+ * another node. But dialling it locally costs an RTSP round trip that changes
9894
+ * the transport underneath the consumer: a dialled stream is an RTP source,
9895
+ * so `isRtpSource()` is true and the session takes the RTP-passthrough +
9896
+ * repacketizer branch. The push branch — the one the derived mechanism has
9897
+ * live hours on — is never reached. Measured on Alexa: broker registered, RTP
9898
+ * arriving, key frame arriving, black screen, on a chain healthy at every
9899
+ * other point.
9900
+ *
9901
+ * Same idea the transport already applies to CALLS, where `classifyCapRoute`
9902
+ * gives priority to `hub-in-process` so a local call never leaves the node.
9903
+ * This is that rule for media.
9904
+ */
9905
+ publishLocally: boolean().optional(),
9237
9906
  pixelFormat: _enum(["yuv420p", "nv12"]).optional(),
9238
9907
  /**
9239
9908
  * Operator/consumer override for decode hardware. ABSENT is the normal case
@@ -9278,7 +9947,13 @@ var EgressTranscodeSchema = object({
9278
9947
  * Returned rather than assumed: a consumer that asked for hardware and got
9279
9948
  * software needs to be able to see that without reading the broker's logs.
9280
9949
  */
9281
- decodeHwAccel: string().nullable()
9950
+ decodeHwAccel: string().nullable(),
9951
+ /**
9952
+ * Set when `publishLocally` was honoured: attach to THIS instead of dialling
9953
+ * `url`, and the session takes the push/deframe transport rather than the
9954
+ * RTP-passthrough one. `null` means the consumer must dial.
9955
+ */
9956
+ camStreamId: string().nullable()
9282
9957
  });
9283
9958
  method(object({
9284
9959
  deviceId: number().int().nonnegative(),
@@ -9426,7 +10101,25 @@ method(object({
9426
10101
  }), _void(), {
9427
10102
  kind: "mutation",
9428
10103
  auth: "admin"
9429
- }), method(object({ brokerId: string() }), boolean()), object({
10104
+ }), method(object({ brokerId: string() }), boolean()), method(object({ deviceId: number().int() }), object({
10105
+ muted: boolean(),
10106
+ /**
10107
+ * How many live non-derived brokers currently hold the mute. Purely
10108
+ * diagnostic: `muted` is the policy and is authoritative on its own
10109
+ * (it applies to brokers that do not exist yet), while this says
10110
+ * whether anything is presently being silenced.
10111
+ */
10112
+ appliedBrokers: number().int().nonnegative()
10113
+ })), method(object({
10114
+ deviceId: number().int(),
10115
+ muted: boolean()
10116
+ }), object({
10117
+ muted: boolean(),
10118
+ appliedBrokers: number().int().nonnegative()
10119
+ }), {
10120
+ kind: "mutation",
10121
+ auth: "admin"
10122
+ }), object({
9430
10123
  deviceId: number().int().nonnegative(),
9431
10124
  camStreamId: string(),
9432
10125
  profile: CamProfileSchema
@@ -14749,6 +15442,30 @@ var TrackSourceSchema = _enum([
14749
15442
  "audio"
14750
15443
  ]);
14751
15444
  /**
15445
+ * Where a track sits in the RETRAIN lifecycle (D81).
15446
+ *
15447
+ * - `none` — never marked, or un-marked. Evictable.
15448
+ * - `staging` — the operator wants this track as training material and has not
15449
+ * finished with it. **This is the only state retention holds**: the track and
15450
+ * everything it owns (object events, crops, keyframes, CLIP vector) survive
15451
+ * the device's age window.
15452
+ * - `trained` — the retrain page has taken what it needed. The frames it chose
15453
+ * were COPIED into the retrain dataset at selection time, so the dataset no
15454
+ * longer depends on the track's media and the track becomes EVICTABLE again.
15455
+ * Terminal for the plain `markForTrain` toggle: returning it to `staging` is
15456
+ * a deliberate action of the retrain page, not a side effect of a checkbox.
15457
+ *
15458
+ * There is no `null`. The state is stored `TEXT NOT NULL DEFAULT 'none'` because
15459
+ * the store's filter language has only positive equality and `whereIn` — no
15460
+ * negation, no IS NULL — so a NULL would be unselectable by ANY predicate and
15461
+ * would make the entire pre-column history immortal in one deploy.
15462
+ */
15463
+ var RetrainStatusSchema = _enum([
15464
+ "none",
15465
+ "staging",
15466
+ "trained"
15467
+ ]);
15468
+ /**
14752
15469
  * Per-track OPERATOR flags — set by hand from the admin UI or the viewer, never
14753
15470
  * by the pipeline. Spread into `TrackSchema` and `KeyEventSchema` from one place
14754
15471
  * so the two surfaces cannot drift.
@@ -14758,18 +15475,31 @@ var TrackSourceSchema = _enum([
14758
15475
  * columns existed read as absent, and a consumer that needs a boolean should say
14759
15476
  * `flag === true`, not `flag !== false`.
14760
15477
  *
14761
- * What the flags DO is deliberately UNDEFINED at the time of writing: they are
14762
- * operator curation, and the behaviour they drive will be specified separately.
14763
- * In particular a `markForTrain` track is NOT pinned against retention — see
14764
- * `docs/decisions/adr-0059.md` for why that is a store-level change, not a flag.
15478
+ * `markForTrain` is the WIRE FACE of {@link RetrainStatusSchema}, not a column:
15479
+ * it is exactly `retrainStatus === 'staging'`, in both directions. Writing
15480
+ * `true` moves `none → staging`, writing `false` moves `staging none`, and a
15481
+ * `trained` track reports `false` while refusing both writes. The boolean is
15482
+ * kept because three surfaces drive a toggle off it; anything that needs to tell
15483
+ * "never marked" from "already trained" must read `retrainStatus`.
15484
+ *
15485
+ * `debug` does NOT pin; it is attention, not durability.
14765
15486
  */
14766
15487
  var TrackFlagFields = {
14767
- /** Operator marked this track as training material. */
15488
+ /** Operator marked this track as training material — i.e. `retrainStatus` is
15489
+ * `'staging'`. */
14768
15490
  markForTrain: boolean().optional(),
14769
15491
  /** Operator marked this track for diagnostic attention. */
14770
15492
  debug: boolean().optional()
14771
15493
  };
14772
15494
  /**
15495
+ * The lifecycle field itself, on the READ surfaces only (`Track`, `KeyEvent`).
15496
+ * Deliberately NOT part of {@link TrackFlagFields}: that group also builds the
15497
+ * write patch, and the status is not something the toggle sets — it is what the
15498
+ * toggle's boolean is derived from. Absent on an in-RAM track never touched;
15499
+ * always present on a persisted row (the column default materialises `'none'`).
15500
+ */
15501
+ var TrackRetrainFields = { retrainStatus: RetrainStatusSchema.optional() };
15502
+ /**
14773
15503
  * The write half: a PARTIAL patch. An omitted key is left untouched, so setting
14774
15504
  * one flag can never clear the other — the toggles are independent and are
14775
15505
  * driven from three surfaces that do not know about each other.
@@ -14783,7 +15513,32 @@ var TrackFlagsPatchSchema = object(TrackFlagFields);
14783
15513
  var TrackFlagsSchema = object({
14784
15514
  trackId: string(),
14785
15515
  markForTrain: boolean(),
14786
- debug: boolean()
15516
+ debug: boolean(),
15517
+ /** The lifecycle state the boolean was derived from. Required here (unlike on
15518
+ * a track row) because this shape is only ever produced by the write body,
15519
+ * which always knows it — and a surface that has just written needs to render
15520
+ * `trained` without a re-fetch. */
15521
+ retrainStatus: RetrainStatusSchema
15522
+ });
15523
+ /** Per-camera slice of a training-export estimate. */
15524
+ var TrainingExportDeviceTotalsSchema = object({
15525
+ deviceId: number(),
15526
+ tracks: number().int(),
15527
+ files: number().int(),
15528
+ bytes: number().int()
15529
+ });
15530
+ /**
15531
+ * What a training export WOULD contain. Computed from media index rows only —
15532
+ * no blob is read to produce this.
15533
+ */
15534
+ var TrainingExportSummarySchema = object({
15535
+ generatedAt: number(),
15536
+ trackCount: number().int(),
15537
+ fileCount: number().int(),
15538
+ byteCount: number().int(),
15539
+ /** More marked tracks exist than a single pass carries. */
15540
+ truncated: boolean(),
15541
+ devices: array(TrainingExportDeviceTotalsSchema).readonly()
14787
15542
  });
14788
15543
  var TrackSchema = object({
14789
15544
  trackId: string(),
@@ -14828,7 +15583,8 @@ var TrackSchema = object({
14828
15583
  * Populated from the persisted envelope columns on historical reads;
14829
15584
  * absent on legacy rows, dims-less tracks and active (in-RAM) tracks. */
14830
15585
  envelope: TrackEnvelopeSchema.optional(),
14831
- ...TrackFlagFields
15586
+ ...TrackFlagFields,
15587
+ ...TrackRetrainFields
14832
15588
  });
14833
15589
  var BaseEventFields = {
14834
15590
  id: string(),
@@ -15042,7 +15798,8 @@ var KeyEventSchema = object({
15042
15798
  bestEventId: string(),
15043
15799
  /** Track lifetime in ms (lastSeen - firstSeen). */
15044
15800
  windowMs: number().optional(),
15045
- ...TrackFlagFields
15801
+ ...TrackFlagFields,
15802
+ ...TrackRetrainFields
15046
15803
  });
15047
15804
  object({
15048
15805
  trackId: string(),
@@ -15303,6 +16060,12 @@ DeviceType.Camera, method(object({ deviceId: number() }), array(TrackSchema).rea
15303
16060
  }), method(OpsLogQueryInputSchema, array(OpsLogEntrySchema).readonly(), {
15304
16061
  kind: "query",
15305
16062
  auth: "admin"
16063
+ }), method(object({ deviceIds: array(number()).optional() }), TrainingExportSummarySchema, {
16064
+ kind: "query",
16065
+ auth: "admin"
16066
+ }), method(object({ deviceIds: array(number()).optional() }), object({ url: string() }), {
16067
+ kind: "query",
16068
+ auth: "admin"
15306
16069
  }), method(object({
15307
16070
  eventId: string(),
15308
16071
  kind: MediaFileKindEnum.optional()
@@ -17120,9 +17883,15 @@ DeviceType.Camera, method(object({
17120
17883
  * Bypass the cache freshness check and fetch directly from the
17121
17884
  * native (or stream-broker fallback). Triggered by the UI's
17122
17885
  * "refresh" button so an operator can force a fresh frame
17123
- * even when the cache is well within `snapshotMaxAgeMs`.
17124
- * On battery cams this WILL wake the camera — accept the
17125
- * cost only when the user explicitly asks for it.
17886
+ * even when the cache is well within the device's
17887
+ * `snapshotMaxAgeS` window.
17888
+ *
17889
+ * **`force` is an OPERATOR signal, not a freshness preference.** On a
17890
+ * battery camera it is the one thing that walks past the wrapper's
17891
+ * sleep gate and wakes the camera, so a background caller — a poller,
17892
+ * an event handler, a thumbnail — must NEVER set it. Every such caller
17893
+ * gets the cached frame, which on a sleeping battery camera is the
17894
+ * correct answer: stale but honest beats woken.
17126
17895
  */
17127
17896
  force: boolean().optional()
17128
17897
  }), SnapshotImageSchema.nullable()), method(object({ deviceId: number() }), _void(), {
@@ -20957,6 +21726,173 @@ DeviceType.Camera, method(object({
20957
21726
  status: OsdStatusSchema
20958
21727
  });
20959
21728
  /**
21729
+ * `osd-manager` — the ORCHESTRATOR over the device-scope `osd` cap.
21730
+ *
21731
+ * The `osd` cap is the firmware contract: it probes a camera's overlay
21732
+ * SLOTS and writes literal text into one. It has no idea WHERE that text
21733
+ * comes from, and it must not — a driver that grew a "show the temperature
21734
+ * here" feature would grow it once per vendor.
21735
+ *
21736
+ * This cap owns the other half: a per-(camera, slot) BINDING that says
21737
+ * which value feeds the slot, how it is formatted, and under which
21738
+ * conditions it is shown at all. One addon renders every binding on every
21739
+ * camera, so a new source costs zero driver code.
21740
+ *
21741
+ * Three deliberate choices, each with a rejected alternative:
21742
+ *
21743
+ * 1. A source is `(capName, valuePath)` over the kernel's device
21744
+ * runtime-state mirror — NOT a closed enum of source kinds. Every
21745
+ * cap-keyed slice a device publishes is bindable the day the cap
21746
+ * ships. The rejected alternative (one enum member per source, with
21747
+ * a resolver branch each) is what makes "add the humidity too" a
21748
+ * code change.
21749
+ * 2. The display gate reuses `NcConditionsSchema` verbatim — the
21750
+ * notification centre's condition vocabulary — rather than a parallel
21751
+ * model. An operator who has learned one condition editor has learned
21752
+ * both.
21753
+ * 3. Because the renderer's facts are device STATE and not a detection
21754
+ * record, only a SUBSET of that vocabulary can be answered here.
21755
+ * `setSlotBinding` REJECTS the rest at write time (see
21756
+ * `getConditionSupport`). It does not accept-then-fail-closed: a
21757
+ * condition that can never be true renders a permanently blank
21758
+ * overlay, and a blank overlay looks exactly like a broken camera.
21759
+ */
21760
+ /** Where a slot's value comes from. */
21761
+ var OsdSourceSchema = discriminatedUnion("kind", [
21762
+ object({
21763
+ kind: literal("static"),
21764
+ text: string().max(64)
21765
+ }),
21766
+ object({
21767
+ kind: literal("clock"),
21768
+ /** Token pattern: `YYYY MM DD HH mm ss`. Everything else is literal. */
21769
+ pattern: string().min(1).max(32).default("HH:mm"),
21770
+ /** IANA zone. Omitted = the server's zone. */
21771
+ timezone: string().min(1).max(64).optional()
21772
+ }),
21773
+ object({
21774
+ kind: literal("device-state"),
21775
+ deviceId: number().int().optional(),
21776
+ capName: string().min(1).max(64),
21777
+ /** Dot path inside the slice, e.g. `detected`, `value`, `mode`. */
21778
+ valuePath: string().min(1).max(64)
21779
+ })
21780
+ ]);
21781
+ var OsdSlotBindingSchema = object({
21782
+ /** Off = the manager stops driving this slot. It does NOT clear it. */
21783
+ enabled: boolean().default(true),
21784
+ source: OsdSourceSchema,
21785
+ /** `${value}` and `${unit}` are substituted; every occurrence. */
21786
+ template: string().max(96).default("${value}"),
21787
+ /** Truncate with an ellipsis past this length. Absent = no limit. */
21788
+ maxCharacters: number().int().min(4).max(64).optional(),
21789
+ /**
21790
+ * Decimal places for a numeric value. `0` yields an integer — the
21791
+ * documented workaround for firmwares that reject `.` in overlay text.
21792
+ */
21793
+ maxDecimals: number().int().min(0).max(4).default(1),
21794
+ /** Appended via `${unit}`. The state mirror does not carry units. */
21795
+ unitLabel: string().max(8).optional(),
21796
+ /** Raw value → display text, e.g. `{"true":"MOTION","false":""}`. */
21797
+ valueMap: record(string(), string()).optional(),
21798
+ /** Time windows in which the slot is shown. Absent = always. */
21799
+ schedule: NcScheduleSchema.optional(),
21800
+ /**
21801
+ * Display gate, in the notification centre's condition vocabulary.
21802
+ * Only the keys reported by `getConditionSupport` are accepted.
21803
+ */
21804
+ conditions: NcConditionsSchema.optional(),
21805
+ /** Rendered when the gate is closed or the value unreadable. Empty = hide. */
21806
+ fallbackText: string().max(64).default("")
21807
+ });
21808
+ /** One camera slot, as the operator sees it: firmware truth + our binding. */
21809
+ var OsdSlotViewSchema = object({
21810
+ slotId: string(),
21811
+ kind: OsdOverlayKindEnum,
21812
+ /** Firmware refuses text edits (a timestamp, the channel name). */
21813
+ readOnly: boolean(),
21814
+ cameraEnabled: boolean(),
21815
+ cameraText: string().optional(),
21816
+ binding: OsdSlotBindingSchema.nullable()
21817
+ });
21818
+ /**
21819
+ * What happened to one slot on one render pass. `unchanged` exists so the
21820
+ * operator can tell "we are driving this and the value is steady" from
21821
+ * "we never got there" — and so the loop can prove it is not rewriting
21822
+ * identical text to the camera every tick.
21823
+ */
21824
+ var OsdRenderOutcomeEnum = _enum([
21825
+ "written",
21826
+ "unchanged",
21827
+ "gated",
21828
+ "unreadable",
21829
+ "disabled",
21830
+ "unbound",
21831
+ "failed"
21832
+ ]);
21833
+ var OsdRenderResultSchema = object({
21834
+ slotId: string(),
21835
+ outcome: OsdRenderOutcomeEnum,
21836
+ /** The text the slot should carry. Empty = the slot is switched off. */
21837
+ text: string(),
21838
+ /** Why, whenever the outcome is not a plain write. Never silent. */
21839
+ reason: string().optional()
21840
+ });
21841
+ var OsdSourceValueTypeEnum = _enum([
21842
+ "number",
21843
+ "boolean",
21844
+ "string",
21845
+ "enum"
21846
+ ]);
21847
+ /**
21848
+ * One bindable value, derived from a cap's `runtimeState` schema — never
21849
+ * hand-listed. The editor renders from this, so a cap that ships a new
21850
+ * state field becomes bindable with no UI change.
21851
+ */
21852
+ var OsdSourceOptionSchema = object({
21853
+ deviceId: number().int(),
21854
+ deviceName: string(),
21855
+ capName: string(),
21856
+ valuePath: string(),
21857
+ label: string(),
21858
+ valueType: OsdSourceValueTypeEnum,
21859
+ /** Present for `enum`; the editor offers these as `valueMap` keys. */
21860
+ enumValues: array(string()).readonly().optional()
21861
+ });
21862
+ method(object({ deviceId: number().int() }), object({
21863
+ supported: boolean(),
21864
+ slots: array(OsdSlotViewSchema)
21865
+ }), { auth: "admin" }), method(object({ deviceId: number().int() }), object({ sources: array(OsdSourceOptionSchema) }), { auth: "admin" }), method(object({}), object({
21866
+ supported: array(string()),
21867
+ catalog: array(NcConditionDescriptorSchema)
21868
+ }), { auth: "admin" }), method(object({
21869
+ deviceId: number().int(),
21870
+ slotId: string().min(1),
21871
+ binding: OsdSlotBindingSchema
21872
+ }), object({
21873
+ slot: OsdSlotViewSchema,
21874
+ render: OsdRenderResultSchema
21875
+ }), {
21876
+ kind: "mutation",
21877
+ auth: "admin"
21878
+ }), method(object({
21879
+ deviceId: number().int(),
21880
+ slotId: string().min(1)
21881
+ }), object({ success: literal(true) }), {
21882
+ kind: "mutation",
21883
+ auth: "admin"
21884
+ }), method(object({
21885
+ deviceId: number().int(),
21886
+ slotId: string().min(1),
21887
+ binding: OsdSlotBindingSchema.optional()
21888
+ }), OsdRenderResultSchema, {
21889
+ kind: "mutation",
21890
+ auth: "admin"
21891
+ }), method(object({ deviceId: number().int() }), object({ results: array(OsdRenderResultSchema) }), {
21892
+ kind: "mutation",
21893
+ auth: "admin"
21894
+ });
21895
+ /**
20960
21896
  * Feeder connectivity / power status — mirrors the HA petkit device-status
20961
21897
  * enum: `normal` (online, mains), `offline` (not reaching PetKit cloud),
20962
21898
  * `on_batteries` (running on battery backup). `null` until first reported.
@@ -21404,12 +22340,30 @@ object({
21404
22340
  });
21405
22341
  DeviceType.Sensor;
21406
22342
  /**
21407
- * Privacy mask = up to `maxRegions` SHAPES the camera blanks out (NOT a
21408
- * cell grid). Reolink `<shelterList>` zones are rectangles; Hikvision
21409
- * ISAPI `<RegionCoordinatesList>` zones are free polygons (this camera:
21410
- * exactly 4 vertices, not necessarily axis-aligned). The cap composes the
21411
- * shared rect|polygon subset of the MaskShape vocabulary. All coords are
21412
- * normalized 0..1 (top-left origin).
22343
+ * PRIVACY what the camera deliberately does not capture. Two planes:
22344
+ *
22345
+ * - **video**: up to `maxRegions` SHAPES the camera blanks out (NOT a cell
22346
+ * grid). Reolink `<shelterList>` zones are rectangles; Hikvision ISAPI
22347
+ * `<RegionCoordinatesList>` zones are free polygons (this camera: exactly
22348
+ * 4 vertices, not necessarily axis-aligned). The cap composes the shared
22349
+ * rect|polygon subset of the MaskShape vocabulary. All coords are
22350
+ * normalized 0..1 (top-left origin).
22351
+ * - **audio**: the camera's microphone. `setAudioEnabled(false)` stops the
22352
+ * camera encoding an audio track at all, so EVERY consumer — live view,
22353
+ * recording, the audio analyzer, an export — sees silent video. There is
22354
+ * no server-side copy of this fact; the camera is the store and every read
22355
+ * is a read-through, which is why a switch over it cannot drift
22356
+ * ([D62](../../../../docs/decisions/adr-0062.md)).
22357
+ *
22358
+ * Both belong here for one reason: they are the two things an operator turns
22359
+ * off when the answer to "what is this camera allowed to record" changes, and
22360
+ * both are applied ON the device, before anything leaves it.
22361
+ *
22362
+ * **The audio flag has exactly one writer.** `stream-params` used to carry a
22363
+ * per-profile `audio` in its patch schema — reachable from no UI and honoured
22364
+ * by one provider — and it was removed when this landed. A second writer onto
22365
+ * one device register is the shape of every knob this repo has shipped that
22366
+ * disagreed with the one the reader read.
21413
22367
  */
21414
22368
  /** A privacy-mask region's geometry — rectangle or free polygon. */
21415
22369
  var PrivacyMaskShapeSchema = discriminatedUnion("kind", [MaskRectShapeSchema, MaskPolygonShapeSchema]);
@@ -21425,16 +22379,40 @@ object({
21425
22379
  enabled: boolean(),
21426
22380
  /** Active zones (normalized 0..1). Length ≤ maxRegions. */
21427
22381
  regions: array(PrivacyMaskRegionSchema),
22382
+ /**
22383
+ * Is the camera capturing sound right now? Read from the camera, never from
22384
+ * a server-side mirror.
22385
+ *
22386
+ * `null` means "no answer" — either this camera exposes no controllable
22387
+ * microphone (`getOptions().supportsAudioMute === false`) or the read
22388
+ * failed. A consumer must render `null` as UNKNOWN and never as `false`:
22389
+ * "the microphone is off" and "we could not ask" look identical to an
22390
+ * operator only until one of them is wrong.
22391
+ *
22392
+ * On a camera whose profiles carry the flag independently (Reolink writes
22393
+ * it per stream), `true` means AT LEAST ONE profile still carries audio —
22394
+ * privacy is only satisfied when every one of them is silent.
22395
+ */
22396
+ audioEnabled: boolean().nullable(),
21428
22397
  lastFetchedAt: number()
21429
22398
  });
21430
- /** Per-camera availability. */
22399
+ /** Per-camera availability. Probed, never assumed from the model name. */
21431
22400
  var PrivacyMaskOptionsSchema = object({
21432
22401
  /** Maximum number of supported zones. */
21433
22402
  maxRegions: number(),
21434
22403
  /** Shape kinds this camera accepts — Reolink: ['rect']; Hikvision: ['rect','polygon']. */
21435
22404
  supportedShapes: array(MaskShapeKindSchema),
21436
22405
  /** Polygon vertex bounds when 'polygon' is supported (Hikvision: {min:4,max:4}). */
21437
- polygonVertices: MaskPolygonVerticesSchema.optional()
22406
+ polygonVertices: MaskPolygonVerticesSchema.optional(),
22407
+ /**
22408
+ * Does this camera expose a microphone switch we can actually write?
22409
+ *
22410
+ * Camera-probed: `true` only when the firmware answered with an audio flag
22411
+ * we know how to patch. A camera that never answered is `false` — a control
22412
+ * the operator can press that changes nothing is worse than no control, and
22413
+ * the switch group renders "not available" instead.
22414
+ */
22415
+ supportsAudioMute: boolean()
21438
22416
  });
21439
22417
  /** Partial change — every field optional. */
21440
22418
  var PrivacyMaskPatchSchema = object({
@@ -21447,6 +22425,12 @@ DeviceType.Camera, method(object({ deviceId: number() }), PrivacyMaskOptionsSche
21447
22425
  }), _void(), {
21448
22426
  kind: "mutation",
21449
22427
  auth: "admin"
22428
+ }), method(object({
22429
+ deviceId: number(),
22430
+ enabled: boolean()
22431
+ }), _void(), {
22432
+ kind: "mutation",
22433
+ auth: "admin"
21450
22434
  });
21451
22435
  var PtzPresetSchema = object({
21452
22436
  id: string(),
@@ -21656,6 +22640,21 @@ var LocateSegmentResultSchema = discriminatedUnion("kind", [object({
21656
22640
  })]);
21657
22641
  /** Raw bytes of one finalized footage segment (read off disk on the recording node). */
21658
22642
  var ReadSegmentBytesResultSchema = object({ data: _instanceof(Uint8Array) });
22643
+ /**
22644
+ * One GOP of a finalized segment, cut by byte range through the segment's own
22645
+ * `mfra` (D31 on the D42 feeder path). `data` is the `ftyp`+`moov` head plus
22646
+ * the single `moof`+`mdat` covering the requested instant — standalone-
22647
+ * demuxable, never the whole file. When the segment's index cannot be parsed
22648
+ * the provider degrades INSIDE the mechanism to the whole segment (still one
22649
+ * `data`, `gopStartMs` = the segment start) — a worse read, not another path.
22650
+ */
22651
+ var ReadGopBytesResultSchema = object({
22652
+ data: _instanceof(Uint8Array),
22653
+ /** Absolute epoch ms of the returned fragment's first sample. */
22654
+ gopStartMs: number(),
22655
+ /** Media ms the returned fragment covers. */
22656
+ gopDurMs: number()
22657
+ });
21659
22658
  method(object({
21660
22659
  deviceId: number(),
21661
22660
  fromMs: number(),
@@ -21698,6 +22697,14 @@ method(object({
21698
22697
  }), ReadSegmentBytesResultSchema, {
21699
22698
  kind: "query",
21700
22699
  auth: "admin"
22700
+ }), method(object({
22701
+ deviceId: number(),
22702
+ profile: string(),
22703
+ startMs: number(),
22704
+ epochMs: number()
22705
+ }), ReadGopBytesResultSchema, {
22706
+ kind: "query",
22707
+ auth: "admin"
21701
22708
  }), method(object({
21702
22709
  deviceId: number(),
21703
22710
  config: RecordingConfigSchema
@@ -22178,6 +23185,16 @@ var StreamProfileConfigSchema = object({
22178
23185
  "baseline"
22179
23186
  ]).optional(),
22180
23187
  gop: number().optional(),
23188
+ /**
23189
+ * Whether THIS profile currently carries an audio track. READ-ONLY here.
23190
+ *
23191
+ * There is no matching field on {@link StreamProfilePatchSchema}: the
23192
+ * camera's microphone is owned by `privacy-mask` (`setAudioEnabled`), which
23193
+ * writes every profile at once so "audio off" means silent everywhere. A
23194
+ * per-profile writer beside it would let a camera be half-muted and would be
23195
+ * a second knob onto one device register — the failure D62 exists to
23196
+ * prevent. Absent when the firmware does not report the flag.
23197
+ */
22181
23198
  audio: boolean().optional()
22182
23199
  });
22183
23200
  object({
@@ -22218,7 +23235,13 @@ var StreamParamsOptionsSchema = object({
22218
23235
  ext: StreamProfileOptionsSchema.optional()
22219
23236
  });
22220
23237
  /** A partial change to one profile — every field optional; a provider
22221
- * ignores fields it doesn't support. */
23238
+ * ignores fields it doesn't support.
23239
+ *
23240
+ * There is deliberately NO `audio` here. It existed until 2026-08-07,
23241
+ * reachable from no form and honoured by exactly one provider, while the
23242
+ * camera's microphone is a whole-device fact. It now has one writer,
23243
+ * `privacyMask.setAudioEnabled`, which writes every profile — see
23244
+ * `privacy-mask.cap.ts`. */
22222
23245
  var StreamProfilePatchSchema = object({
22223
23246
  width: number().optional(),
22224
23247
  height: number().optional(),
@@ -22231,8 +23254,7 @@ var StreamProfilePatchSchema = object({
22231
23254
  "main",
22232
23255
  "baseline"
22233
23256
  ]).optional(),
22234
- gop: number().optional(),
22235
- audio: boolean().optional()
23257
+ gop: number().optional()
22236
23258
  });
22237
23259
  DeviceType.Camera, method(object({ deviceId: number() }), StreamParamsOptionsSchema), method(object({
22238
23260
  deviceId: number(),
@@ -25826,6 +26848,48 @@ Object.freeze({
25826
26848
  addonId: null,
25827
26849
  access: "create"
25828
26850
  },
26851
+ "osdManager.clearSlotBinding": {
26852
+ capName: "osd-manager",
26853
+ capScope: "system",
26854
+ addonId: null,
26855
+ access: "delete"
26856
+ },
26857
+ "osdManager.getConditionSupport": {
26858
+ capName: "osd-manager",
26859
+ capScope: "system",
26860
+ addonId: null,
26861
+ access: "view"
26862
+ },
26863
+ "osdManager.getDeviceOsd": {
26864
+ capName: "osd-manager",
26865
+ capScope: "system",
26866
+ addonId: null,
26867
+ access: "view"
26868
+ },
26869
+ "osdManager.getSourceCatalog": {
26870
+ capName: "osd-manager",
26871
+ capScope: "system",
26872
+ addonId: null,
26873
+ access: "view"
26874
+ },
26875
+ "osdManager.previewSlot": {
26876
+ capName: "osd-manager",
26877
+ capScope: "system",
26878
+ addonId: null,
26879
+ access: "create"
26880
+ },
26881
+ "osdManager.renderDevice": {
26882
+ capName: "osd-manager",
26883
+ capScope: "system",
26884
+ addonId: null,
26885
+ access: "create"
26886
+ },
26887
+ "osdManager.setSlotBinding": {
26888
+ capName: "osd-manager",
26889
+ capScope: "system",
26890
+ addonId: null,
26891
+ access: "create"
26892
+ },
25829
26893
  "petFeeder.callPet": {
25830
26894
  capName: "pet-feeder",
25831
26895
  capScope: "device",
@@ -25988,6 +27052,18 @@ Object.freeze({
25988
27052
  addonId: null,
25989
27053
  access: "view"
25990
27054
  },
27055
+ "pipelineAnalytics.getTrainingExportSummary": {
27056
+ capName: "pipeline-analytics",
27057
+ capScope: "device",
27058
+ addonId: null,
27059
+ access: "view"
27060
+ },
27061
+ "pipelineAnalytics.getTrainingExportUrl": {
27062
+ capName: "pipeline-analytics",
27063
+ capScope: "device",
27064
+ addonId: null,
27065
+ access: "view"
27066
+ },
25991
27067
  "pipelineAnalytics.listEventKinds": {
25992
27068
  capName: "pipeline-analytics",
25993
27069
  capScope: "device",
@@ -26744,6 +27820,12 @@ Object.freeze({
26744
27820
  addonId: null,
26745
27821
  access: "view"
26746
27822
  },
27823
+ "privacyMask.setAudioEnabled": {
27824
+ capName: "privacy-mask",
27825
+ capScope: "device",
27826
+ addonId: null,
27827
+ access: "create"
27828
+ },
26747
27829
  "privacyMask.setMask": {
26748
27830
  capName: "privacy-mask",
26749
27831
  capScope: "device",
@@ -26912,6 +27994,12 @@ Object.freeze({
26912
27994
  addonId: null,
26913
27995
  access: "create"
26914
27996
  },
27997
+ "recording.readGopBytes": {
27998
+ capName: "recording",
27999
+ capScope: "system",
28000
+ addonId: null,
28001
+ access: "view"
28002
+ },
26915
28003
  "recording.readSegmentBytes": {
26916
28004
  capName: "recording",
26917
28005
  capScope: "system",
@@ -27452,6 +28540,12 @@ Object.freeze({
27452
28540
  addonId: null,
27453
28541
  access: "view"
27454
28542
  },
28543
+ "streamBroker.getDeviceAudioMute": {
28544
+ capName: "stream-broker",
28545
+ capScope: "system",
28546
+ addonId: null,
28547
+ access: "view"
28548
+ },
27455
28549
  "streamBroker.getPreBufferInfo": {
27456
28550
  capName: "stream-broker",
27457
28551
  capScope: "system",
@@ -27572,6 +28666,12 @@ Object.freeze({
27572
28666
  addonId: null,
27573
28667
  access: "create"
27574
28668
  },
28669
+ "streamBroker.setDeviceAudioMute": {
28670
+ capName: "stream-broker",
28671
+ capScope: "system",
28672
+ addonId: null,
28673
+ access: "create"
28674
+ },
27575
28675
  "streamBroker.setPreBufferDuration": {
27576
28676
  capName: "stream-broker",
27577
28677
  capScope: "system",
@@ -28333,6 +29433,88 @@ object({
28333
29433
  square: false
28334
29434
  }).paddingRatio;
28335
29435
  /**
29436
+ * WHICH delivered frames the decode worker retains a native copy of.
29437
+ *
29438
+ * - `all` — every frame the worker delivered to the runner. The shipped
29439
+ * behaviour, and the only correct one if something can ask for a crop of a
29440
+ * frame the runner never sent to inference.
29441
+ * - `inferred` — only the frames the runner ADMITTED to its detection queue.
29442
+ * A native-crop request always names a `frameId` that rode an inference
29443
+ * result, so that is the only set a request can name. How much it drops is
29444
+ * the two-plane governor's admit ratio and nothing else: measured at ~50% on
29445
+ * this cluster, not the ~80% the design sketch assumed, because the governor
29446
+ * was not throttling as hard as the sketch supposed. Read
29447
+ * `leaseAdmitted`/`leaseOffered` off the metrics line for the camera in front
29448
+ * of you rather than quoting a number from here. The newest delivered frame is
29449
+ * croppable regardless — it is still the worker's reserved slot, not a lease —
29450
+ * which covers the one-frame race between a mark and the supersede that
29451
+ * consumes it.
29452
+ */
29453
+ var NativeLeaseAdmissionSchema = _enum(["all", "inferred"]);
29454
+ object({
29455
+ /**
29456
+ * How long a retained native frame is served before it counts as a miss.
29457
+ *
29458
+ * Must cover the FULL late-crop horizon: detection inference + the
29459
+ * cross-process inference-result hop to hub post-analysis + tracking + the
29460
+ * tRPC crop round-trip back. Below ~500 ms the busiest cameras' subject crops
29461
+ * outrun it and fall back to the ≤640 detection frame; above ~3 s the resident
29462
+ * RAM per busy camera grows linearly with no measured hit-rate gain.
29463
+ */
29464
+ ttlMs: number().int().min(250).max(1e4),
29465
+ /**
29466
+ * Hard per-decode-worker RAM ceiling for retained native frames, in MB.
29467
+ *
29468
+ * Intended as a SAFETY ceiling with the TTL as the effective cap — but check
29469
+ * which one is actually binding before reasoning from that. At the shipped
29470
+ * 1024 MB and a 2 800 ms TTL, a 4K camera hits the CEILING first (~43 frames
29471
+ * at ~24 MB each) and the TTL never gets to expire anything; `leaseMb` /
29472
+ * `leaseFrames` on the metrics line say which. When the ceiling binds, a
29473
+ * change that admits fewer frames buys retention WINDOW at constant RAM
29474
+ * rather than giving RAM back — lower this knob if RAM is what you wanted.
29475
+ * `0` DISABLES the lease entirely and falls the worker back to the tiny
29476
+ * leak-prone GPU surface ring (~85% crop miss; that is what the lease exists
29477
+ * to replace).
29478
+ */
29479
+ budgetMb: number().int().min(0).max(4096),
29480
+ /**
29481
+ * Demand window: eager per-frame native retention runs only within this many
29482
+ * ms of the last native-crop request (or of the dial starting).
29483
+ *
29484
+ * `0` means ALWAYS ON — it disables the gate, it does not disable retention.
29485
+ * That is the legacy behaviour that saturated an N100 (24 native-4K downloads
29486
+ * per second on a camera with zero crop demand), so leave it non-zero unless
29487
+ * you are reproducing that.
29488
+ */
29489
+ activityMs: number().int().min(0).max(12e4),
29490
+ /**
29491
+ * Which delivered frames are retained at all — see
29492
+ * {@link NativeLeaseAdmissionSchema}. This is the only knob of the four that
29493
+ * changes WHAT is kept rather than for how long, so it is also the only one
29494
+ * that can turn a crop that used to hit into a miss. The worker counts every
29495
+ * crop request naming a frame it did NOT see marked
29496
+ * (`leaseUnmarkedCrops` on the session-decode metrics line): a non-zero value
29497
+ * there is the signal that some caller names frames outside the inference set
29498
+ * and that this must go back to `all`.
29499
+ */
29500
+ admission: NativeLeaseAdmissionSchema
29501
+ });
29502
+ /**
29503
+ * The values in force when the operator has set nothing — byte-for-byte the
29504
+ * constants the decode worker shipped with as env-var defaults, so making these
29505
+ * settings changed no behaviour on the day it landed.
29506
+ */
29507
+ var DEFAULT_NATIVE_LEASE_SETTINGS = {
29508
+ ttlMs: 1200,
29509
+ budgetMb: 1024,
29510
+ activityMs: 15e3,
29511
+ admission: "inferred"
29512
+ };
29513
+ DEFAULT_NATIVE_LEASE_SETTINGS.ttlMs;
29514
+ DEFAULT_NATIVE_LEASE_SETTINGS.budgetMb;
29515
+ DEFAULT_NATIVE_LEASE_SETTINGS.activityMs;
29516
+ DEFAULT_NATIVE_LEASE_SETTINGS.admission;
29517
+ /**
28336
29518
  * Compute the stable 64-char lowercase-hex fingerprint of a device's
28337
29519
  * export-relevant shape. Two structurally-equal shapes (any feature order,
28338
29520
  * any duplicates, any deviceId) hash identically.
@@ -28358,6 +29540,506 @@ function resolveExportFingerprint(input) {
28358
29540
  if (input.ready) return input.fresh;
28359
29541
  return input.persisted ?? input.fresh;
28360
29542
  }
29543
+ /**
29544
+ * Fmp4FragmentPlane — a SUBSCRIBABLE fragmented-MP4 plane, fed by one
29545
+ * {@link import('./fmp4-box-splitter.js').Fmp4BoxSplitter}.
29546
+ *
29547
+ * ## Why a plane and not a callback
29548
+ *
29549
+ * The operator's requirement for HKSV was explicit: the live fMP4 source built
29550
+ * for it must be **dual-use**, so a HomeKit-triggered recording also lands in
29551
+ * CamStack as an additional videoclip source alongside the recorder and the NC
29552
+ * clip ring — *one fragmenter, two consumers; do not build an HKSV-only pipe*
29553
+ * (`docs/roadmap.md` item 4b). A single-callback pipe makes the second consumer
29554
+ * a second ffmpeg child of the same camera. So this is the same shape the
29555
+ * broker's other multi-consumer surfaces already have
29556
+ * (`AudioChunkPlane`, the push packet plane): N independent subscriptions over
29557
+ * one producer.
29558
+ *
29559
+ * **Nothing consumes it yet.** Phase 4 brings the HKSV delegate and phase 4b the
29560
+ * clip source; both are named here so the seam is not re-invented, and neither
29561
+ * is built.
29562
+ *
29563
+ * ## The init segment is RETAINED
29564
+ *
29565
+ * A subscriber that attaches mid-stream — the clip consumer joining an already
29566
+ * running HKSV session, which is the whole dual-use case — receives the
29567
+ * retained `ftyp`+`moov` as its first packet and then live fragments. Without
29568
+ * retention its fragments are undecodable and the failure looks like a codec
29569
+ * problem.
29570
+ *
29571
+ * ## A slow subscriber is CLOSED, never silently gapped
29572
+ *
29573
+ * `AudioChunkPlane` drops its oldest chunk on overflow, which for audio costs a
29574
+ * click. An fMP4 stream with a hole is not a shorter clip, it is a corrupt one:
29575
+ * `moof` sequence numbers jump, the consumer's demuxer desynchronises, and HKSV
29576
+ * shows a clip that fails to play with nothing anywhere saying why. So a
29577
+ * subscription whose queue overflows is ENDED with a reason, loudly, and the
29578
+ * other subscriptions are untouched.
29579
+ *
29580
+ * ## The PREBUFFER (phase 3)
29581
+ *
29582
+ * HKSV asks for context BEFORE the trigger — `CameraRecordingOptions.prebufferLength`
29583
+ * is a HAP-mandated minimum of 4000 ms — and a subscriber that attaches at the
29584
+ * motion edge has none. So the plane optionally retains the last few fragments
29585
+ * and replays them to a subscriber that asks for them.
29586
+ *
29587
+ * Three things this ring gets right, each of which is a measured fact rather
29588
+ * than a preference (see [D84](../../../../docs/decisions/adr-0084.md)):
29589
+ *
29590
+ * - **It is bounded by TIME *and* BYTES.** On the live fleet a 720p copy
29591
+ * fragment is ~255 KB and a 4K one is ~6.35 MB — a 25× spread over the same
29592
+ * window. A time-only bound is a per-camera RAM figure nobody can predict.
29593
+ * - **The window is measured on ARRIVAL, not parsed from `tfdt`.** The
29594
+ * splitter deliberately never computes a fragment's duration (a second
29595
+ * opinion about a fact the muxer owns), and a prebuffer cares about how long
29596
+ * ago the bytes turned up, which is exactly what arrival time answers.
29597
+ * - **A replay is not backlog.** A subscriber taking N retained fragments gets
29598
+ * its queue capacity raised by N for them, because closing a subscriber as a
29599
+ * slow consumer for the prebuffer it explicitly asked for would be the
29600
+ * stupidest possible failure — and, with `DEFAULT_QUEUE_CAPACITY` of 4 and a
29601
+ * ring of 4, the guaranteed one.
29602
+ *
29603
+ * ## `isLast`
29604
+ *
29605
+ * hap-nodejs requires the delegate to mark exactly one `RecordingPacket` with
29606
+ * `isLast` — a generator that finishes without it produces the twelve-second
29607
+ * timeout loop [D50](../../../../../docs/decisions/adr-0050.md) deleted. The
29608
+ * plane therefore computes it at DELIVERY time: a packet is last when the plane
29609
+ * has ended and nothing remains queued behind it. A subscription that ends
29610
+ * having delivered NOTHING says so through {@link Fmp4Subscription.delivered};
29611
+ * the future delegate must not open an HDS stream it cannot feed.
29612
+ */
29613
+ var DEFAULT_QUEUE_CAPACITY = 4;
29614
+ var Fmp4FragmentPlane = class {
29615
+ logger;
29616
+ prebuffer;
29617
+ now;
29618
+ subscriptions = /* @__PURE__ */ new Map();
29619
+ /** The last init unit seen, handed to every later subscriber. */
29620
+ retainedInit = null;
29621
+ ended = false;
29622
+ /** Oldest first. Empty unless {@link Fmp4PrebufferOptions} was supplied. */
29623
+ ring = [];
29624
+ ringBytes = 0;
29625
+ constructor(logger, prebuffer, now = Date.now) {
29626
+ this.logger = logger;
29627
+ this.prebuffer = prebuffer;
29628
+ this.now = now;
29629
+ }
29630
+ get subscriberCount() {
29631
+ return this.subscriptions.size;
29632
+ }
29633
+ /** True once {@link end} has been called — no further units are accepted. */
29634
+ get isEnded() {
29635
+ return this.ended;
29636
+ }
29637
+ /** What the prebuffer ring holds right now. All zeroes when disabled. */
29638
+ prebufferStats() {
29639
+ const oldest = this.ring[0];
29640
+ return {
29641
+ fragments: this.ring.length,
29642
+ bytes: this.ringBytes,
29643
+ spanMs: oldest === void 0 ? 0 : this.now() - oldest.arrivedAt
29644
+ };
29645
+ }
29646
+ subscribe(input) {
29647
+ const replay = input.withPrebuffer === true ? this.trimmedRing() : [];
29648
+ const requested = Math.max(1, input.queueCapacity ?? DEFAULT_QUEUE_CAPACITY);
29649
+ const sub = {
29650
+ id: `fmp4-${randomUUID()}`,
29651
+ tag: input.tag,
29652
+ subscribedAt: this.now(),
29653
+ capacity: requested + replay.length,
29654
+ queue: [],
29655
+ delivered: 0,
29656
+ closedReason: null,
29657
+ wake: null,
29658
+ iterating: false
29659
+ };
29660
+ this.subscriptions.set(sub.id, sub);
29661
+ if (this.retainedInit !== null) this.enqueue(sub, this.retainedInit);
29662
+ for (const retained of replay) this.enqueue(sub, retained.unit);
29663
+ if (this.ended) this.closeSubscription(sub, "ended");
29664
+ this.logger?.info("fmp4 plane: subscribed", { meta: {
29665
+ subscriptionId: sub.id,
29666
+ tag: sub.tag,
29667
+ hasRetainedInit: this.retainedInit !== null,
29668
+ prebufferFragments: replay.length,
29669
+ prebufferBytes: replay.reduce((n, r) => n + r.unit.data.length, 0)
29670
+ } });
29671
+ return this.facade(sub);
29672
+ }
29673
+ /**
29674
+ * Fan one splitter unit out. An `init` REPLACES the retained one — ffmpeg
29675
+ * emits exactly one per child, and a second means the child was respawned, in
29676
+ * which case the old one describes a stream that no longer exists.
29677
+ */
29678
+ publish(unit) {
29679
+ if (this.ended) return;
29680
+ if (unit.kind === "init") {
29681
+ this.retainedInit = unit;
29682
+ this.ring.length = 0;
29683
+ this.ringBytes = 0;
29684
+ } else this.retain(unit);
29685
+ for (const sub of this.subscriptions.values()) {
29686
+ if (sub.closedReason !== null) continue;
29687
+ this.enqueue(sub, unit);
29688
+ }
29689
+ }
29690
+ /**
29691
+ * The producer stopped. Every subscriber drains what it holds; its final
29692
+ * packet carries `isLast`, and its generator then completes.
29693
+ */
29694
+ end(reason = "producer ended") {
29695
+ if (this.ended) return;
29696
+ this.ended = true;
29697
+ this.logger?.info("fmp4 plane: ended", { meta: {
29698
+ reason,
29699
+ subscribers: this.subscriptions.size
29700
+ } });
29701
+ for (const sub of this.subscriptions.values()) if (sub.closedReason === null) this.closeSubscription(sub, "ended");
29702
+ }
29703
+ listSubscribers() {
29704
+ return [...this.subscriptions.values()].map((s) => ({
29705
+ tag: s.tag,
29706
+ subscribedAt: s.subscribedAt,
29707
+ delivered: s.delivered,
29708
+ closedReason: s.closedReason
29709
+ }));
29710
+ }
29711
+ /** End and forget everything. Idempotent. */
29712
+ dispose() {
29713
+ this.end("disposed");
29714
+ this.subscriptions.clear();
29715
+ this.retainedInit = null;
29716
+ this.ring.length = 0;
29717
+ this.ringBytes = 0;
29718
+ }
29719
+ /**
29720
+ * Add one fragment to the ring and evict from the front until BOTH bounds
29721
+ * hold. Eviction is oldest-first, which is the one place in this file where
29722
+ * dropping is correct: the ring is context, not stream — nobody is mid-decode
29723
+ * on it, and a subscriber only ever receives a contiguous tail of it.
29724
+ */
29725
+ retain(unit) {
29726
+ const prebuffer = this.prebuffer;
29727
+ if (prebuffer === void 0) return;
29728
+ const arrivedAt = this.now();
29729
+ this.ring.push({
29730
+ unit,
29731
+ arrivedAt
29732
+ });
29733
+ this.ringBytes += unit.data.length;
29734
+ const cutoff = arrivedAt - prebuffer.windowMs;
29735
+ while (this.ring.length > 0) {
29736
+ const oldest = this.ring[0];
29737
+ if (oldest === void 0) break;
29738
+ const tooOld = oldest.arrivedAt < cutoff;
29739
+ const tooBig = this.ringBytes > prebuffer.maxBytes;
29740
+ if (!tooOld && !tooBig || this.ring.length === 1) break;
29741
+ this.ring.shift();
29742
+ this.ringBytes -= oldest.unit.data.length;
29743
+ }
29744
+ }
29745
+ /**
29746
+ * The ring as a subscriber should receive it — window applied AT SUBSCRIBE
29747
+ * time, not only at publish time. A camera that went quiet keeps its last
29748
+ * fragment in the ring indefinitely (see the never-evict-the-newest rule),
29749
+ * and replaying a 40-second-old fragment as "prebuffer" would put stale video
29750
+ * at the head of a clip iOS presents as the moment of the event.
29751
+ */
29752
+ trimmedRing() {
29753
+ const prebuffer = this.prebuffer;
29754
+ if (prebuffer === void 0) return [];
29755
+ const cutoff = this.now() - prebuffer.windowMs;
29756
+ return this.ring.filter((r) => r.arrivedAt >= cutoff);
29757
+ }
29758
+ enqueue(sub, unit) {
29759
+ if (sub.queue.length >= sub.capacity) {
29760
+ this.logger?.warn("fmp4 plane: subscriber fell behind — CLOSING it rather than gapping it", { meta: {
29761
+ subscriptionId: sub.id,
29762
+ tag: sub.tag,
29763
+ capacity: sub.capacity,
29764
+ delivered: sub.delivered
29765
+ } });
29766
+ this.closeSubscription(sub, "slow-consumer");
29767
+ return;
29768
+ }
29769
+ sub.queue.push({
29770
+ kind: unit.kind,
29771
+ data: unit.data,
29772
+ sequence: unit.sequence,
29773
+ isLast: false
29774
+ });
29775
+ this.wake(sub);
29776
+ }
29777
+ closeSubscription(sub, reason) {
29778
+ if (sub.closedReason !== null) return;
29779
+ sub.closedReason = reason;
29780
+ if (reason === "slow-consumer") sub.queue.length = 0;
29781
+ this.wake(sub);
29782
+ }
29783
+ wake(sub) {
29784
+ const resume = sub.wake;
29785
+ sub.wake = null;
29786
+ resume?.();
29787
+ }
29788
+ facade(sub) {
29789
+ const plane = this;
29790
+ return {
29791
+ id: sub.id,
29792
+ tag: sub.tag,
29793
+ get delivered() {
29794
+ return sub.delivered;
29795
+ },
29796
+ get closedReason() {
29797
+ return sub.closedReason;
29798
+ },
29799
+ packets: () => plane.iterate(sub),
29800
+ release: () => {
29801
+ plane.closeSubscription(sub, "released");
29802
+ plane.subscriptions.delete(sub.id);
29803
+ }
29804
+ };
29805
+ }
29806
+ async *iterate(sub) {
29807
+ if (sub.iterating) throw new Error(`fmp4 plane: subscription ${sub.tag} is already being consumed — take a second subscription`);
29808
+ sub.iterating = true;
29809
+ for (;;) {
29810
+ const next = sub.queue.shift();
29811
+ if (next === void 0) {
29812
+ if (sub.closedReason !== null) return;
29813
+ await new Promise((resolve) => {
29814
+ sub.wake = resolve;
29815
+ });
29816
+ continue;
29817
+ }
29818
+ const isLast = sub.closedReason === "ended" && sub.queue.length === 0;
29819
+ sub.delivered += 1;
29820
+ yield {
29821
+ ...next,
29822
+ isLast
29823
+ };
29824
+ if (isLast) return;
29825
+ }
29826
+ }
29827
+ };
29828
+ var DEFAULT_FIRST_UNIT_TIMEOUT_MS = 12e3;
29829
+ /** Heartbeat cadence — ~2 minutes of 4 s fragments. */
29830
+ var FRAGMENT_LOG_EVERY = 30;
29831
+ var Fmp4FragmentChild = class {
29832
+ deps;
29833
+ args;
29834
+ child = null;
29835
+ splitter = new Fmp4BoxSplitter();
29836
+ stopped = false;
29837
+ unitsOut = 0;
29838
+ activeHwAccel = null;
29839
+ constructor(deps, args) {
29840
+ this.deps = deps;
29841
+ this.args = args;
29842
+ }
29843
+ /** Spawn, and resolve once the INIT segment has been cut out of stdout. */
29844
+ async start() {
29845
+ const requested = this.args.invocation.decodeHwAccel;
29846
+ this.activeHwAccel = requested;
29847
+ try {
29848
+ await this.spawnAttempt(requested);
29849
+ return;
29850
+ } catch (err) {
29851
+ if (this.stopped) throw err;
29852
+ if (requested === null || isSoftwareDecode(requested)) throw err;
29853
+ this.deps.logger.warn("fmp4 fragment child: hardware decode produced NO fragment — retrying in SOFTWARE", {
29854
+ tags: { deviceId: this.args.deviceId },
29855
+ meta: {
29856
+ sourceId: this.args.sourceId,
29857
+ decodeHwAccel: requested,
29858
+ error: errMsg$12(err)
29859
+ }
29860
+ });
29861
+ this.killChild();
29862
+ this.splitter = new Fmp4BoxSplitter();
29863
+ this.activeHwAccel = null;
29864
+ await this.spawnAttempt(null);
29865
+ }
29866
+ }
29867
+ /** The backend the child ACTUALLY ran with — `null` for software. */
29868
+ activeDecodeHwAccel() {
29869
+ const value = this.activeHwAccel;
29870
+ return value === null || value === "none" || value === "copy" ? null : value;
29871
+ }
29872
+ /** Kill ffmpeg and end the plane. Idempotent. */
29873
+ async stop() {
29874
+ if (this.stopped) return;
29875
+ this.stopped = true;
29876
+ this.killChild();
29877
+ this.args.plane.end("the fragment child stopped");
29878
+ }
29879
+ spawnAttempt(decodeHwAccel) {
29880
+ const args = buildFfmpegArgs({
29881
+ ...this.args.invocation,
29882
+ decodeHwAccel,
29883
+ sink: {
29884
+ kind: "stdout",
29885
+ container: "mp4",
29886
+ fragmentMs: this.args.fragmentMs
29887
+ }
29888
+ });
29889
+ this.deps.logger.info("fmp4 fragment child: spawning ffmpeg", {
29890
+ tags: { deviceId: this.args.deviceId },
29891
+ meta: {
29892
+ sourceId: this.args.sourceId,
29893
+ fragmentMs: this.args.fragmentMs,
29894
+ decodeHwAccel: decodeHwAccel ?? "software",
29895
+ argv: args.join(" ")
29896
+ }
29897
+ });
29898
+ return new Promise((resolve, reject) => {
29899
+ const child = this.deps.spawnFn(this.deps.ffmpegBinaryPath, args, { stdio: [
29900
+ "ignore",
29901
+ "pipe",
29902
+ "pipe"
29903
+ ] });
29904
+ this.child = child;
29905
+ let settled = false;
29906
+ /**
29907
+ * This attempt FAILED. Set before the kill, because SIGTERM makes the
29908
+ * child exit and that exit must not be reported as a death: the retry —
29909
+ * or the caller's rejection — already owns what happens next. Without it
29910
+ * the timeout path ends the plane the software retry is about to fill,
29911
+ * and the consumer sees a stream that stopped for no reason. A "which
29912
+ * spawn is current" counter does NOT cover this: the retry has not been
29913
+ * spawned when the kill's exit arrives.
29914
+ */
29915
+ let failed = false;
29916
+ /**
29917
+ * This attempt is still the live producer: it has not failed (a failure
29918
+ * hands ownership to the retry, or to the caller's rejection) and nothing
29919
+ * has stopped the child. Those two cover every way an attempt stops being
29920
+ * current — `start` only respawns after a rejection.
29921
+ */
29922
+ const isCurrent = () => !this.stopped && !failed;
29923
+ const timeoutMs = this.deps.firstUnitTimeoutMs ?? DEFAULT_FIRST_UNIT_TIMEOUT_MS;
29924
+ const settle = (fail) => {
29925
+ if (settled) return;
29926
+ settled = true;
29927
+ clearTimeout(timer);
29928
+ if (fail) {
29929
+ failed = true;
29930
+ reject(fail);
29931
+ } else resolve();
29932
+ };
29933
+ const timer = setTimeout(() => {
29934
+ settle(/* @__PURE__ */ new Error(`fmp4 fragment child: no fragment within ${timeoutMs}ms`));
29935
+ this.killChild();
29936
+ }, timeoutMs);
29937
+ timer.unref?.();
29938
+ child.stdout?.on("data", (chunk) => {
29939
+ for (const unit of this.splitter.push(chunk)) {
29940
+ this.unitsOut += 1;
29941
+ this.args.plane.publish(unit);
29942
+ if (unit.kind === "init") {
29943
+ this.deps.logger.info("fmp4 fragment child: INIT segment cut", {
29944
+ tags: { deviceId: this.args.deviceId },
29945
+ meta: {
29946
+ sourceId: this.args.sourceId,
29947
+ bytes: unit.data.length
29948
+ }
29949
+ });
29950
+ settle();
29951
+ } else if (this.unitsOut % FRAGMENT_LOG_EVERY === 0) this.deps.logger.info("fmp4 fragment child: fragments still flowing", {
29952
+ tags: { deviceId: this.args.deviceId },
29953
+ meta: {
29954
+ sourceId: this.args.sourceId,
29955
+ unitsOut: this.unitsOut,
29956
+ bytes: unit.data.length,
29957
+ subscribers: this.args.plane.subscriberCount
29958
+ }
29959
+ });
29960
+ }
29961
+ const fault = this.splitter.fault;
29962
+ if (fault !== null) this.onFault(fault, settled, isCurrent(), settle);
29963
+ });
29964
+ child.stderr?.setEncoding("utf8");
29965
+ child.stderr?.on("data", (line) => {
29966
+ this.deps.logger.debug("fmp4 fragment child ffmpeg", {
29967
+ tags: { deviceId: this.args.deviceId },
29968
+ meta: {
29969
+ sourceId: this.args.sourceId,
29970
+ line: line.trim()
29971
+ }
29972
+ });
29973
+ });
29974
+ child.once("error", (err) => {
29975
+ if (!settled) {
29976
+ settle(err);
29977
+ return;
29978
+ }
29979
+ if (!isCurrent()) return;
29980
+ this.args.plane.end("the fragment child errored");
29981
+ this.deps.onChildExit?.(err);
29982
+ });
29983
+ child.once("exit", (code, signal) => {
29984
+ if (!settled) {
29985
+ settle(/* @__PURE__ */ new Error(`fmp4 fragment child: ffmpeg exited before any fragment (code=${code} signal=${signal})`));
29986
+ return;
29987
+ }
29988
+ if (!isCurrent()) return;
29989
+ const error = /* @__PURE__ */ new Error(`fmp4 fragment child: ffmpeg exited while live (code=${code} signal=${signal})`);
29990
+ this.deps.logger.warn("fmp4 fragment child: ffmpeg exited while live", {
29991
+ tags: { deviceId: this.args.deviceId },
29992
+ meta: {
29993
+ sourceId: this.args.sourceId,
29994
+ code,
29995
+ signal,
29996
+ unitsOut: this.unitsOut
29997
+ }
29998
+ });
29999
+ this.args.plane.end("the fragment child exited");
30000
+ this.deps.onChildExit?.(error);
30001
+ });
30002
+ });
30003
+ }
30004
+ /**
30005
+ * The byte stream stopped being splittable. Not recoverable — the splitter
30006
+ * cannot resynchronise mid-box — so the child is a corpse and every consumer
30007
+ * has to be told, loudly, with the reason.
30008
+ */
30009
+ onFault(reason, wasLive, current, settle) {
30010
+ const error = /* @__PURE__ */ new Error(`fmp4 fragment child: ${reason}`);
30011
+ this.deps.logger.error("fmp4 fragment child: the ffmpeg output stopped parsing as fMP4", {
30012
+ tags: { deviceId: this.args.deviceId },
30013
+ meta: {
30014
+ sourceId: this.args.sourceId,
30015
+ unitsOut: this.unitsOut,
30016
+ interstitial: this.splitter.discardedInterstitialTypes,
30017
+ reason
30018
+ }
30019
+ });
30020
+ this.killChild();
30021
+ settle(error);
30022
+ if (wasLive && current) {
30023
+ this.args.plane.end("the fragment child produced unsplittable output");
30024
+ this.deps.onChildExit?.(error);
30025
+ }
30026
+ }
30027
+ killChild() {
30028
+ const child = this.child;
30029
+ this.child = null;
30030
+ if (child && !child.killed) try {
30031
+ child.kill("SIGTERM");
30032
+ } catch (err) {
30033
+ this.deps.logger.warn("fmp4 fragment child: kill error", {
30034
+ tags: { deviceId: this.args.deviceId },
30035
+ meta: {
30036
+ sourceId: this.args.sourceId,
30037
+ error: errMsg$12(err)
30038
+ }
30039
+ });
30040
+ }
30041
+ }
30042
+ };
28361
30043
  //#endregion
28362
30044
  //#region src/accessory-publisher.ts
28363
30045
  /**
@@ -28471,7 +30153,7 @@ function clearPairingFiles(accessoryUuid, logger) {
28471
30153
  }
28472
30154
  //#endregion
28473
30155
  //#region src/hap-setup-uri.ts
28474
- function errMsg$10(e) {
30156
+ function errMsg$11(e) {
28475
30157
  return e instanceof Error ? e.message : String(e);
28476
30158
  }
28477
30159
  /**
@@ -28498,7 +30180,7 @@ function firstExposedAccessorySetupUri(exposed, logger) {
28498
30180
  try {
28499
30181
  return first.setupURI();
28500
30182
  } catch (err) {
28501
- logger.debug("export-hap: setupURI failed on first exposed accessory", { meta: { error: errMsg$10(err) } });
30183
+ logger.debug("export-hap: setupURI failed on first exposed accessory", { meta: { error: errMsg$11(err) } });
28502
30184
  return;
28503
30185
  }
28504
30186
  }
@@ -28536,13 +30218,19 @@ function hapServiceName(parts, fallback) {
28536
30218
  /**
28537
30219
  * The privacy-mask switch.
28538
30220
  *
28539
- * The camera's own name carries the meaning; "Privacy" only says which of the
28540
- * camera's switches this is. It used to be the WHOLE name, which is why the
28541
- * operator saw a switch that named neither its camera nor, with two cameras
28542
- * exposed, which camera it belonged to.
30221
+ * Just "Privacy". The camera name is NOT prefixed: this service lives inside
30222
+ * the camera's own accessory, iOS already renders it under the camera, and a
30223
+ * round that prefixed it gave the operator "Videocamera ingresso Privacy"
30224
+ * sitting inside a tile titled "Videocamera ingresso".
30225
+ *
30226
+ * The prefix was added for a real reason — two cameras publishing a switch
30227
+ * called "Privacy" — but that was a symptom of the label being the ONLY thing
30228
+ * shown, which stopped being true once `ConfiguredName` made the service
30229
+ * render in its accessory's context. Uniqueness is required WITHIN one
30230
+ * accessory, not across the bridge, and one camera has one privacy switch.
28543
30231
  */
28544
- function privacyServiceName(deviceName) {
28545
- return hapServiceName([deviceName, PRIVACY_SUFFIX], PRIVACY_SUFFIX);
30232
+ function privacyServiceName() {
30233
+ return hapServiceName([PRIVACY_SUFFIX], PRIVACY_SUFFIX);
28546
30234
  }
28547
30235
  /**
28548
30236
  * Deliberately not localised, and deliberately not a translation table.
@@ -28559,35 +30247,60 @@ var PRIVACY_SUFFIX = "Privacy";
28559
30247
  * the parent camera.
28560
30248
  *
28561
30249
  * The child's OWN stored name wins. It is the string the operator typed, in
28562
- * the operator's language, and the previous rule threw it away: `role` was
30250
+ * the operator's language, and an early rule threw it away: `role` was
28563
30251
  * consulted first and title-cased, so every siren on the fleet published as
28564
30252
  * the English word "Siren" no matter what the operator had called it.
28565
30253
  *
28566
- * The parent name is prefixed only when the child's name does not already
28567
- * carry it providers name children both ways ("Sirena" and "Videocamera
28568
- * cucina Sirena") and the result must be one form, not two.
30254
+ * Providers name children both ways "Sirena" and "Videocamera cucina
30255
+ * Sirena". The parent half is now REMOVED rather than added, because the
30256
+ * service is published inside the parent camera's own accessory and iOS
30257
+ * already shows it there. The result must be one form, not two.
28569
30258
  */
28570
30259
  function childServiceName(parentName, child) {
28571
- const own = child.name.trim();
28572
- if (own.length > 0) return mentions(own, parentName) ? hapServiceName([own], parentName) : hapServiceName([parentName, own], own);
28573
- return hapServiceName([parentName, typeof child.role === "string" ? titleCase(child.role) : ""], parentName);
30260
+ const own = withoutParent(child.name.trim(), parentName);
30261
+ if (own.length > 0) return hapServiceName([own], own);
30262
+ return hapServiceName([typeof child.role === "string" ? titleCase(child.role) : ""], CHILD_FALLBACK);
28574
30263
  }
28575
30264
  /**
28576
- * A PTZ action switch.
30265
+ * Last resort for a child that carries neither a name nor a role. Better than
30266
+ * the parent's name, which would publish a service indistinguishable from the
30267
+ * accessory holding it — the exact defect this module keeps being asked to fix.
28577
30268
  *
28578
- * The action labels themselves stay in `ptz-labels.ts` they name a HomeKit
28579
- * control, not a device but the service is still qualified by the camera, so
28580
- * a home with two PTZ cameras does not publish two switches called "Preset
28581
- * ingresso".
30269
+ * English, like the role slugs it stands in for ("Floodlight", "Siren"): the
30270
+ * only strings this module invents are English, and inventing one Italian word
30271
+ * would be a localisation layer that localises nothing.
28582
30272
  */
28583
- function ptzServiceName(deviceName, actionLabel) {
28584
- return hapServiceName([deviceName, actionLabel], actionLabel);
30273
+ var CHILD_FALLBACK = "Accessory";
30274
+ /**
30275
+ * A PTZ action switch: the bare action, "Preset ingresso" / "Pan Left" /
30276
+ * "Autotrack".
30277
+ *
30278
+ * The labels themselves stay in `ptz-labels.ts` — they name a HomeKit control,
30279
+ * not a device. This function exists only to put the operator-typed half of a
30280
+ * preset name through the same sanitisation everything else gets; it no longer
30281
+ * qualifies the label with the camera, because all eight PTZ services live on
30282
+ * that camera's accessory and are unique among themselves.
30283
+ */
30284
+ function ptzServiceName(actionLabel) {
30285
+ return hapServiceName([actionLabel], actionLabel);
28585
30286
  }
28586
- /** Does `name` already contain `parentName` as a whole word? */
28587
- function mentions(name, parentName) {
28588
- const needle = parentName.trim().toLowerCase();
28589
- if (needle.length === 0) return true;
28590
- return name.toLowerCase().includes(needle);
30287
+ /**
30288
+ * Drop `parentName` from the front of `name`.
30289
+ *
30290
+ * A PREFIX only. "Videocamera cucina Sirena" → "Sirena"; "Sirena" is already
30291
+ * bare and untouched. A parent name appearing anywhere else in the child's
30292
+ * name is left alone — cutting from the middle of a string the operator typed
30293
+ * would mangle it, and this function must never make a label WORSE.
30294
+ *
30295
+ * Returns `name` unchanged when stripping would leave nothing: a child the
30296
+ * operator called exactly what the camera is called still needs a label.
30297
+ */
30298
+ function withoutParent(name, parentName) {
30299
+ const needle = parentName.trim();
30300
+ if (needle.length === 0) return name;
30301
+ if (!name.toLowerCase().startsWith(needle.toLowerCase())) return name;
30302
+ const rest = name.slice(needle.length).trim();
30303
+ return rest.length > 0 ? rest : name;
28591
30304
  }
28592
30305
  /**
28593
30306
  * Truncate to the HAP ceiling and shave any leading/trailing character the
@@ -28636,7 +30349,7 @@ async function buildBattery(bctx) {
28636
30349
  const status = await proxy.battery?.getStatus({});
28637
30350
  if (status) applyToService(service, status);
28638
30351
  } catch (err) {
28639
- log.debug("export-hap: battery getStatus hydrate failed (non-fatal)", { meta: { error: errMsg$9(err) } });
30352
+ log.debug("export-hap: battery getStatus hydrate failed (non-fatal)", { meta: { error: errMsg$10(err) } });
28640
30353
  }
28641
30354
  const unsubscribes = [];
28642
30355
  if (proxy.state.battery) {
@@ -28662,7 +30375,7 @@ function applyToService(service, status) {
28662
30375
  const lowBattery = pct <= LOW_BATTERY_THRESHOLD_PCT ? Characteristic.StatusLowBattery.BATTERY_LEVEL_LOW : Characteristic.StatusLowBattery.BATTERY_LEVEL_NORMAL;
28663
30376
  service.updateCharacteristic(Characteristic.StatusLowBattery, lowBattery);
28664
30377
  }
28665
- function errMsg$9(err) {
30378
+ function errMsg$10(err) {
28666
30379
  return err instanceof Error ? err.message : String(err);
28667
30380
  }
28668
30381
  //#endregion
@@ -39158,9 +40871,121 @@ function ingestDecryptedRtcp(plaintext, tally) {
39158
40871
  failure: null
39159
40872
  };
39160
40873
  }
40874
+ function classifyConnection(input) {
40875
+ if (input.negotiatedWidth < 640) return "watch";
40876
+ if (input.audioPacketTimeMs >= 60) return "remote";
40877
+ return input.viaHomeHub ? "home-hub" : "local";
40878
+ }
40879
+ /**
40880
+ * The slot each class asks for.
40881
+ *
40882
+ * ## `local` takes the camera's best stream — settled by measurement
40883
+ *
40884
+ * This function was pinned to `low` for EVERY class by one number. On 615/high,
40885
+ * 3840x2160 pass-through:
40886
+ *
40887
+ * durationMs=30820 videoPacketsForwarded=93 videoKeyframes=1
40888
+ * audioPacketsForwarded=1497 lost=0
40889
+ *
40890
+ * Three video datagrams a second, one key frame in half a minute, while the
40891
+ * AUDIO leg of the *same* ffmpeg ran perfectly. It read as "our path cannot
40892
+ * carry a high-bitrate stream".
40893
+ *
40894
+ * **It was not HomeKit, not 4K and not SRTP. The loopback UDP socket ffmpeg
40895
+ * writes its RTP into had no `SO_RCVBUF` at all** (2026-08-07). It ran on
40896
+ * `net.core.rmem_default`, 212 992 B — about a fifth of one 4K IDR, which
40897
+ * arrives as ~750 datagrams at `pkt_size=1378` in a single burst. The kernel
40898
+ * discarded the overflow, and a datagram dropped there never reaches a
40899
+ * `message` handler, so it lowered the forwarded count exactly like a packet
40900
+ * ffmpeg never wrote and the controller reported no loss for it either. Audio,
40901
+ * a few hundred bytes every 20 ms, never filled the buffer. That is the whole
40902
+ * asymmetry. See `stream-socket-buffer.ts`.
40903
+ *
40904
+ * With an 8 MiB buffer (granted — this hub's `net.core.rmem_max` is 16 MiB),
40905
+ * the same camera and the same slot, session
40906
+ * `12308100-7dbe-4ad1-b277-1f046ba54ec2` on 2026-08-07:
40907
+ *
40908
+ * selectedProfile=high transcode=false slotMeasuredKbps=5097
40909
+ * videoPacketsForwarded=6512 durationMs=9867 (~660/s, was ~3/s)
40910
+ * msToFirstKeyframe=858 deliveredFps=24 worstFractionLostPct=0.4
40911
+ * videoLoopRcvbufBytes=16777216 clamped=false
40912
+ *
40913
+ * Operator: loaded instantly, and visibly not the low stream. A 220x increase
40914
+ * in delivered packet rate from sizing one socket.
40915
+ *
40916
+ * ## Why the remote classes stay `low`
40917
+ *
40918
+ * Not caution left over from the freeze — a different, UNMEASURED question.
40919
+ * `watch`, `remote` and `home-hub` all send video across a link whose budget
40920
+ * nothing here has measured; the buffer fix says something about a loopback hop
40921
+ * inside one host and nothing whatsoever about a WAN. 4K pass-through at ~5 Mbps
40922
+ * to a phone on LTE is a decision that needs its own evidence, and `watch` has
40923
+ * a panel under 640 px wide that could not use the pixels anyway. Raise these
40924
+ * only with a measurement of the remote link, not by analogy with this one.
40925
+ *
40926
+ * `mid` remains excluded from every class, unrelated to all of the above: it is
40927
+ * a 10 fps stream on this fleet and it has never rendered under any combination
40928
+ * tried.
40929
+ */
40930
+ function slotForConnection(connection) {
40931
+ switch (connection) {
40932
+ case "watch": return "low";
40933
+ case "remote": return "low";
40934
+ case "home-hub": return "low";
40935
+ case "local": return "high";
40936
+ }
40937
+ }
39161
40938
  //#endregion
39162
40939
  //#region src/mappers/builders/stream-bitrate.ts
39163
40940
  /**
40941
+ * Send a stream that FITS the rate HomeKit negotiated. (R5)
40942
+ *
40943
+ * The controller's own Receiver Reports, read on the live hub on 2026-08-06,
40944
+ * closed a year of guessing: one 19.27 s session on `615/mid` forwarded 737
40945
+ * video packets at `mtu=1378` — roughly **421 kbps** — against a negotiated
40946
+ * `max_bit_rate` of **299**, and iOS reported losing **450 of those 737
40947
+ * packets (61 %)**, worst fraction lost 51.2 %, peak jitter 3.03 s. Under
40948
+ * `-c:v copy` the accessory has no lever at all: it forwards whatever the
40949
+ * camera's encoder produces, at whatever cadence it produces it.
40950
+ *
40951
+ * So the fix has two halves, and this module owns both:
40952
+ *
40953
+ * 1. **Choose a slot that fits.** Among the slots that can be passed through
40954
+ * (H.264) and whose rate is known to be within budget, the existing
40955
+ * resolution-closest picker decides — so the "which slot serves which
40956
+ * resolution" opinion stays single, exactly as D51 requires.
40957
+ * 2. **Transcode only when none does**, with a real cap
40958
+ * (`-b:v` / `-maxrate` / `-bufsize`) at the negotiated rate.
40959
+ *
40960
+ * ## Where the authoritative rate comes from, and why it is NOT the obvious one
40961
+ *
40962
+ * `webrtcSession.listStreams` reports a `bitrateKbps` per slot and it is a
40963
+ * **measured flow rate**, which is meaningless for a slot nobody is consuming.
40964
+ * Live on 2026-08-06 it reported `mid = 9 kbps` for the very slot that had just
40965
+ * delivered ~421 kbps, `low = 5 kbps`, and `high = 5441 kbps` (high was being
40966
+ * consumed, hence plausible). Selecting on that reading would admit every slot.
40967
+ *
40968
+ * The authority is therefore the camera's **configured** encoder rate, from
40969
+ * `streamParams.getStatus` — `main` / `sub` / `ext`, each carrying the
40970
+ * `bitrate` the operator (or the vendor default) set. On 615 that is
40971
+ * `main 8192`, `sub 2048`, `ext 2048` kbps. It is mapped onto a profile slot
40972
+ * through the slot's assigned cam-stream, matched on resolution and frame
40973
+ * rate; an ambiguous or absent match is reported as **unknown**, never as a
40974
+ * number.
40975
+ *
40976
+ * This inverts D51's ordering — there, `measured` outranks `published` — and
40977
+ * the inversion is deliberate:
40978
+ *
40979
+ * - a frame rate is a stable property of the source and a measurement of it
40980
+ * is the *best* evidence;
40981
+ * - a bitrate under VBR is an envelope. A measurement is a **lower bound**
40982
+ * on it, and a lower bound can prove a slot does NOT fit but can never
40983
+ * prove that it does.
40984
+ *
40985
+ * So `measured` is kept, and used only in the direction it is sound in.
40986
+ * Everything here is pure; the cap reads live in `stream-bitrate-probe.ts`.
40987
+ */
40988
+ /**
39164
40989
  * Fraction of the negotiated ceiling we actually aim the encoder at.
39165
40990
  *
39166
40991
  * `max_bit_rate` is what the controller budgeted for the stream; what crosses
@@ -39253,7 +41078,8 @@ function classifyBitrateFit(evidence, budgetKbps) {
39253
41078
  function selectStreamForBudget(input) {
39254
41079
  const budgetKbps = budgetForNegotiatedRate(input.negotiatedMaxBitrateKbps);
39255
41080
  const notes = fitNotes(input.entries, input.bitrates, budgetKbps);
39256
- const fallback = pickPreferredRtspEntry(input.entries, input.pref, input.deviceId, { targetResolution: input.targetResolution });
41081
+ const effectivePref = input.pref === "auto" ? slotForConnection(input.connection) : input.pref;
41082
+ const fallback = pickPreferredRtspEntry(input.entries, effectivePref, input.deviceId, { targetResolution: input.targetResolution });
39257
41083
  if (fallback === null) return null;
39258
41084
  const fallbackProfile = toCamProfile$1(fallback.profileId);
39259
41085
  if (budgetKbps === null) {
@@ -39278,7 +41104,7 @@ function selectStreamForBudget(input) {
39278
41104
  return classifyBitrateFit(input.bitrates.get(entry.profile), budgetKbps) === "fits";
39279
41105
  });
39280
41106
  if (affordable.length > 0) {
39281
- const picked = pickPreferredRtspEntry(affordable, input.pref, input.deviceId, { targetResolution: input.targetResolution });
41107
+ const picked = pickPreferredRtspEntry(affordable, effectivePref, input.deviceId, { targetResolution: input.targetResolution });
39282
41108
  if (picked !== null) return {
39283
41109
  kind: "copy",
39284
41110
  reason: "source-fits-budget",
@@ -39323,16 +41149,15 @@ function withSlotCodecs(entries, slots) {
39323
41149
  });
39324
41150
  }
39325
41151
  /**
39326
- * The rate we will actually deliver: never more than the controller asked for,
39327
- * and never more than the source produces. `-r` above the source rate makes
39328
- * ffmpeg DUPLICATE frames, which spends the budget on nothing.
39329
- */
39330
- function deliverableFps(negotiatedFps, slotFps) {
39331
- if (slotFps === null || !Number.isFinite(slotFps) || slotFps <= 0) return negotiatedFps;
39332
- return Math.min(negotiatedFps, Math.floor(slotFps));
39333
- }
39334
- /**
39335
- * The ffmpeg video-output arguments.
41152
+ * The video half of the ffmpeg plan, in the SHARED vocabulary
41153
+ * (`@camstack/types` `ffmpeg/invocation.ts`). This function used to emit
41154
+ * arguments; it now describes them, and `buildFfmpegArgs` emits every one — the
41155
+ * repo keeps exactly one argv builder, and HomeKit stopped being an exception
41156
+ * to that (D67, `scripts/check-ffmpeg-primitive.ts` Rule 1).
41157
+ *
41158
+ * Nothing about the RESULT changed except the rescale spelling: `-s WxH` became
41159
+ * `-vf scale=W:H`. Equivalent for a plain rescale, and worth knowing because
41160
+ * the two are NOT interchangeable once another `-vf` is in play.
39336
41161
  *
39337
41162
  * Pass-through carries `-bsf:v dump_extra` so every IDR inlines its own
39338
41163
  * SPS/PPS — sources that publish parameter sets only in the RTSP SDP hand iOS
@@ -39343,54 +41168,42 @@ function deliverableFps(negotiatedFps, slotFps) {
39343
41168
  * **one-second** `-bufsize` bounds any one-second window at the negotiated
39344
41169
  * rate, which is also the only lever available on the 3.03 s peak jitter — the
39345
41170
  * VBV window is what forces x264 to size a key frame to fit rather than
39346
- * emitting it as one tight burst.
39347
- */
39348
- /**
39349
- * Seconds between forced IDRs on the transcode path.
39350
- *
39351
- * A join can only start decoding at a key frame, so this is the worst-case
39352
- * wait a controller pays before the first pictureand the bound the RTSP
39353
- * join-burst withhold falls back on when it declines to replay a wide GOP.
39354
- */
39355
- var KEYFRAME_INTERVAL_SEC = 4;
39356
- function buildVideoEncodeArgs(input) {
39357
- if (!input.transcode) return [
39358
- "-c:v",
39359
- "copy",
39360
- "-bsf:v",
39361
- "dump_extra"
39362
- ];
39363
- const rate = input.budgetKbps === null ? [] : [
39364
- "-b:v",
39365
- `${input.budgetKbps}k`,
39366
- "-maxrate",
39367
- `${input.budgetKbps}k`,
39368
- "-bufsize",
39369
- `${input.budgetKbps}k`
39370
- ];
39371
- return [
39372
- "-c:v",
39373
- "libx264",
39374
- "-preset",
39375
- "ultrafast",
39376
- "-tune",
39377
- "zerolatency",
39378
- "-pix_fmt",
39379
- "yuv420p",
39380
- "-r",
39381
- String(input.fps),
39382
- "-s",
39383
- `${input.width}x${input.height}`,
39384
- "-g",
39385
- String(Math.max(1, Math.round(input.fps * KEYFRAME_INTERVAL_SEC))),
39386
- ...rate,
39387
- "-profile:v",
39388
- "baseline",
39389
- "-level",
39390
- "3.1",
39391
- "-bsf:v",
39392
- "dump_extra"
39393
- ];
41171
+ * emitting it as one tight burst. That window is {@link RATE_CONTROL_TIGHT},
41172
+ * the shared constant whose whole reason to exist is HomeKit's per-second
41173
+ * budget; the browser and Echo use the relaxed two-second one.
41174
+ *
41175
+ * **The encoder stays `libx264`, deliberately.** `h264_vaapi` / `h264_qsv`
41176
+ * carry their own rate-control model, do not accept `-profile:v baseline`, and
41177
+ * emit parameter sets on their own schedule rather than x264's which puts the
41178
+ * two load-bearing flags below back in play, with no hardware here to prove
41179
+ * they still hold. Hardware DECODE is where the measured cost is.
41180
+ */
41181
+ function buildVideoPlan(input) {
41182
+ if (!input.transcode) return {
41183
+ kind: "copy",
41184
+ bitstreamFilter: "dump_extra"
41185
+ };
41186
+ return {
41187
+ kind: "encode",
41188
+ encoder: "libx264",
41189
+ scale: {
41190
+ mode: "exact",
41191
+ width: input.width,
41192
+ height: input.height
41193
+ },
41194
+ preset: "ultrafast",
41195
+ tune: "zerolatency",
41196
+ profile: "baseline",
41197
+ level: "3.1",
41198
+ pixelFormat: "yuv420p",
41199
+ fps: input.fps,
41200
+ gopFrames: Math.max(1, Math.round(input.fps * 4)),
41201
+ ...input.budgetKbps === null ? {} : {
41202
+ bitrateKbps: input.budgetKbps,
41203
+ rateControl: RATE_CONTROL_TIGHT
41204
+ },
41205
+ bitstreamFilter: "dump_extra"
41206
+ };
39394
41207
  }
39395
41208
  /** Compact `mid=2048pub/9meas:over-budget` rendering for a single log field. */
39396
41209
  function formatFitNotes(notes) {
@@ -39470,23 +41283,129 @@ function toCamProfile$1(profileId) {
39470
41283
  return CAM_PROFILES$1.find((p) => p === profileId) ?? null;
39471
41284
  }
39472
41285
  //#endregion
41286
+ //#region src/mappers/builders/deadline.ts
41287
+ /**
41288
+ * Bound a piece of optional work in time.
41289
+ *
41290
+ * HomeKit answers `Selected RTP Stream Configuration` inside a write handler
41291
+ * hap-nodejs expects back quickly, and the start path behind it makes six
41292
+ * sequential cross-process cap calls into a stream-broker that regularly
41293
+ * freezes for two to three seconds at a time. Measured on the live hub: the
41294
+ * controller negotiated at :11, gave up at 9.1 s, and the bitrate fit resolved
41295
+ * at :32 — twenty-one seconds — with the start then failing on `Not running`
41296
+ * because the session it was preparing no longer existed.
41297
+ *
41298
+ * The evidence those calls gather is genuinely optional: an absent reading
41299
+ * classifies as `unknown`, and the tolerated branch still picks a slot. So the
41300
+ * right trade under load is to answer with less evidence rather than late, and
41301
+ * this makes that trade explicit at each call site instead of leaving it to
41302
+ * whatever the broker's latency happens to be.
41303
+ *
41304
+ * A late failure from work we stopped waiting on is swallowed on purpose: the
41305
+ * probe keeps running after the deadline fires, and an unhandled rejection
41306
+ * from an abandoned probe would take the process down over a reading nobody is
41307
+ * using any more.
41308
+ */
41309
+ var TIMED_OUT = Symbol("deadline:timed-out");
41310
+ var FAILED = Symbol("deadline:failed");
41311
+ async function withDeadline(work, ms, fallback, onTimeout) {
41312
+ let timer;
41313
+ const guard = new Promise((resolve) => {
41314
+ timer = setTimeout(() => resolve(TIMED_OUT), ms);
41315
+ });
41316
+ try {
41317
+ const settled = await Promise.race([work.catch(() => FAILED), guard]);
41318
+ if (settled === TIMED_OUT) {
41319
+ onTimeout();
41320
+ return fallback;
41321
+ }
41322
+ return settled === FAILED ? fallback : settled;
41323
+ } finally {
41324
+ if (timer !== void 0) clearTimeout(timer);
41325
+ work.catch(() => void 0);
41326
+ }
41327
+ }
41328
+ //#endregion
39473
41329
  //#region src/mappers/builders/stream-bitrate-probe.ts
39474
41330
  /**
39475
- * Resolve every profile slot's rate. Never throws: an unresolvable slot ends
39476
- * up `unknown`, which the selector treats as "cannot prove it fits" and
39477
- * therefore transcodes — the safe direction on the wire.
41331
+ * Total budget for the rate evidence, not per call the point is to bound
41332
+ * what the CONTROLLER waits for, and it waits for the sum.
41333
+ */
41334
+ var BITRATE_EVIDENCE_BUDGET_MS = 1500;
41335
+ var NO_EVIDENCE = {
41336
+ camStreams: null,
41337
+ streamParams: null,
41338
+ choices: null
41339
+ };
41340
+ /**
41341
+ * The last evidence that actually arrived, per device.
41342
+ *
41343
+ * Falling back to NO evidence on a slow read was not a neutral degradation: it
41344
+ * changed WHICH SLOT the picker chose. Measured on 615 within forty seconds,
41345
+ * same camera, same negotiated 1280x720:
41346
+ *
41347
+ * 10:22:11 mid 10 fps
41348
+ * 10:22:17 low 24 fps
41349
+ * 10:22:26 mid 10 fps
41350
+ * 10:22:50 low 24 fps
41351
+ *
41352
+ * With evidence, `low` classifies as a fit and wins; without it every slot is
41353
+ * `unknown` and the fallback takes `mid`. So the stream a controller received
41354
+ * depended on whether a cap read beat a 1500 ms timer — a coin flip, and one
41355
+ * that hands iOS a different profile on each retry.
41356
+ *
41357
+ * A rate is a property of the camera's encoder configuration, which changes
41358
+ * when an operator changes it and not otherwise. Yesterday's reading is a far
41359
+ * better answer than no reading, and the ONE case that must still see fresh
41360
+ * numbers — the operator lowering a substream — is a deliberate act followed
41361
+ * by a new session, by which time the background read has long landed.
41362
+ */
41363
+ var lastGoodEvidence = /* @__PURE__ */ new Map();
41364
+ /**
41365
+ * Resolve every profile slot's rate. Never throws and never outlives its
41366
+ * budget: a slow read falls back to this device's last good reading, and only
41367
+ * a device that has never answered at all ends up `unknown`.
39478
41368
  */
39479
41369
  async function probeProfileBitrates(input) {
39480
- const { proxy } = input.bctx;
39481
- const camStreams = await probe$1(() => proxy.cameraStreams?.getCameraStreams({}), "cameraStreams.getCameraStreams", input.log);
39482
- const streamParams = await probe$1(() => proxy.streamParams?.getStatus({}), "streamParams.getStatus", input.log);
39483
- const choices = await probe$1(() => proxy.webrtcSession?.listStreams({}), "webrtcSession.listStreams", input.log);
41370
+ const deviceId = input.bctx.numericDeviceId;
41371
+ const evidence = await gatherRateEvidence(input.bctx.proxy, input.log, lastGoodEvidence.get(deviceId));
41372
+ if (evidence.camStreams !== null || evidence.streamParams !== null) lastGoodEvidence.set(deviceId, evidence);
39484
41373
  return resolveProfileBitrates({
39485
41374
  slots: input.slots,
39486
- camStreams: camStreams ?? [],
41375
+ camStreams: evidence.camStreams ?? [],
41376
+ streamParams: evidence.streamParams,
41377
+ choices: evidence.choices ?? []
41378
+ });
41379
+ }
41380
+ /**
41381
+ * Issue the three reads CONCURRENTLY under one budget.
41382
+ *
41383
+ * Exported so the concurrency and the budget can be asserted directly: run in
41384
+ * sequence these latencies add, and adding them is what cost a session.
41385
+ */
41386
+ async function gatherRateEvidence(proxy, log, lastGood) {
41387
+ const startedAt = Date.now();
41388
+ const evidence = await withDeadline(Promise.all([
41389
+ probe$1(() => proxy.cameraStreams?.getCameraStreams({}), "cameraStreams.getCameraStreams", log),
41390
+ probe$1(() => proxy.streamParams?.getStatus({}), "streamParams.getStatus", log),
41391
+ probe$1(() => proxy.webrtcSession?.listStreams({}), "webrtcSession.listStreams", log)
41392
+ ]).then(([camStreams, streamParams, choices]) => ({
41393
+ camStreams,
39487
41394
  streamParams,
39488
- choices: choices ?? []
41395
+ choices
41396
+ })), BITRATE_EVIDENCE_BUDGET_MS, lastGood ?? NO_EVIDENCE, () => {
41397
+ log.warn("export-hap: rate evidence ABANDONED on its budget", { meta: {
41398
+ budgetMs: BITRATE_EVIDENCE_BUDGET_MS,
41399
+ fellBackTo: lastGood === void 0 ? "no-evidence" : "last-good",
41400
+ 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"
41401
+ } });
39489
41402
  });
41403
+ const elapsedMs = Date.now() - startedAt;
41404
+ if (elapsedMs > 1500 / 2) log.info("export-hap: rate evidence was slow", { meta: {
41405
+ elapsedMs,
41406
+ budgetMs: BITRATE_EVIDENCE_BUDGET_MS
41407
+ } });
41408
+ return evidence;
39490
41409
  }
39491
41410
  async function probe$1(call, label, log) {
39492
41411
  try {
@@ -39504,61 +41423,138 @@ async function probe$1(call, label, log) {
39504
41423
  return null;
39505
41424
  }
39506
41425
  }
41426
+ //#endregion
41427
+ //#region src/mappers/builders/stream-ffmpeg-args.ts
41428
+ /**
41429
+ * The ffmpeg PLAN for one HomeKit streaming session.
41430
+ *
41431
+ * This file used to assemble the argument vector by hand. It no longer emits a
41432
+ * single argument: it describes the session as an {@link FfmpegInvocation} and
41433
+ * `buildFfmpegArgs` (`@camstack/types` `ffmpeg/invocation.ts`) emits every one.
41434
+ * The repo keeps exactly ONE argv builder — `scripts/check-ffmpeg-primitive.ts`
41435
+ * Rule 1 refuses a second, and HomeKit was the last exception (D67).
41436
+ *
41437
+ * ## What moved behind the primitive, and what stayed here
41438
+ *
41439
+ * MOVED — everything that describes an ENCODE, because it is the same job every
41440
+ * other live egress does and the repo had five disagreeing copies of it: the
41441
+ * encoder, preset, tune, profile, level, pixel format, rate, GOP, the tight VBV
41442
+ * window ({@link RATE_CONTROL_TIGHT}), the bitstream filter, and the Opus block
41443
+ * ({@link HAP_AUDIO_BASE}).
41444
+ *
41445
+ * STAYED — everything that is a HAP PROTOCOL fact and belongs to no other
41446
+ * consumer: the payload types, the SSRCs (and their signed-int32 coercion), the
41447
+ * MTU baked into each `rtp://…?pkt_size=` target, the loopback ports the
41448
+ * JS-side SRTP encrypt reads from, and the negotiated audio sample rate and
41449
+ * packet time.
41450
+ *
41451
+ * ## The two flags this file exists to protect
41452
+ *
41453
+ * `-g` and `-bsf:v dump_extra` were two of the four causes of the year-long
41454
+ * failure, and both live in the encode plan now. They are asserted by token
41455
+ * AND by position in `__tests__/stream-ffmpeg-argv.spec.ts`, on both the copy
41456
+ * and the encode branch, so the move behind the primitive cannot quietly drop
41457
+ * either. Every other comment below records something learned the expensive
41458
+ * way; deleting one loses the reason a flag is there.
41459
+ */
41460
+ /**
41461
+ * Opus encoder targets — kept low because:
41462
+ * - Camera audio is overwhelmingly speech / ambient noise; 24 kbps mono
41463
+ * is the published "fullband speech" sweet spot for libopus (well
41464
+ * above the 20 kbps "wideband speech" floor).
41465
+ * - HAP audio is one-shot live (no buffering on the controller side),
41466
+ * so under-shooting the bitrate is cheaper than over-shooting it and
41467
+ * hitting jitter.
41468
+ * - Mono / low-delay profile matches Apple Home's published Opus decoder
41469
+ * expectations for camera accessories.
41470
+ *
41471
+ * The numbers themselves live in `@camstack/types` `ffmpeg/encode-defaults.ts`
41472
+ * now, alongside every other live-egress constant, so the five sets that used
41473
+ * to disagree about Opus channel count can be diffed in one place. Re-exported
41474
+ * here because the session telemetry reports the bitrate it dialled.
41475
+ */
41476
+ var OPUS_BITRATE_KBPS = 24;
41477
+ /**
41478
+ * The Opus plane, per session.
41479
+ *
41480
+ * Re-encoded regardless of source codec: the source pool is a mix of
41481
+ * PCM_MULAW, PCM_ALAW, G.711 and AAC depending on driver, and Apple Home
41482
+ * expects Opus on the wire.
41483
+ *
41484
+ * `sampleRateHz` and `frameDurationMs` are NEGOTIATED — the controller picks
41485
+ * them — which is why the shared {@link HAP_AUDIO_BASE} leaves both out and
41486
+ * they are filled in here.
41487
+ *
41488
+ * CRITICAL on the sample rate: encode at the rate iOS asked for, never a
41489
+ * constant. iOS's `AudioStreamingSamplerate` enum surfaces as 8 / 16 / 24 kHz;
41490
+ * encoding at 24 when iOS asked for 16 produces RTP timestamps stepping by 480
41491
+ * samples/packet against a clock expecting 320 — the SRTP frames decrypt
41492
+ * cleanly but the speaker stays mute, because the timestamps slide out of the
41493
+ * AV-sync window before the first Opus frame renders. The same request value
41494
+ * drives `audioIntervalScale` in the re-stamping pass, so the two MUST come
41495
+ * from one source.
41496
+ *
41497
+ * On the frame duration: libopus emits exactly one RTP packet per Opus frame at
41498
+ * that duration, and matching HAP's `packet_time` (20 ms on LAN, 30/40/60 on
41499
+ * LTE) is what keeps the 1:1 frame↔packet mapping the controller expects.
41500
+ */
41501
+ function audioPlan(input) {
41502
+ return {
41503
+ ...HAP_AUDIO_BASE,
41504
+ sampleRateHz: input.audioSampleRateKhz * 1e3,
41505
+ frameDurationMs: input.audioPacketTimeMs,
41506
+ vbvBufferKbits: 96
41507
+ };
41508
+ }
39507
41509
  /**
39508
41510
  * Two outputs from one input: video SRTP and audio SRTP, one process, one
39509
- * lifetime, one kill signal. ffmpeg-internal stream selection (`-vn` / `-an`
39510
- * plus per-output codec args) keeps both flowing through it.
41511
+ * lifetime, one kill signal. The shared builder's `rtp-outputs` sink maps each
41512
+ * plane explicitly (`-an -map 0:v:0` / `-vn -map 0:a:0?`) so ffmpeg never
41513
+ * guesses which stream belongs where, and `0:a:0?` makes the audio optional so
41514
+ * a source with no microphone skips it instead of failing the invocation.
39511
41515
  */
39512
- function buildSessionFfmpegArgs(input) {
39513
- return [
39514
- "-hide_banner",
39515
- "-loglevel",
39516
- "warning",
39517
- ...input.decodeArgs,
39518
- "-rtsp_transport",
39519
- "tcp",
39520
- "-i",
39521
- input.rtspUrl,
39522
- "-an",
39523
- "-map",
39524
- "0:v:0",
39525
- ...input.videoArgs,
39526
- "-payload_type",
39527
- String(input.videoPayloadType),
39528
- "-ssrc",
39529
- String(input.videoSsrcSigned),
39530
- "-f",
39531
- "rtp",
39532
- input.videoTarget,
39533
- "-vn",
39534
- "-map",
39535
- "0:a:0?",
39536
- "-af",
39537
- "aresample=async=1000:first_pts=0",
39538
- "-c:a",
39539
- "libopus",
39540
- "-application",
39541
- "lowdelay",
39542
- "-frame_duration",
39543
- String(input.audioPacketTimeMs),
39544
- "-flags",
39545
- "+global_header",
39546
- "-ar",
39547
- String(input.audioSampleRateKhz * 1e3),
39548
- "-b:a",
39549
- `24k`,
39550
- "-bufsize",
39551
- `96k`,
39552
- "-ac",
39553
- String(1),
39554
- "-payload_type",
39555
- String(input.audioPayloadType),
39556
- "-ssrc",
39557
- String(input.audioSsrcSigned),
39558
- "-f",
39559
- "rtp",
39560
- input.audioTarget
39561
- ];
41516
+ /**
41517
+ * How long ffmpeg may inspect the broker's restream before emitting.
41518
+ *
41519
+ * Not zero. A zero-length probe makes ffmpeg trust the SDP completely, and an
41520
+ * RTSP source that announces a track it then never sends would leave the
41521
+ * mapping wrong with no way to notice. 200 ms and 64 KB is far below the
41522
+ * shortest key-frame interval on this fleet while still letting the demuxer
41523
+ * see real packets — enough to be honest, short enough that nobody watches it.
41524
+ */
41525
+ var HAP_INPUT_PROBE = {
41526
+ analyzeDurationUs: 2e5,
41527
+ probeSizeBytes: 64 * 1024
41528
+ };
41529
+ function buildSessionInvocation(input) {
41530
+ return {
41531
+ logLevel: "warning",
41532
+ decodeHwAccel: input.decode.hwaccel,
41533
+ input: {
41534
+ url: input.rtspUrl,
41535
+ rtspTransport: "tcp",
41536
+ analyzeDurationUs: HAP_INPUT_PROBE.analyzeDurationUs,
41537
+ probeSizeBytes: HAP_INPUT_PROBE.probeSizeBytes,
41538
+ ...input.decode.extraInputArgs.length > 0 ? { extraArgs: input.decode.extraInputArgs } : {}
41539
+ },
41540
+ video: input.video,
41541
+ audio: audioPlan(input),
41542
+ threadCount: 0,
41543
+ outputArgs: [],
41544
+ sink: {
41545
+ kind: "rtp-outputs",
41546
+ video: {
41547
+ url: input.videoTarget,
41548
+ payloadType: input.videoPayloadType,
41549
+ ssrc: input.videoSsrcSigned
41550
+ },
41551
+ audio: {
41552
+ url: input.audioTarget,
41553
+ payloadType: input.audioPayloadType,
41554
+ ssrc: input.audioSsrcSigned
41555
+ }
41556
+ }
41557
+ };
39562
41558
  }
39563
41559
  /**
39564
41560
  * The resolutions we offer, before rates are attached. Same list the delegate
@@ -39698,6 +41694,84 @@ var CAM_PROFILES = [
39698
41694
  function toCamProfile(profileId) {
39699
41695
  return CAM_PROFILES.find((p) => p === profileId) ?? null;
39700
41696
  }
41697
+ //#endregion
41698
+ //#region src/mappers/builders/h264-idr.ts
41699
+ /**
41700
+ * Does this RTP packet carry the start of an H.264 IDR?
41701
+ *
41702
+ * A pass-through session cannot manufacture a key frame on demand — it can
41703
+ * only forward the one the camera decides to emit. So the number that decides
41704
+ * whether a controller sees a picture or a loader is *how long it waited for
41705
+ * the first IDR*, and until now nothing measured it: a session could report
41706
+ * a thousand packets forwarded, zero loss, and a blank screen, with no field
41707
+ * distinguishing "the stream is broken" from "the next key frame is 20
41708
+ * seconds away".
41709
+ *
41710
+ * That is the whole reason this exists, so it is deliberately narrow: a
41711
+ * boolean per packet, no state, no allocation, and it never throws. It runs on
41712
+ * every forwarded video packet, and a parser that throws on a malformed packet
41713
+ * would take the media path down with it.
41714
+ */
41715
+ /** NAL unit type carrying a coded slice of an IDR picture (RFC 6184 §5.2). */
41716
+ var NAL_TYPE_IDR = 5;
41717
+ /** Single-time aggregation packet — several NALs in one RTP payload. */
41718
+ var NAL_TYPE_STAP_A = 24;
41719
+ /** Fragmentation units: one NAL spread over several RTP payloads. */
41720
+ var NAL_TYPE_FU_A = 28;
41721
+ var NAL_TYPE_FU_B = 29;
41722
+ var RTP_MIN_HEADER_BYTES = 12;
41723
+ var NAL_TYPE_MASK = 31;
41724
+ /** FU header start bit — set only on the FIRST fragment of a fragmented NAL. */
41725
+ var FU_START_BIT = 128;
41726
+ function rtpPacketCarriesIdr(packet) {
41727
+ const payloadStart = rtpPayloadOffset(packet);
41728
+ if (payloadStart === null) return false;
41729
+ const firstPayloadByte = packet[payloadStart];
41730
+ if (firstPayloadByte === void 0) return false;
41731
+ const nalType = firstPayloadByte & NAL_TYPE_MASK;
41732
+ if (nalType === NAL_TYPE_FU_A || nalType === NAL_TYPE_FU_B) {
41733
+ const fuHeader = packet[payloadStart + 1];
41734
+ if (fuHeader === void 0) return false;
41735
+ if ((fuHeader & FU_START_BIT) === 0) return false;
41736
+ return (fuHeader & NAL_TYPE_MASK) === NAL_TYPE_IDR;
41737
+ }
41738
+ if (nalType === NAL_TYPE_STAP_A) return stapContainsIdr(packet, payloadStart + 1);
41739
+ return nalType === NAL_TYPE_IDR;
41740
+ }
41741
+ /**
41742
+ * Byte offset of the RTP payload, or `null` when the packet is too short to
41743
+ * hold one. The variable-length parts are what make this worth a function:
41744
+ * a fixed offset of 12 is right for every packet ffmpeg emits today and wrong
41745
+ * the moment one carries a CSRC list or a header extension.
41746
+ */
41747
+ function rtpPayloadOffset(packet) {
41748
+ if (packet.length <= RTP_MIN_HEADER_BYTES) return null;
41749
+ const flags = packet[0];
41750
+ if (flags === void 0) return null;
41751
+ const csrcCount = flags & 15;
41752
+ const hasExtension = (flags & 16) !== 0;
41753
+ let offset = RTP_MIN_HEADER_BYTES + csrcCount * 4;
41754
+ if (hasExtension) {
41755
+ if (offset + 4 > packet.length) return null;
41756
+ const words = packet.readUInt16BE(offset + 2);
41757
+ offset += 4 + words * 4;
41758
+ }
41759
+ return offset < packet.length ? offset : null;
41760
+ }
41761
+ /** Walk a STAP-A's `[size][nal]` pairs looking for an IDR. */
41762
+ function stapContainsIdr(packet, start) {
41763
+ let offset = start;
41764
+ while (offset + 2 <= packet.length) {
41765
+ const size = packet.readUInt16BE(offset);
41766
+ offset += 2;
41767
+ if (size === 0 || offset + size > packet.length) return false;
41768
+ const nalHeader = packet[offset];
41769
+ if (nalHeader === void 0) return false;
41770
+ if ((nalHeader & NAL_TYPE_MASK) === NAL_TYPE_IDR) return true;
41771
+ offset += size;
41772
+ }
41773
+ return false;
41774
+ }
39701
41775
  /**
39702
41776
  * How long after spawn an exit still counts as "hardware init failed".
39703
41777
  *
@@ -39762,34 +41836,65 @@ function software(reason) {
39762
41836
  return {
39763
41837
  kind: "software",
39764
41838
  reason,
41839
+ hwaccel: null,
41840
+ extraInputArgs: [],
39765
41841
  args: []
39766
41842
  };
39767
41843
  }
39768
41844
  function hardware(backend, source, input) {
41845
+ if (input.recentlyFailedBackend === backend) return software("hardware-attempt-failed");
41846
+ const { hwaccel, extraInputArgs } = decodePlan(backend, input);
39769
41847
  return {
39770
41848
  kind: "hardware",
39771
41849
  backend,
39772
41850
  source,
39773
- args: decodeArgs(backend, input)
41851
+ hwaccel,
41852
+ extraInputArgs,
41853
+ args: [
41854
+ "-hwaccel",
41855
+ hwaccel,
41856
+ ...extraInputArgs
41857
+ ]
39774
41858
  };
39775
41859
  }
39776
41860
  /**
39777
- * The input-side flags, and nothing else.
41861
+ * The input-side decode configuration, and nothing else.
39778
41862
  *
39779
41863
  * No `-hwaccel_output_format`: the decoded frames have to land in system
39780
41864
  * memory for libx264 to scale and encode them. Setting it would keep them on
39781
41865
  * the GPU, which only pays off with a GPU scale filter — and that is the
39782
41866
  * decoder addon's job, not a two-output SRTP session's.
41867
+ *
41868
+ * The two halves are returned SEPARATELY because the shared argv builder emits
41869
+ * `-hwaccel` itself (it is the only function allowed to, so the flag cannot
41870
+ * drift past `-i`) and takes everything else as the input plan's `extraArgs`.
39783
41871
  */
39784
- function decodeArgs(backend, input) {
39785
- if (backend === "videotoolbox" && input.platform === "darwin") return ["-hwaccel", "auto"];
39786
- const args = ["-hwaccel", backend];
39787
- if (RENDER_NODE_BACKENDS.includes(backend)) args.push("-hwaccel_device", input.renderDevice ?? "/dev/dri/renderD128");
39788
- return args;
41872
+ function decodePlan(backend, input) {
41873
+ if (backend === "videotoolbox" && input.platform === "darwin") return {
41874
+ hwaccel: "auto",
41875
+ extraInputArgs: []
41876
+ };
41877
+ return {
41878
+ hwaccel: backend,
41879
+ extraInputArgs: RENDER_NODE_BACKENDS.includes(backend) ? ["-hwaccel_device", input.renderDevice ?? "/dev/dri/renderD128"] : []
41880
+ };
39789
41881
  }
39790
41882
  //#endregion
39791
41883
  //#region src/mappers/builders/stream-hwaccel-probe.ts
39792
41884
  /**
41885
+ * The real read: `decoder.getInfo`, pinned to the LOCAL node.
41886
+ *
41887
+ * Pinned explicitly rather than left to routing, because an unpinned singleton
41888
+ * cap answers from whichever node owns it and would report the WRONG host's
41889
+ * hardware.
41890
+ */
41891
+ function decoderInfoSourceFromContext(ctx) {
41892
+ return {
41893
+ localNodeId: ctx.kernel?.localNodeId,
41894
+ readInfo: (nodeId) => ctx.api.decoder.getInfo.query(void 0, nodePin(nodeId))
41895
+ };
41896
+ }
41897
+ /**
39793
41898
  * Read this node's decode-hwaccel state, or `null` when nothing answered.
39794
41899
  *
39795
41900
  * Never throws. `null` means "we do not know", which
@@ -39797,27 +41902,144 @@ function decodeArgs(backend, input) {
39797
41902
  * the safe direction, because a guess here costs the whole stream.
39798
41903
  */
39799
41904
  async function probeDecoderHwaccel(input) {
39800
- const { ctx, log } = input;
39801
- const nodeId = ctx.kernel?.localNodeId;
41905
+ const { source, log, memo } = input;
41906
+ const memoised = memo.read();
41907
+ if (memoised !== void 0) return memoised;
41908
+ const nodeId = source.localNodeId;
39802
41909
  if (nodeId === void 0 || nodeId.length === 0) {
39803
41910
  log.warn("export-hap: hwaccel probe skipped — no local node id, decoding in SOFTWARE");
39804
41911
  return null;
39805
41912
  }
39806
41913
  try {
39807
- const info = await ctx.api.decoder.getInfo.query(void 0, nodePin(nodeId));
41914
+ const info = await source.readInfo(nodeId);
39808
41915
  if (info === null || info === void 0) {
39809
41916
  log.info("export-hap: hwaccel probe returned nothing — decoding in SOFTWARE", { meta: { nodeId } });
41917
+ memo.write(null);
39810
41918
  return null;
39811
41919
  }
39812
- return {
41920
+ const reading = {
39813
41921
  hwaccel: info.hwaccel ?? null,
39814
41922
  probedBestHwaccel: info.probedBestHwaccel ?? null
39815
41923
  };
41924
+ memo.write(reading);
41925
+ return reading;
39816
41926
  } catch (err) {
39817
41927
  log.warn("export-hap: hwaccel probe failed — decoding in SOFTWARE", { meta: {
39818
41928
  nodeId,
39819
41929
  error: err instanceof Error ? err.message : String(err)
39820
41930
  } });
41931
+ memo.write(null);
41932
+ return null;
41933
+ }
41934
+ }
41935
+ /**
41936
+ * What we ask for on the VIDEO loopback socket.
41937
+ *
41938
+ * Generous on purpose: the cost is virtual address space the kernel only
41939
+ * commits as datagrams actually queue, and the failure it prevents is a black
41940
+ * tile. Sized well above {@link KEYFRAME_BURST_FLOOR_BYTES} so a slow drain
41941
+ * (the JS forwarder is on the same event loop as everything else this addon
41942
+ * does) still has headroom.
41943
+ */
41944
+ var VIDEO_LOOPBACK_RCVBUF_BYTES = 8 * 1024 * 1024;
41945
+ /**
41946
+ * What we ask for on the AUDIO loopback socket.
41947
+ *
41948
+ * Audio never bursts — that is the control in this experiment, and it is why
41949
+ * the two legs get different numbers rather than one shared constant. If audio
41950
+ * ever starts dropping at the same buffer that carries video fine, the cause is
41951
+ * not burst size.
41952
+ */
41953
+ var AUDIO_LOOPBACK_RCVBUF_BYTES = 1024 * 1024;
41954
+ function errMsg$9(err) {
41955
+ return err instanceof Error ? err.message : String(err);
41956
+ }
41957
+ /**
41958
+ * Set `SO_RCVBUF` and READ IT BACK.
41959
+ *
41960
+ * Never throws: a platform that refuses the option must cost the buffer, never
41961
+ * the session. The read-back is the point — a request the kernel clamped and a
41962
+ * request it honoured are indistinguishable at the call site.
41963
+ */
41964
+ function applyReceiveBuffer(socket, requestedBytes) {
41965
+ let error = null;
41966
+ try {
41967
+ socket.setRecvBufferSize(requestedBytes);
41968
+ } catch (err) {
41969
+ error = errMsg$9(err);
41970
+ }
41971
+ let effectiveBytes = null;
41972
+ try {
41973
+ effectiveBytes = socket.getRecvBufferSize();
41974
+ } catch (err) {
41975
+ if (error === null) error = errMsg$9(err);
41976
+ }
41977
+ return {
41978
+ requestedBytes,
41979
+ effectiveBytes,
41980
+ clamped: effectiveBytes !== null && effectiveBytes < requestedBytes,
41981
+ sufficientForKeyframeBurst: effectiveBytes !== null && effectiveBytes >= 2097152,
41982
+ error
41983
+ };
41984
+ }
41985
+ /** Where the kernel publishes per-socket UDP counters, by address family. */
41986
+ var PROC_NET_UDP = {
41987
+ ipv4: "/proc/net/udp",
41988
+ ipv6: "/proc/net/udp6"
41989
+ };
41990
+ /**
41991
+ * The per-socket `drops` count for `port`, out of a `/proc/net/udp` table.
41992
+ *
41993
+ * Pure so the format assumption is pinned by a test rather than by a live
41994
+ * kernel. Returns `null` when the port has no row — which is NOT the same as
41995
+ * zero drops, and the two must never collapse: `0` is evidence the buffer held,
41996
+ * `null` is the absence of evidence.
41997
+ */
41998
+ function parseUdpSocketDrops(table, port) {
41999
+ const lines = table.split("\n");
42000
+ for (const line of lines) {
42001
+ const fields = line.trim().split(/\s+/);
42002
+ if (fields.length < 13) continue;
42003
+ const local = fields[1];
42004
+ if (local === void 0) continue;
42005
+ const hexPort = local.split(":")[1];
42006
+ if (hexPort === void 0) continue;
42007
+ const parsedPort = Number.parseInt(hexPort, 16);
42008
+ if (!Number.isFinite(parsedPort) || parsedPort !== port) continue;
42009
+ const drops = Number(fields[fields.length - 1]);
42010
+ return Number.isFinite(drops) ? drops : null;
42011
+ }
42012
+ return null;
42013
+ }
42014
+ /**
42015
+ * Fold a fresh drop sample into the one already held.
42016
+ *
42017
+ * A socket that has been CLOSED disappears from `/proc/net/udp`, so a resample
42018
+ * after teardown returns `null` — "I can no longer look", which must never
42019
+ * erase "I looked and it was 0". The first live session that proved the buffer
42020
+ * fix reported `videoLoopKernelDrops=null` for exactly this reason: on a
42021
+ * controller `stop` the sockets are closed synchronously while the summary is
42022
+ * emitted later from ffmpeg's `exit` handler.
42023
+ */
42024
+ function mergeDropSample(previous, sampled) {
42025
+ return sampled ?? previous;
42026
+ }
42027
+ /**
42028
+ * Read the kernel's drop counter for a bound local UDP port.
42029
+ *
42030
+ * Linux only — `null` on every other platform and on every read failure, which
42031
+ * is honest: "we could not look" and "nothing was dropped" are different
42032
+ * answers and this returns the first as `null`.
42033
+ *
42034
+ * Synchronous on purpose. It is called at the session heartbeat (5 s) and once
42035
+ * at teardown, against a memory-backed pseudo-file; making it async would mean
42036
+ * the SUMMARY line — the one line this experiment is read from — could not
42037
+ * carry a fresh count, which is the only reason it exists.
42038
+ */
42039
+ function readUdpSocketDrops(port, ipVersion) {
42040
+ try {
42041
+ return parseUdpSocketDrops(readFileSync(PROC_NET_UDP[ipVersion], "utf8"), port);
42042
+ } catch {
39821
42043
  return null;
39822
42044
  }
39823
42045
  }
@@ -39946,6 +42168,9 @@ function summariseSession(snapshot) {
39946
42168
  encodeBudgetKbps: slot?.budgetKbps ?? null,
39947
42169
  fitNotes: slot?.fitNotes ?? [],
39948
42170
  videoPacketsForwarded: snapshot.videoPacketsForwarded,
42171
+ msToFirstKeyframe: snapshot.firstKeyframeAtMs === null || snapshot.startedAtMs === null ? null : snapshot.firstKeyframeAtMs - snapshot.startedAtMs,
42172
+ videoKeyframes: snapshot.videoKeyframes,
42173
+ maxKeyframeGapMs: snapshot.maxKeyframeGapMs,
39949
42174
  audioPacketsForwarded: snapshot.audioPacketsForwarded,
39950
42175
  videoRtcpSrSent: snapshot.videoRtcpSrSent,
39951
42176
  audioRtcpSrSent: snapshot.audioRtcpSrSent,
@@ -39960,11 +42185,21 @@ function summariseSession(snapshot) {
39960
42185
  videoLossVerdict: lossVerdict(snapshot.videoReceiverReports),
39961
42186
  audioLossVerdict: lossVerdict(snapshot.audioReceiverReports),
39962
42187
  mediaStarved: snapshot.videoPacketsForwarded === 0,
42188
+ videoLoopRcvbufRequestedBytes: snapshot.videoLoopback.rcvbufRequestedBytes,
42189
+ videoLoopRcvbufBytes: snapshot.videoLoopback.rcvbufEffectiveBytes,
42190
+ videoLoopRcvbufClamped: snapshot.videoLoopback.rcvbufClamped,
42191
+ videoLoopKernelDrops: snapshot.videoLoopback.kernelDrops,
42192
+ videoLoopKernelDropped: (snapshot.videoLoopback.kernelDrops ?? 0) > 0,
42193
+ audioLoopRcvbufBytes: snapshot.audioLoopback.rcvbufEffectiveBytes,
42194
+ audioLoopKernelDrops: snapshot.audioLoopback.kernelDrops,
42195
+ audioLoopKernelDropped: (snapshot.audioLoopback.kernelDrops ?? 0) > 0,
39963
42196
  drops: nonZeroDrops(snapshot.drops)
39964
42197
  };
39965
42198
  }
39966
42199
  //#endregion
39967
42200
  //#region src/mappers/builders/camera-streams.ts
42201
+ /** A decoder that is slow to describe itself costs hardware decode, not the session. */
42202
+ var HWACCEL_PROBE_BUDGET_MS = 1e3;
39968
42203
  var SRTP_KEY_LEN = 16;
39969
42204
  var SRTP_SALT_LEN = 14;
39970
42205
  /**
@@ -40048,6 +42283,7 @@ function buildCameraStreamingDelegate(bctx, advertised) {
40048
42283
  const hadFfmpeg = session.ffmpeg !== null;
40049
42284
  killFfmpeg(session, ctx, numericDeviceId);
40050
42285
  stopHeartbeat(session);
42286
+ sampleLoopbackDrops(session);
40051
42287
  if (!hadFfmpeg) logSessionSummary(session, log, "accessory-dispose-no-ffmpeg");
40052
42288
  await closeIntercomTalkSession(session, bctx).catch(() => void 0);
40053
42289
  closeSocket(session.videoUdp);
@@ -40076,8 +42312,10 @@ async function prepareStream(request, sessions, bctx) {
40076
42312
  const localIp = pickLocalInterfaceIp(request.targetAddress, ipVersion);
40077
42313
  const videoUdp = await bindUdp(ipVersion, localIp);
40078
42314
  const audioUdp = await bindUdp(ipVersion, localIp);
40079
- const videoLoopUdp = await bindLoopback(ipVersion);
40080
- const audioLoopUdp = await bindLoopback(ipVersion);
42315
+ const videoLoop = await bindLoopback(ipVersion, VIDEO_LOOPBACK_RCVBUF_BYTES);
42316
+ const audioLoop = await bindLoopback(ipVersion, AUDIO_LOOPBACK_RCVBUF_BYTES);
42317
+ const videoLoopUdp = videoLoop.socket;
42318
+ const audioLoopUdp = audioLoop.socket;
40081
42319
  const localVideoPort = videoUdp.address().port;
40082
42320
  const localAudioPort = audioUdp.address().port;
40083
42321
  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) {
@@ -40153,6 +42391,10 @@ async function prepareStream(request, sessions, bctx) {
40153
42391
  drops: emptyDropCounters(),
40154
42392
  videoPacketsForwarded: 0,
40155
42393
  audioPacketsForwarded: 0,
42394
+ videoKeyframes: 0,
42395
+ firstKeyframeAtMs: null,
42396
+ lastKeyframeAtMs: null,
42397
+ maxKeyframeGapMs: 0,
40156
42398
  videoRtcpSrSent: 0,
40157
42399
  audioRtcpSrSent: 0,
40158
42400
  videoRtcpReceived: 0,
@@ -40192,6 +42434,12 @@ async function prepareStream(request, sessions, bctx) {
40192
42434
  audioInSrtcp,
40193
42435
  audioSendGate: null,
40194
42436
  ipVersion,
42437
+ videoLoopRcvbuf: videoLoop.buffer,
42438
+ audioLoopRcvbuf: audioLoop.buffer,
42439
+ videoLoopPort: videoLoopUdp.address().port,
42440
+ audioLoopPort: audioLoopUdp.address().port,
42441
+ videoLoopKernelDrops: null,
42442
+ audioLoopKernelDrops: null,
40195
42443
  ffmpeg: null,
40196
42444
  lastStartParams: null,
40197
42445
  upstreamAudioSrtp,
@@ -40231,6 +42479,7 @@ async function prepareStream(request, sessions, bctx) {
40231
42479
  });
40232
42480
  videoLoopUdp.on("message", (rtpPacket) => {
40233
42481
  session.videoPacketsForwarded += 1;
42482
+ if (rtpPacketCarriesIdr(rtpPacket)) recordKeyframe(session);
40234
42483
  if (session.videoPacketsForwarded === 1) tagLog.info("export-hap: first video packet from ffmpeg", { meta: {
40235
42484
  sessionId: session.sessionId,
40236
42485
  bytes: rtpPacket.length
@@ -40246,6 +42495,8 @@ async function prepareStream(request, sessions, bctx) {
40246
42495
  bctx.ctx.logger.withTags({ deviceId: bctx.numericDeviceId }).debug("export-hap: incoming-audio handler error (dropped)", { meta: { error: errMsg$8(err) } });
40247
42496
  });
40248
42497
  });
42498
+ logLoopbackBuffer(tagLog, request.sessionID, "video", videoLoop.buffer);
42499
+ logLoopbackBuffer(tagLog, request.sessionID, "audio", audioLoop.buffer);
40249
42500
  tagLog.info("export-hap: stream prepared", { meta: {
40250
42501
  sessionId: request.sessionID,
40251
42502
  controllerAddress: request.targetAddress,
@@ -40326,12 +42577,45 @@ function sameIpv4Subnet(a, mask, b) {
40326
42577
  return true;
40327
42578
  }
40328
42579
  /**
42580
+ * Report one loopback socket's receive buffer.
42581
+ *
42582
+ * `warn` when the video leg cannot hold a 4K key-frame burst: that is the state
42583
+ * in which this addon silently drops most of a key frame and the tile stays
42584
+ * black, and it was invisible for the whole life of this code path.
42585
+ */
42586
+ function logLoopbackBuffer(log, sessionId, leg, outcome) {
42587
+ const meta = {
42588
+ sessionId,
42589
+ leg,
42590
+ requestedBytes: outcome.requestedBytes,
42591
+ effectiveBytes: outcome.effectiveBytes,
42592
+ clamped: outcome.clamped,
42593
+ sufficientForKeyframeBurst: outcome.sufficientForKeyframeBurst,
42594
+ error: outcome.error
42595
+ };
42596
+ if (leg === "video" && !outcome.sufficientForKeyframeBurst) {
42597
+ log.warn("export-hap: loopback receive buffer is TOO SMALL for a key-frame burst — raise net.core.rmem_max on the host", { meta });
42598
+ return;
42599
+ }
42600
+ log.info("export-hap: loopback receive buffer", { meta });
42601
+ }
42602
+ /**
40329
42603
  * Resolve once-per-session: the local IP we bind iOS-facing sockets to
40330
42604
  * AND its mate on `127.0.0.1` for the ffmpeg loopback path. Both go
40331
42605
  * through the bounded-wait `dgram.bind` pattern.
42606
+ *
42607
+ * `SO_RCVBUF` is set AFTER the bind and read back, never assumed. Until
42608
+ * 2026-08-07 nothing set it at all, so these sockets ran on
42609
+ * `net.core.rmem_default` (212 992 B on this hub) — about a fifth of one 4K
42610
+ * key frame, which arrives as ~750 datagrams in one burst. See
42611
+ * `stream-socket-buffer.ts` for the measurement.
40332
42612
  */
40333
- async function bindLoopback(ipVersion) {
40334
- return bindUdp(ipVersion, ipVersion === "ipv6" ? "::1" : "127.0.0.1");
42613
+ async function bindLoopback(ipVersion, requestedRcvbufBytes) {
42614
+ const socket = await bindUdp(ipVersion, ipVersion === "ipv6" ? "::1" : "127.0.0.1");
42615
+ return {
42616
+ socket,
42617
+ buffer: applyReceiveBuffer(socket, requestedRcvbufBytes)
42618
+ };
40335
42619
  }
40336
42620
  /** Book a named drop. Every silent `return` on the streaming path routes here. */
40337
42621
  function drop(session, reason) {
@@ -40472,6 +42756,30 @@ function logReceiverReports(session, leg, reports, isFirst, log) {
40472
42756
  if (!shouldLogReceiverReport(session, leg)) return;
40473
42757
  log.info("export-hap: controller receiver report", { meta });
40474
42758
  }
42759
+ /**
42760
+ * Record a forwarded key frame. Kept separate from the packet counter because
42761
+ * the interesting quantity is TIMING, not a tally: the first arrival dates the
42762
+ * moment the controller could begin decoding, and the widest gap says how long
42763
+ * a mid-GOP join can be expected to stare at a loader.
42764
+ */
42765
+ function recordKeyframe(session) {
42766
+ const now = Date.now();
42767
+ session.videoKeyframes += 1;
42768
+ if (session.firstKeyframeAtMs === null) session.firstKeyframeAtMs = now;
42769
+ else if (session.lastKeyframeAtMs !== null) session.maxKeyframeGapMs = Math.max(session.maxKeyframeGapMs, now - session.lastKeyframeAtMs);
42770
+ session.lastKeyframeAtMs = now;
42771
+ }
42772
+ /**
42773
+ * Refresh the kernel's per-socket drop counters.
42774
+ *
42775
+ * Called immediately before every line that reports them, because a stale
42776
+ * sample on the summary would answer the experiment's central question with
42777
+ * data from five seconds earlier. Cheap: `/proc/net/udp` is memory-backed.
42778
+ */
42779
+ function sampleLoopbackDrops(session) {
42780
+ session.videoLoopKernelDrops = mergeDropSample(session.videoLoopKernelDrops, readUdpSocketDrops(session.videoLoopPort, session.ipVersion));
42781
+ session.audioLoopKernelDrops = mergeDropSample(session.audioLoopKernelDrops, readUdpSocketDrops(session.audioLoopPort, session.ipVersion));
42782
+ }
40475
42783
  /** Snapshot every counter into the summary meta. */
40476
42784
  function sessionSummaryMeta(session) {
40477
42785
  return summariseSession({
@@ -40482,6 +42790,9 @@ function sessionSummaryMeta(session) {
40482
42790
  selectedSlot: session.selectedSlot,
40483
42791
  videoPacketsForwarded: session.videoPacketsForwarded,
40484
42792
  audioPacketsForwarded: session.audioPacketsForwarded,
42793
+ videoKeyframes: session.videoKeyframes,
42794
+ firstKeyframeAtMs: session.firstKeyframeAtMs,
42795
+ maxKeyframeGapMs: session.maxKeyframeGapMs,
40485
42796
  videoRtcpSrSent: session.videoRtcpSrSent,
40486
42797
  audioRtcpSrSent: session.audioRtcpSrSent,
40487
42798
  videoRtcpReceived: session.videoRtcpReceived,
@@ -40494,7 +42805,19 @@ function sessionSummaryMeta(session) {
40494
42805
  audioReceiverReports: session.audioReceiverReports,
40495
42806
  drops: session.drops,
40496
42807
  ffmpegExit: session.ffmpegExit,
40497
- stopRequestedByController: session.stopRequestedByController
42808
+ stopRequestedByController: session.stopRequestedByController,
42809
+ videoLoopback: {
42810
+ rcvbufRequestedBytes: session.videoLoopRcvbuf.requestedBytes,
42811
+ rcvbufEffectiveBytes: session.videoLoopRcvbuf.effectiveBytes,
42812
+ rcvbufClamped: session.videoLoopRcvbuf.clamped,
42813
+ kernelDrops: session.videoLoopKernelDrops
42814
+ },
42815
+ audioLoopback: {
42816
+ rcvbufRequestedBytes: session.audioLoopRcvbuf.requestedBytes,
42817
+ rcvbufEffectiveBytes: session.audioLoopRcvbuf.effectiveBytes,
42818
+ rcvbufClamped: session.audioLoopRcvbuf.clamped,
42819
+ kernelDrops: session.audioLoopKernelDrops
42820
+ }
40498
42821
  });
40499
42822
  }
40500
42823
  /**
@@ -40508,6 +42831,7 @@ function armHeartbeat(session, log) {
40508
42831
  const timer = setInterval(() => {
40509
42832
  const forwarded = session.videoPacketsForwarded - lastVideo;
40510
42833
  lastVideo = session.videoPacketsForwarded;
42834
+ sampleLoopbackDrops(session);
40511
42835
  log.info("export-hap: stream heartbeat", { meta: {
40512
42836
  ...sessionSummaryMeta(session),
40513
42837
  videoPacketsSinceLastBeat: forwarded,
@@ -40546,6 +42870,7 @@ function stopHeartbeat(session) {
40546
42870
  */
40547
42871
  function logSessionSummary(session, log, trigger) {
40548
42872
  session.endedAtMs = Date.now();
42873
+ sampleLoopbackDrops(session);
40549
42874
  log.info("export-hap: stream session summary", { meta: {
40550
42875
  ...sessionSummaryMeta(session),
40551
42876
  trigger
@@ -40730,6 +43055,7 @@ async function handleStreamRequest(request, sessions, bctx, advertised) {
40730
43055
  const hadFfmpeg = session.ffmpeg !== null;
40731
43056
  killFfmpeg(session, bctx.ctx, bctx.numericDeviceId);
40732
43057
  stopHeartbeat(session);
43058
+ sampleLoopbackDrops(session);
40733
43059
  if (!hadFfmpeg) logSessionSummary(session, log, "controller-stop-no-ffmpeg");
40734
43060
  await closeIntercomTalkSession(session, bctx).catch(() => void 0);
40735
43061
  closeSocket(session.videoUdp);
@@ -40839,7 +43165,7 @@ async function handleStreamRequest(request, sessions, bctx, advertised) {
40839
43165
  async function startFfmpegForSession(bctx, session, sessionId, video, advertised) {
40840
43166
  const { ctx, proxy, numericDeviceId, options } = bctx;
40841
43167
  const startLog = ctx.logger.withTags({ deviceId: numericDeviceId });
40842
- const entries = await proxy.cameraStreams?.getProfileRtspEntries({}) ?? [];
43168
+ const [entries, brokerStreams] = await Promise.all([(async () => await proxy.cameraStreams?.getProfileRtspEntries({}) ?? [])(), (async () => await proxy.cameraStreams?.getBrokerStreams({}) ?? [])()]);
40843
43169
  if (entries.length === 0) {
40844
43170
  startLog.warn("export-hap: stream start DROPPED — device publishes no profile RTSP entries", { meta: {
40845
43171
  sessionId,
@@ -40848,16 +43174,21 @@ async function startFfmpegForSession(bctx, session, sessionId, video, advertised
40848
43174
  throw new Error(`export-hap: no profile RTSP entries for device ${numericDeviceId}`);
40849
43175
  }
40850
43176
  const pref = options.hapDeviceSettings.streamPreference;
40851
- const brokerStreams = await proxy.cameraStreams?.getBrokerStreams({}) ?? [];
40852
43177
  const bitrates = await probeProfileBitrates({
40853
43178
  bctx,
40854
43179
  slots: brokerStreams,
40855
43180
  log: startLog
40856
43181
  });
43182
+ const connection = classifyConnection({
43183
+ negotiatedWidth: video.width,
43184
+ audioPacketTimeMs: session.negotiated?.audioPacketTimeMs ?? 20,
43185
+ viaHomeHub: false
43186
+ });
40857
43187
  const fit = selectStreamForBudget({
40858
43188
  entries: withSlotCodecs(entries, brokerStreams),
40859
43189
  deviceId: numericDeviceId,
40860
43190
  pref,
43191
+ connection,
40861
43192
  targetResolution: {
40862
43193
  width: video.width,
40863
43194
  height: video.height
@@ -40883,7 +43214,7 @@ async function startFfmpegForSession(bctx, session, sessionId, video, advertised
40883
43214
  const resolvedFps = pickedProfile === null ? void 0 : advertised.fpsByProfile.get(pickedProfile);
40884
43215
  const advertisedFps = resolvedFps?.fps ?? video.fps;
40885
43216
  const advertisedFpsSource = resolvedFps?.source ?? "assumed";
40886
- const deliveredFps = needsTranscode ? deliverableFps(video.fps, resolvedFps?.fps ?? null) : advertisedFps;
43217
+ const deliveredFps = needsTranscode ? video.fps : advertisedFps;
40887
43218
  const slotEvidence = pickedProfile === null ? void 0 : bitrates.get(pickedProfile);
40888
43219
  const fitNotes = formatFitNotes(fit.notes);
40889
43220
  session.selectedSlot = {
@@ -40928,7 +43259,7 @@ async function startFfmpegForSession(bctx, session, sessionId, video, advertised
40928
43259
  const audioLoopPort = session.audioLoopUdp.address().port;
40929
43260
  const videoTarget = `rtp://127.0.0.1:${videoLoopPort}?pkt_size=${video.mtu}`;
40930
43261
  const audioTarget = `rtp://127.0.0.1:${audioLoopPort}?pkt_size=${video.mtu}`;
40931
- const videoArgs = buildVideoEncodeArgs({
43262
+ const videoPlan = buildVideoPlan({
40932
43263
  transcode: needsTranscode,
40933
43264
  width: video.width,
40934
43265
  height: video.height,
@@ -40937,19 +43268,21 @@ async function startFfmpegForSession(bctx, session, sessionId, video, advertised
40937
43268
  });
40938
43269
  const hwDecode = selectHwDecode({
40939
43270
  transcode: needsTranscode,
40940
- reading: needsTranscode ? await probeDecoderHwaccel({
40941
- ctx,
40942
- log: startLog
40943
- }) : null,
40944
- platform: process.platform
43271
+ reading: needsTranscode ? await withDeadline(probeDecoderHwaccel({
43272
+ source: decoderInfoSourceFromContext(ctx),
43273
+ log: startLog,
43274
+ memo: options.decodeMemos.reading
43275
+ }), 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,
43276
+ platform: process.platform,
43277
+ recentlyFailedBackend: options.decodeMemos.failedBackend.read() ?? null
40945
43278
  });
40946
43279
  logDecodePath(startLog, sessionId, hwDecode, needsTranscode);
40947
43280
  const videoSsrcSigned = session.videoSsrc | 0;
40948
43281
  const audioSsrcSigned = video.audio_ssrc | 0;
40949
- const buildArgs = (decodeArgs) => buildSessionFfmpegArgs({
40950
- decodeArgs,
43282
+ const buildArgs = (decode) => buildFfmpegArgs(buildSessionInvocation({
43283
+ decode,
40951
43284
  rtspUrl,
40952
- videoArgs,
43285
+ video: videoPlan,
40953
43286
  videoTarget,
40954
43287
  audioTarget,
40955
43288
  videoPayloadType: video.pt,
@@ -40958,13 +43291,13 @@ async function startFfmpegForSession(bctx, session, sessionId, video, advertised
40958
43291
  audioSsrcSigned,
40959
43292
  audioPacketTimeMs: video.packet_time ?? 20,
40960
43293
  audioSampleRateKhz: video.sample_rate ?? 16
40961
- });
43294
+ }));
40962
43295
  const log = ctx.logger.withTags({ deviceId: numericDeviceId });
40963
43296
  let hardwareAlreadyFailed = false;
40964
43297
  const spawnFfmpeg = (decision) => {
40965
43298
  const spawnedAtMs = Date.now();
40966
43299
  const usedHardware = decision.kind === "hardware";
40967
- const proc = spawn("ffmpeg", buildArgs(decision.args), { stdio: [
43300
+ const proc = spawn("ffmpeg", buildArgs(decision), { stdio: [
40968
43301
  "ignore",
40969
43302
  "ignore",
40970
43303
  "pipe"
@@ -40987,6 +43320,7 @@ async function startFfmpegForSession(bctx, session, sessionId, video, advertised
40987
43320
  runtimeMs: Date.now() - spawnedAtMs
40988
43321
  })) {
40989
43322
  hardwareAlreadyFailed = true;
43323
+ if (decision.kind === "hardware") options.decodeMemos.failedBackend.write(decision.backend);
40990
43324
  log.warn("export-hap: hardware decode FAILED at init — respawning ffmpeg in software", { meta: {
40991
43325
  sessionId,
40992
43326
  backend: decision.kind === "hardware" ? decision.backend : null,
@@ -40996,11 +43330,7 @@ async function startFfmpegForSession(bctx, session, sessionId, video, advertised
40996
43330
  runtimeMs: Date.now() - spawnedAtMs
40997
43331
  } });
40998
43332
  if (session.ffmpeg === proc) session.ffmpeg = null;
40999
- spawnFfmpeg({
41000
- kind: "software",
41001
- reason: "hardware-attempt-failed",
41002
- args: []
41003
- });
43333
+ spawnFfmpeg(software("hardware-attempt-failed"));
41004
43334
  return;
41005
43335
  }
41006
43336
  onFfmpegExit(proc, code, signal);
@@ -41045,7 +43375,7 @@ async function startFfmpegForSession(bctx, session, sessionId, video, advertised
41045
43375
  fitReason: fit.reason,
41046
43376
  encodeBudgetKbps: fit.budgetKbps,
41047
43377
  audioCodec: "opus",
41048
- audioBitrateKbps: 24,
43378
+ audioBitrateKbps: OPUS_BITRATE_KBPS,
41049
43379
  videoDecode: hwDecode.kind === "hardware" ? hwDecode.backend : "software"
41050
43380
  } });
41051
43381
  }
@@ -41250,16 +43580,87 @@ function errMsg$8(err) {
41250
43580
  return err instanceof Error ? err.message : String(err);
41251
43581
  }
41252
43582
  //#endregion
43583
+ //#region src/mappers/builders/doorbell-delivery.ts
43584
+ function isRecord(value) {
43585
+ return typeof value === "object" && value !== null;
43586
+ }
43587
+ function numberOrNull(value) {
43588
+ return typeof value === "number" ? value : null;
43589
+ }
43590
+ function isConnectionLike(value) {
43591
+ return isRecord(value) && typeof value["hasEventNotifications"] === "function";
43592
+ }
43593
+ function isIterable(value) {
43594
+ return isRecord(value) && typeof value[Symbol.iterator] === "function";
43595
+ }
43596
+ /** `accessory._server.httpServer.connections`, or null at any missing hop. */
43597
+ function readConnections(accessory) {
43598
+ if (!isRecord(accessory)) return null;
43599
+ const server = accessory["_server"];
43600
+ if (!isRecord(server)) return null;
43601
+ const httpServer = server["httpServer"];
43602
+ if (!isRecord(httpServer)) return null;
43603
+ const connections = httpServer["connections"];
43604
+ return isIterable(connections) ? connections : null;
43605
+ }
43606
+ /**
43607
+ * Probe how far a ring on `characteristic` of `accessory` can travel RIGHT NOW.
43608
+ * Pure with respect to HAP state — it only reads. Never throws.
43609
+ */
43610
+ function describeDoorbellDelivery(accessory, characteristic) {
43611
+ const aid = isRecord(accessory) ? numberOrNull(accessory["aid"]) : null;
43612
+ const iid = isRecord(characteristic) ? numberOrNull(characteristic["iid"]) : null;
43613
+ const serverPublished = isRecord(accessory) && isRecord(accessory["_server"]);
43614
+ const connections = readConnections(accessory);
43615
+ if (connections === null) return {
43616
+ aid,
43617
+ iid,
43618
+ serverPublished,
43619
+ connectionCount: 0,
43620
+ subscriberCount: 0
43621
+ };
43622
+ let connectionCount = 0;
43623
+ let subscriberCount = 0;
43624
+ for (const connection of connections) {
43625
+ connectionCount += 1;
43626
+ if (aid === null || iid === null) continue;
43627
+ if (isConnectionLike(connection) && connection.hasEventNotifications(aid, iid)) subscriberCount += 1;
43628
+ }
43629
+ return {
43630
+ aid,
43631
+ iid,
43632
+ serverPublished,
43633
+ connectionCount,
43634
+ subscriberCount
43635
+ };
43636
+ }
43637
+ /**
43638
+ * True when the ring provably reached nobody: no connection is subscribed to
43639
+ * the characteristic, so hap-nodejs dropped every event frame silently. The
43640
+ * caller must say so out loud — this is a branch that discards work.
43641
+ */
43642
+ function ringReachedNobody(report) {
43643
+ return report.subscriberCount === 0;
43644
+ }
43645
+ //#endregion
41253
43646
  //#region src/mappers/builders/doorbell.ts
41254
43647
  async function buildDoorbell(input) {
41255
43648
  const { bctx, controller } = input;
41256
43649
  const { ctx, numericDeviceId } = bctx;
43650
+ const log = ctx.logger.withTags({ deviceId: numericDeviceId });
43651
+ log.info("export-hap: doorbell forward armed — HomeKit will ring on doorbell.onPressed");
41257
43652
  const unsubscribe = ctx.eventBus.subscribe({ category: EventCategory.DoorbellOnPressed }, (event) => {
41258
- if (event.data.deviceId !== numericDeviceId) return;
43653
+ if (event.data?.deviceId !== numericDeviceId) return;
41259
43654
  try {
43655
+ const delivery = describeDoorbellDelivery(bctx.accessory, bctx.accessory.getService(Service.Doorbell)?.getCharacteristic(Characteristic.ProgrammableSwitchEvent) ?? null);
41260
43656
  controller.ringDoorbell();
43657
+ if (ringReachedNobody(delivery)) {
43658
+ 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 } });
43659
+ return;
43660
+ }
43661
+ log.info("export-hap: doorbell SINGLE_PRESS pushed to HomeKit", { meta: { ...delivery } });
41261
43662
  } catch (err) {
41262
- ctx.logger.withTags({ deviceId: numericDeviceId }).warn("export-hap: ringDoorbell() failed", { meta: { error: errMsg$7(err) } });
43663
+ log.warn("export-hap: ringDoorbell() failed", { meta: { error: errMsg$7(err) } });
41263
43664
  }
41264
43665
  });
41265
43666
  return { async dispose() {
@@ -41295,9 +43696,18 @@ async function buildIntercom(input) {
41295
43696
  * (`proxy.motion.isDetected({})`) when the motion cap is bound.
41296
43697
  */
41297
43698
  var RESET_DEBOUNCE_MS = 5e3;
41298
- async function buildMotionSensor(bctx) {
43699
+ /**
43700
+ * @param existing - The controller's OWN `MotionSensor`, when HomeKit Secure
43701
+ * Video is advertised. HKSV derives its `EventTriggerOption.MOTION` from the
43702
+ * service the `CameraController` created (`sensors: { motion: true }`) and is
43703
+ * blind to any other one — a second MotionSensor added here would keep working
43704
+ * as a sensor in the Home app while silently triggering no recording at all.
43705
+ * `null` when recording is off, in which case this builder owns the service as
43706
+ * it always has.
43707
+ */
43708
+ async function buildMotionSensor(bctx, existing = null) {
41299
43709
  const { ctx, accessory, proxy, numericDeviceId, displayName } = bctx;
41300
- const motionService = accessory.addService(Service.MotionSensor, hapServiceName([displayName], `Camera ${numericDeviceId}`));
43710
+ const motionService = existing ?? accessory.addService(Service.MotionSensor, hapServiceName([displayName], `Camera ${numericDeviceId}`));
41301
43711
  motionService.setCharacteristic(Characteristic.MotionDetected, false);
41302
43712
  try {
41303
43713
  const detected = await proxy.motion?.isDetected({});
@@ -41335,6 +43745,78 @@ function errMsg$6(err) {
41335
43745
  return err instanceof Error ? err.message : String(err);
41336
43746
  }
41337
43747
  //#endregion
43748
+ //#region src/mappers/builders/service-label.ts
43749
+ /**
43750
+ * The ONE place a secondary service on the camera accessory gets its label.
43751
+ *
43752
+ * A "secondary service" here is a Switch or Lightbulb published alongside the
43753
+ * camera on the same accessory — the privacy switch, each accessory child
43754
+ * (siren, floodlight), each PTZ action. iOS Home renders these as their own
43755
+ * controls, and the operator has seen them as "Interruttore 1", "Interruttore
43756
+ * 2" through three separate rounds of fixes.
43757
+ *
43758
+ * ## Why `Name` alone cannot rename anything
43759
+ *
43760
+ * Two facts about hap-nodejs 2.1.7, both measured against the installed copy
43761
+ * rather than reasoned about:
43762
+ *
43763
+ * 1. `accessory.addService(Type, displayName, subtype)` ALREADY writes
43764
+ * `displayName` to `Characteristic.Name` (`Service` constructor). So every
43765
+ * round of this bug — including the one that moved the label onto
43766
+ * `ConfiguredName` — shipped with `Name` correctly set. "iOS had no name
43767
+ * to render" was never true.
43768
+ * 2. The mDNS configuration number (`c#`) is a sha1 over
43769
+ * `internalHAPRepresentation(false)`, which OMITS characteristic VALUES.
43770
+ * Changing the string in `Name` therefore does not bump `c#`, a paired
43771
+ * controller gets no signal to re-read `/accessories`, and the name it
43772
+ * cached at first enumeration stands forever.
43773
+ *
43774
+ * `Name` is also declared `pr` only — paired read, no write, no notify. It is
43775
+ * the seed a controller seeds its database from once; it is not a channel.
43776
+ *
43777
+ * ## Why `ConfiguredName`
43778
+ *
43779
+ * `ConfiguredName` (`000000E3`) is declared `pr | pw | ev` — the only name
43780
+ * characteristic a controller may write and may subscribe to. It is what iOS
43781
+ * 16+ reads for a service the user can rename, and adding it CHANGES the
43782
+ * accessory structure, so `c#` does bump and the controller re-reads.
43783
+ *
43784
+ * It was removed once because hap-nodejs logged
43785
+ *
43786
+ * ```
43787
+ * Characteristic not in required or optional characteristic section for
43788
+ * service Switch. Adding anyway.
43789
+ * ```
43790
+ *
43791
+ * That line is a WARNING, not a rejection: `Service.getCharacteristic` calls
43792
+ * `addCharacteristic` unconditionally and only then emits the warning. The
43793
+ * characteristic was always present and always published. hap-nodejs'
43794
+ * per-service optional lists simply predate `ConfiguredName` being valid on
43795
+ * any service.
43796
+ *
43797
+ * Registering it with {@link Service.addOptionalCharacteristic} first takes
43798
+ * the branch above the warning, so the accessory still builds with ZERO
43799
+ * characteristic warnings — which is what `service-naming.spec.ts` asserts.
43800
+ *
43801
+ * ## Scope
43802
+ *
43803
+ * Switch- and Lightbulb-shaped services only. `Service.MotionSensor` on a
43804
+ * camera accessory is not a separately named tile in iOS Home, so giving it a
43805
+ * writable name would be a guess, and this module does not guess.
43806
+ */
43807
+ /**
43808
+ * Publish `name` as both the immutable `Name` and the controller-visible
43809
+ * `ConfiguredName` of `service`.
43810
+ *
43811
+ * `name` must already be HAP-valid — build it with `service-names.ts`, which
43812
+ * cannot return a string hap-nodejs' `checkName` would warn about.
43813
+ */
43814
+ function applyServiceLabel(service, name) {
43815
+ service.setCharacteristic(Characteristic.Name, name);
43816
+ if (!service.optionalCharacteristics.some((characteristic) => characteristic.UUID === Characteristic.ConfiguredName.UUID)) service.addOptionalCharacteristic(Characteristic.ConfiguredName);
43817
+ service.setCharacteristic(Characteristic.ConfiguredName, name);
43818
+ }
43819
+ //#endregion
41338
43820
  //#region src/mappers/builders/privacy-switch.ts
41339
43821
  /**
41340
43822
  * Privacy-mask switch builder — turns the camstack `privacy-mask` cap's
@@ -41352,12 +43834,12 @@ function errMsg$6(err) {
41352
43834
  * camera-enabled switch — distinct from privacy-mask).
41353
43835
  */
41354
43836
  async function buildPrivacySwitch(bctx) {
41355
- const { ctx, accessory, proxy, numericDeviceId, displayName } = bctx;
43837
+ const { ctx, accessory, proxy, numericDeviceId } = bctx;
41356
43838
  const log = ctx.logger.withTags({ deviceId: numericDeviceId });
41357
43839
  const subtype = "privacy-mask";
41358
- const serviceName = privacyServiceName(displayName);
43840
+ const serviceName = privacyServiceName();
41359
43841
  const service = accessory.addService(Service.Switch, serviceName, subtype);
41360
- service.setCharacteristic(Characteristic.Name, serviceName);
43842
+ applyServiceLabel(service, serviceName);
41361
43843
  try {
41362
43844
  const status = await proxy.privacyMask?.getStatus({});
41363
43845
  if (status && typeof status.enabled === "boolean") service.updateCharacteristic(Characteristic.On, status.enabled);
@@ -41447,23 +43929,17 @@ function ptzPresetLabel(presetName) {
41447
43929
  * `proxy.ptzAutotrack.setEnabled({enabled})`. Initial value is
41448
43930
  * hydrated from `getStatus({})`.
41449
43931
  *
41450
- * Naming: `<camera> <action>` — "Videocamera ingresso Preset stanza" — built
41451
- * by `ptzServiceName` and written to the `Name` characteristic.
41452
- *
41453
- * TWO separate naming defects have been through this file, and both are fixed
41454
- * by that one line:
41455
- * - `${displayName} <action>` embedded an em-dash (U+2014), which is
41456
- * outside Apple's permitted set. hap-nodejs' `checkName` warned, iOS
41457
- * discarded the name and showed "Interruttore N". A previous round dropped
41458
- * the camera prefix along with the em-dash; only the em-dash was the fault.
41459
- * - The bare label that replaced it was then written to `ConfiguredName`,
41460
- * which `Service.Switch` does not list, so hap-nodejs rejected the
41461
- * characteristic outright — SIX rejections per PTZ camera per build, never
41462
- * reported because only the two switches on the non-PTZ camera were noticed.
43932
+ * Naming: the bare action — "Preset stanza", "Pan Left", "Autotrack" — built
43933
+ * by `ptzServiceName` and published through `applyServiceLabel`, which writes
43934
+ * it to BOTH `Name` and `ConfiguredName`. The camera name is deliberately not
43935
+ * prefixed these eight services live on that camera's accessory and iOS
43936
+ * shows them there. THREE rounds of this bug have been through this file;
43937
+ * `service-label.ts` records what each got wrong, and why only the writable
43938
+ * characteristic can rename a service after pairing.
41463
43939
  */
41464
43940
  var MOMENTARY_RESET_MS = 1e3;
41465
43941
  async function buildPtz(bctx) {
41466
- const { ctx, accessory, proxy, numericDeviceId, displayName, options } = bctx;
43942
+ const { ctx, accessory, proxy, numericDeviceId, options } = bctx;
41467
43943
  const log = ctx.logger.withTags({ deviceId: numericDeviceId });
41468
43944
  const timers = /* @__PURE__ */ new Set();
41469
43945
  const armReset = (cb, delay) => {
@@ -41475,10 +43951,10 @@ async function buildPtz(bctx) {
41475
43951
  };
41476
43952
  const presets = await readPresets(bctx);
41477
43953
  for (const preset of presets) {
41478
- const label = ptzServiceName(displayName, ptzPresetLabel(preset.name));
43954
+ const label = ptzServiceName(ptzPresetLabel(preset.name));
41479
43955
  const subtype = `ptz-preset-${preset.id}`;
41480
43956
  const service = accessory.addService(Service.Switch, label, subtype);
41481
- service.setCharacteristic(Characteristic.Name, label);
43957
+ applyServiceLabel(service, label);
41482
43958
  service.getCharacteristic(Characteristic.On).onSet(async (value) => {
41483
43959
  if (value !== true) return;
41484
43960
  try {
@@ -41493,9 +43969,9 @@ async function buildPtz(bctx) {
41493
43969
  });
41494
43970
  }
41495
43971
  if (proxy.ptz) for (const dir of PTZ_DIRECTIONS) {
41496
- const label = ptzServiceName(displayName, dir.label);
43972
+ const label = ptzServiceName(dir.label);
41497
43973
  const service = accessory.addService(Service.Switch, label, dir.subtype);
41498
- service.setCharacteristic(Characteristic.Name, label);
43974
+ applyServiceLabel(service, label);
41499
43975
  service.getCharacteristic(Characteristic.On).onSet(async (value) => {
41500
43976
  if (value !== true) return;
41501
43977
  try {
@@ -41539,12 +44015,12 @@ async function readPresets(bctx) {
41539
44015
  }
41540
44016
  }
41541
44017
  async function tryBuildAutotrack(bctx) {
41542
- const { ctx, accessory, proxy, numericDeviceId, displayName } = bctx;
44018
+ const { ctx, accessory, proxy, numericDeviceId } = bctx;
41543
44019
  if (!proxy.ptzAutotrack) return { async dispose() {} };
41544
44020
  const log = ctx.logger.withTags({ deviceId: numericDeviceId });
41545
- const label = ptzServiceName(displayName, PTZ_AUTOTRACK_LABEL);
44021
+ const label = ptzServiceName(PTZ_AUTOTRACK_LABEL);
41546
44022
  const service = accessory.addService(Service.Switch, label, "ptz-autotrack");
41547
- service.setCharacteristic(Characteristic.Name, label);
44023
+ applyServiceLabel(service, label);
41548
44024
  try {
41549
44025
  const status = await proxy.ptzAutotrack.getStatus({});
41550
44026
  if (status && typeof status.enabled === "boolean") service.updateCharacteristic(Characteristic.On, status.enabled);
@@ -41627,6 +44103,814 @@ async function probe(call, label, log) {
41627
44103
  }
41628
44104
  }
41629
44105
  //#endregion
44106
+ //#region src/hksv/recording-options.ts
44107
+ /**
44108
+ * The HomeKit Secure Video ADVERTISEMENT — `CameraRecordingOptions`, derived
44109
+ * from what the fMP4 sink will actually produce for THIS camera.
44110
+ *
44111
+ * ## The rule this file exists to enforce
44112
+ *
44113
+ * Never advertise something we cannot serve. That is not a slogan here: it is
44114
+ * the diagnosis of [D50](../../../../docs/decisions/adr-0050.md) — an
44115
+ * advertised `recording` whose delegate yielded nothing put every motion-capable
44116
+ * camera into a ~12 s timeout loop every 20-60 s, all day. So every number below
44117
+ * is derived from the picked source (`recording-source.ts`) or from a measured
44118
+ * property of the sink, and none of them is a plausible-looking constant.
44119
+ *
44120
+ * ## The fragment length is the subtle one
44121
+ *
44122
+ * HKSV requires every media fragment to be **no longer** than the length the
44123
+ * controller selected. On the copy branch the fragment length is the SOURCE's
44124
+ * key-frame cadence ([D80](../../../../docs/decisions/adr-0080.md)) — we do not
44125
+ * get to choose it, we can only be honest about it. So:
44126
+ *
44127
+ * - when the camera reports its GOP (`stream-params`), the advertised length is
44128
+ * the smallest offered value that COVERS it;
44129
+ * - when it does not, we advertise the 4000 ms every HKSV camera uses and the
44130
+ * delegate warns at `warn` with `tags: { deviceId }` if the fragments that
44131
+ * actually arrive are longer.
44132
+ *
44133
+ * A camera whose GOP exceeds the longest value we offer does not advertise
44134
+ * recording at all. See {@link deriveFragmentLengthMs}.
44135
+ */
44136
+ /**
44137
+ * The prebuffer we promise. HAP's floor is 4000 ms and its documented sensible
44138
+ * range is [4000, 8000]; the plane's ring is sized from this, so the two cannot
44139
+ * disagree. Asking for more than we retain would be the same lie in the other
44140
+ * direction.
44141
+ */
44142
+ var HKSV_PREBUFFER_MS = 4e3;
44143
+ /**
44144
+ * The fragment lengths we are willing to advertise, shortest first. 4000 ms is
44145
+ * what every shipping HKSV camera uses; 8000 exists for a camera whose GOP is
44146
+ * 8 s, which is common enough on this fleet's defaults to be worth covering
44147
+ * rather than refusing.
44148
+ */
44149
+ var HKSV_FRAGMENT_LENGTHS_MS = [4e3, 8e3];
44150
+ /**
44151
+ * AAC-LC at 24 kHz mono. Fixed rather than negotiated: an fMP4 fragment carries
44152
+ * its audio in-band, so unlike the live SRTP path there is no second plane on
44153
+ * which to answer a different sample rate, and D80 records that HKSV takes AAC
44154
+ * and nothing else.
44155
+ */
44156
+ var HKSV_AUDIO_SAMPLE_RATE_HZ = 24e3;
44157
+ /**
44158
+ * The advertised fragment length for a camera whose key-frame cadence is
44159
+ * `sourceGopMs`, or `null` when no offered length covers it.
44160
+ *
44161
+ * `undefined` — the camera does not report a GOP — takes the shortest offered
44162
+ * length. That is a guess, and it is the RIGHT guess (4 s is the near-universal
44163
+ * default), but it is a guess: the delegate measures the arriving cadence and
44164
+ * says so when reality disagrees.
44165
+ */
44166
+ function deriveFragmentLengthMs(sourceGopMs) {
44167
+ const shortest = HKSV_FRAGMENT_LENGTHS_MS[0];
44168
+ if (shortest === void 0) return null;
44169
+ if (sourceGopMs === void 0 || sourceGopMs <= 0) return shortest;
44170
+ return HKSV_FRAGMENT_LENGTHS_MS.find((ms) => ms >= sourceGopMs) ?? null;
44171
+ }
44172
+ /**
44173
+ * Build the advertisement.
44174
+ *
44175
+ * ONE resolution is advertised — the one slot the recording child pulls. HAP's
44176
+ * documentation lists 1920×1080 and 1280×720 as "required to be supported", and
44177
+ * listing both when the source is only one of them is precisely the D50 failure
44178
+ * in miniature: iOS would select a configuration we then cannot deliver, on the
44179
+ * copy branch, with no encoder to resize with.
44180
+ */
44181
+ function buildRecordingOptions(input) {
44182
+ const resolution = [
44183
+ input.width,
44184
+ input.height,
44185
+ Math.max(1, Math.round(input.fps))
44186
+ ];
44187
+ return {
44188
+ prebufferLength: HKSV_PREBUFFER_MS,
44189
+ mediaContainerConfiguration: {
44190
+ type: MediaContainerType.FRAGMENTED_MP4,
44191
+ fragmentLength: input.fragmentLengthMs
44192
+ },
44193
+ video: {
44194
+ type: VideoCodecType.H264,
44195
+ parameters: {
44196
+ profiles: [
44197
+ H264Profile.BASELINE,
44198
+ H264Profile.MAIN,
44199
+ H264Profile.HIGH
44200
+ ],
44201
+ levels: [
44202
+ H264Level.LEVEL3_1,
44203
+ H264Level.LEVEL3_2,
44204
+ H264Level.LEVEL4_0
44205
+ ]
44206
+ },
44207
+ resolutions: [resolution]
44208
+ },
44209
+ audio: { codecs: [{
44210
+ type: AudioRecordingCodecType.AAC_LC,
44211
+ audioChannels: 1,
44212
+ bitrateMode: AudioBitrate.VARIABLE,
44213
+ samplerate: [AudioRecordingSamplerate.KHZ_24]
44214
+ }] }
44215
+ };
44216
+ }
44217
+ //#endregion
44218
+ //#region src/hksv/fragment-source.ts
44219
+ /**
44220
+ * How far back the prebuffer ring reaches.
44221
+ *
44222
+ * Twice {@link HKSV_PREBUFFER_MS}, and the factor is structural rather than
44223
+ * generous: the ring holds WHOLE fragments, so a window of exactly 4 s can hold
44224
+ * a single 4 s fragment that is about to age out — a trigger landing a moment
44225
+ * later would replay nothing. Two fragment lengths guarantee at least one
44226
+ * covering fragment at every instant.
44227
+ */
44228
+ var PREBUFFER_WINDOW_MS = HKSV_PREBUFFER_MS * 2;
44229
+ /**
44230
+ * The ring's hard byte ceiling, per camera.
44231
+ *
44232
+ * Measured fragment sizes on this fleet: ~145 KB for 4 s at 720p, ~3.6 MB for
44233
+ * 4 s at 4K. 16 MB covers two 4K fragments with room and bounds the exporter's
44234
+ * heap at a figure an operator can multiply by the camera count — which is the
44235
+ * number a time-only bound refuses to give.
44236
+ */
44237
+ var PREBUFFER_MAX_BYTES = 16 * 1024 * 1024;
44238
+ /** Backoff after a child that died while live. Bounded, never a tight loop. */
44239
+ var RESPAWN_BACKOFF_MS = [
44240
+ 2e3,
44241
+ 5e3,
44242
+ 15e3,
44243
+ 3e4
44244
+ ];
44245
+ var HksvFragmentSource = class {
44246
+ input;
44247
+ plane = null;
44248
+ child = null;
44249
+ stopped = false;
44250
+ starting = null;
44251
+ respawnAttempt = 0;
44252
+ respawnTimer = null;
44253
+ audioActive;
44254
+ log;
44255
+ constructor(input) {
44256
+ this.input = input;
44257
+ this.audioActive = input.audioActive;
44258
+ this.log = input.logger;
44259
+ }
44260
+ /** True once a child has produced its initialisation segment. */
44261
+ get isRunning() {
44262
+ return this.child !== null && this.plane !== null && !this.plane.isEnded;
44263
+ }
44264
+ /**
44265
+ * Spawn the child and start filling the ring. Idempotent, and concurrent
44266
+ * calls share one attempt — `updateRecordingActive(true)` and a stream
44267
+ * request can arrive in either order.
44268
+ */
44269
+ async start() {
44270
+ if (this.stopped) throw new Error("hksv fragment source: already stopped");
44271
+ if (this.isRunning) return;
44272
+ const inflight = this.starting;
44273
+ if (inflight !== null) return inflight;
44274
+ const attempt = this.spawn();
44275
+ this.starting = attempt;
44276
+ try {
44277
+ await attempt;
44278
+ } finally {
44279
+ this.starting = null;
44280
+ }
44281
+ }
44282
+ /** Stop the child, end the plane, forget the ring. Idempotent. */
44283
+ async stop(reason) {
44284
+ if (this.stopped) return;
44285
+ this.stopped = true;
44286
+ this.clearRespawn();
44287
+ this.log.info("hksv fragment source: stopping", {
44288
+ tags: { deviceId: this.input.deviceId },
44289
+ meta: {
44290
+ reason,
44291
+ brokerId: this.input.source.brokerId
44292
+ }
44293
+ });
44294
+ const child = this.child;
44295
+ this.child = null;
44296
+ if (child) await child.stop();
44297
+ this.plane?.dispose();
44298
+ this.plane = null;
44299
+ }
44300
+ /**
44301
+ * iOS turned recording audio on or off. Respawns onto the other url when it
44302
+ * genuinely changed — the fragments themselves must carry or omit the track,
44303
+ * there is nothing to strip downstream.
44304
+ */
44305
+ async setAudioActive(active) {
44306
+ if (active === this.audioActive) return;
44307
+ this.audioActive = active;
44308
+ this.log.info("hksv fragment source: RecordingAudioActive changed — respawning the child", {
44309
+ tags: { deviceId: this.input.deviceId },
44310
+ meta: {
44311
+ audioActive: active,
44312
+ brokerId: this.input.source.brokerId
44313
+ }
44314
+ });
44315
+ if (!this.isRunning) return;
44316
+ const child = this.child;
44317
+ this.child = null;
44318
+ if (child) await child.stop();
44319
+ this.plane?.dispose();
44320
+ this.plane = null;
44321
+ await this.start();
44322
+ }
44323
+ /**
44324
+ * Subscribe to the live fragments, replaying the prebuffer first.
44325
+ *
44326
+ * Returns `null` when there is no plane — the caller MUST treat that as
44327
+ * "cannot serve this recording" rather than opening an HDS stream it cannot
44328
+ * feed, which is the D50 failure exactly.
44329
+ */
44330
+ subscribe(tag) {
44331
+ const plane = this.plane;
44332
+ if (plane === null || plane.isEnded) return null;
44333
+ const stats = plane.prebufferStats();
44334
+ this.log.info("hksv fragment source: subscribing a recording stream", {
44335
+ tags: { deviceId: this.input.deviceId },
44336
+ meta: {
44337
+ tag,
44338
+ prebufferFragments: stats.fragments,
44339
+ prebufferBytes: stats.bytes,
44340
+ prebufferSpanMs: stats.spanMs
44341
+ }
44342
+ });
44343
+ return plane.subscribe({
44344
+ tag,
44345
+ withPrebuffer: true
44346
+ });
44347
+ }
44348
+ /** What the ring holds — surfaced so the delegate can log what it served. */
44349
+ prebufferSpanMs() {
44350
+ return this.plane?.prebufferStats().spanMs ?? 0;
44351
+ }
44352
+ async spawn() {
44353
+ const plane = new Fmp4FragmentPlane(this.log.child("hksv-plane"), {
44354
+ windowMs: PREBUFFER_WINDOW_MS,
44355
+ maxBytes: PREBUFFER_MAX_BYTES
44356
+ }, this.input.now ?? Date.now);
44357
+ const child = new Fmp4FragmentChild({
44358
+ logger: this.log.child("hksv-fmp4"),
44359
+ ffmpegBinaryPath: this.input.ffmpegBinaryPath,
44360
+ spawnFn: this.input.spawnFn,
44361
+ onChildExit: (error) => this.onChildExit(error)
44362
+ }, {
44363
+ sourceId: `hksv/${this.input.deviceId}`,
44364
+ deviceId: this.input.deviceId,
44365
+ fragmentMs: this.input.fragmentMs,
44366
+ invocation: this.buildInvocation(),
44367
+ plane
44368
+ });
44369
+ this.plane = plane;
44370
+ this.child = child;
44371
+ try {
44372
+ await child.start();
44373
+ this.respawnAttempt = 0;
44374
+ this.log.info("hksv fragment source: prebuffer running", {
44375
+ tags: { deviceId: this.input.deviceId },
44376
+ meta: {
44377
+ brokerId: this.input.source.brokerId,
44378
+ resolution: `${this.input.source.width}x${this.input.source.height}`,
44379
+ fragmentMs: this.input.fragmentMs,
44380
+ audioActive: this.audioActive,
44381
+ windowMs: PREBUFFER_WINDOW_MS
44382
+ }
44383
+ });
44384
+ } catch (err) {
44385
+ this.plane = null;
44386
+ this.child = null;
44387
+ plane.dispose();
44388
+ throw err;
44389
+ }
44390
+ }
44391
+ /**
44392
+ * The child died while live. The prebuffer is gone with it — and saying so is
44393
+ * the point: a source that silently stopped filling reads, from the delegate,
44394
+ * exactly like a camera nothing ever happens on.
44395
+ */
44396
+ onChildExit(error) {
44397
+ if (this.stopped) return;
44398
+ this.child = null;
44399
+ this.plane = null;
44400
+ const delay = RESPAWN_BACKOFF_MS[Math.min(this.respawnAttempt, RESPAWN_BACKOFF_MS.length - 1)];
44401
+ this.respawnAttempt += 1;
44402
+ this.log.warn("hksv fragment source: the child DIED — the prebuffer is empty until it respawns", {
44403
+ tags: { deviceId: this.input.deviceId },
44404
+ meta: {
44405
+ brokerId: this.input.source.brokerId,
44406
+ attempt: this.respawnAttempt,
44407
+ respawnInMs: delay,
44408
+ error: error.message
44409
+ }
44410
+ });
44411
+ this.clearRespawn();
44412
+ const schedule = this.input.setTimeoutFn ?? setTimeout;
44413
+ this.respawnTimer = schedule(() => {
44414
+ this.respawnTimer = null;
44415
+ if (this.stopped) return;
44416
+ this.start().catch((err) => {
44417
+ this.log.warn("hksv fragment source: respawn failed", {
44418
+ tags: { deviceId: this.input.deviceId },
44419
+ meta: {
44420
+ brokerId: this.input.source.brokerId,
44421
+ error: err instanceof Error ? err.message : String(err)
44422
+ }
44423
+ });
44424
+ });
44425
+ }, delay ?? 3e4);
44426
+ this.respawnTimer?.unref?.();
44427
+ }
44428
+ clearRespawn() {
44429
+ if (this.respawnTimer !== null) {
44430
+ clearTimeout(this.respawnTimer);
44431
+ this.respawnTimer = null;
44432
+ }
44433
+ }
44434
+ /**
44435
+ * The invocation, minus the sink the child owns.
44436
+ *
44437
+ * `kind: 'copy'` is not a preference: it is the 128× measurement, and it is
44438
+ * why `pickRecordingSource` refuses a camera whose only slots are H.265. The
44439
+ * audio IS encoded — the source mic is G.711/PCM depending on vendor and HKSV
44440
+ * takes AAC only — which the same measurement priced at 0.4 % of a core.
44441
+ */
44442
+ buildInvocation() {
44443
+ const audio = this.audioActive ? {
44444
+ kind: "encode",
44445
+ codec: "aac",
44446
+ bitrateKbps: 32,
44447
+ sampleRateHz: HKSV_AUDIO_SAMPLE_RATE_HZ,
44448
+ channels: 1
44449
+ } : { kind: "none" };
44450
+ return {
44451
+ logLevel: "error",
44452
+ decodeHwAccel: null,
44453
+ input: {
44454
+ url: this.audioActive ? this.input.source.url : this.input.source.mutedUrl,
44455
+ rtspTransport: "tcp",
44456
+ analyzeDurationUs: 1e6,
44457
+ probeSizeBytes: 1e6
44458
+ },
44459
+ video: { kind: "copy" },
44460
+ audio,
44461
+ threadCount: 0,
44462
+ outputArgs: []
44463
+ };
44464
+ }
44465
+ };
44466
+ //#endregion
44467
+ //#region src/hksv/recording-delegate.ts
44468
+ /**
44469
+ * `HDSProtocolSpecificErrorReason` is a `const enum`, so there is no reverse
44470
+ * map to index — and a bare number in the log is the difference between "iOS
44471
+ * closed it normally" and "iOS rejected our data", which is the whole reason
44472
+ * this line exists.
44473
+ */
44474
+ var HDS_REASON_NAMES = {
44475
+ [HDSProtocolSpecificErrorReason.NORMAL]: "normal",
44476
+ [HDSProtocolSpecificErrorReason.NOT_ALLOWED]: "not-allowed",
44477
+ [HDSProtocolSpecificErrorReason.BUSY]: "busy",
44478
+ [HDSProtocolSpecificErrorReason.CANCELLED]: "cancelled",
44479
+ [HDSProtocolSpecificErrorReason.UNSUPPORTED]: "unsupported",
44480
+ [HDSProtocolSpecificErrorReason.UNEXPECTED_FAILURE]: "unexpected-failure",
44481
+ [HDSProtocolSpecificErrorReason.TIMEOUT]: "timeout",
44482
+ [HDSProtocolSpecificErrorReason.BAD_DATA]: "bad-data",
44483
+ [HDSProtocolSpecificErrorReason.PROTOCOL_ERROR]: "protocol-error",
44484
+ [HDSProtocolSpecificErrorReason.INVALID_CONFIGURATION]: "invalid-configuration"
44485
+ };
44486
+ function hdsReasonName(reason) {
44487
+ return HDS_REASON_NAMES[reason] ?? `unknown(${String(reason)})`;
44488
+ }
44489
+ var HksvRecordingDelegate = class {
44490
+ input;
44491
+ active = false;
44492
+ configuration = void 0;
44493
+ source = null;
44494
+ log;
44495
+ /** The stream currently being yielded, so `closeRecordingStream` can end it. */
44496
+ open = null;
44497
+ constructor(input) {
44498
+ this.input = input;
44499
+ this.log = input.logger;
44500
+ }
44501
+ /** Test/diagnostic view — the prebuffer is running for this camera. */
44502
+ get prebufferRunning() {
44503
+ return this.source?.isRunning === true;
44504
+ }
44505
+ updateRecordingActive(active) {
44506
+ if (active === this.active) return;
44507
+ this.active = active;
44508
+ this.log.info("hksv: recording active changed", {
44509
+ tags: { deviceId: this.input.deviceId },
44510
+ meta: {
44511
+ active,
44512
+ hasConfiguration: this.configuration !== void 0
44513
+ }
44514
+ });
44515
+ this.reconcile("recording-active");
44516
+ }
44517
+ updateRecordingConfiguration(configuration) {
44518
+ this.configuration = configuration;
44519
+ if (configuration === void 0) {
44520
+ this.log.info("hksv: the selected configuration was CLEARED — stopping the prebuffer", { tags: { deviceId: this.input.deviceId } });
44521
+ this.reconcile("configuration-cleared");
44522
+ return;
44523
+ }
44524
+ const selectedMs = configuration.mediaContainerConfiguration.fragmentLength;
44525
+ this.log.info("hksv: iOS selected a recording configuration", {
44526
+ tags: { deviceId: this.input.deviceId },
44527
+ meta: {
44528
+ fragmentLengthMs: selectedMs,
44529
+ prebufferLengthMs: configuration.prebufferLength,
44530
+ resolution: configuration.videoCodec.resolution.join("x"),
44531
+ audioCodec: configuration.audioCodec.type,
44532
+ eventTriggers: configuration.eventTriggerTypes
44533
+ }
44534
+ });
44535
+ if (selectedMs < this.input.advertisedFragmentMs) this.log.warn("hksv: iOS selected a SHORTER fragment length than the source can cut", {
44536
+ tags: { deviceId: this.input.deviceId },
44537
+ meta: {
44538
+ selectedMs,
44539
+ advertisedMs: this.input.advertisedFragmentMs
44540
+ }
44541
+ });
44542
+ this.reconcile("configuration-selected");
44543
+ }
44544
+ async *handleRecordingStreamRequest(streamId, signal) {
44545
+ const source = this.source;
44546
+ if (source === null || !source.isRunning) {
44547
+ this.log.warn("hksv: recording stream requested with NO prebuffer running — refusing", {
44548
+ tags: { deviceId: this.input.deviceId },
44549
+ meta: {
44550
+ streamId,
44551
+ active: this.active,
44552
+ hasConfiguration: this.configuration !== void 0
44553
+ }
44554
+ });
44555
+ throw new HDSProtocolError(HDSProtocolSpecificErrorReason.NOT_ALLOWED);
44556
+ }
44557
+ const subscription = source.subscribe(`hksv/${this.input.deviceId}#${streamId}`);
44558
+ if (subscription === null) {
44559
+ this.log.warn("hksv: the fragment plane refused a subscription — refusing the stream", {
44560
+ tags: { deviceId: this.input.deviceId },
44561
+ meta: { streamId }
44562
+ });
44563
+ throw new HDSProtocolError(HDSProtocolSpecificErrorReason.NOT_ALLOWED);
44564
+ }
44565
+ this.open = {
44566
+ streamId,
44567
+ subscription
44568
+ };
44569
+ const startedAt = Date.now();
44570
+ const prebufferSpanMs = source.prebufferSpanMs();
44571
+ let packets = 0;
44572
+ let bytes = 0;
44573
+ let markedLast = false;
44574
+ let longestFragmentGapMs = 0;
44575
+ let lastPacketAt = startedAt;
44576
+ try {
44577
+ for await (const packet of subscription.packets()) {
44578
+ if (signal?.aborted === true) {
44579
+ this.log.info("hksv: the recording stream was aborted — ending the generator", {
44580
+ tags: { deviceId: this.input.deviceId },
44581
+ meta: {
44582
+ streamId,
44583
+ packets
44584
+ }
44585
+ });
44586
+ return;
44587
+ }
44588
+ packets += 1;
44589
+ bytes += packet.data.length;
44590
+ if (packet.kind === "fragment") {
44591
+ const now = Date.now();
44592
+ longestFragmentGapMs = Math.max(longestFragmentGapMs, now - lastPacketAt);
44593
+ lastPacketAt = now;
44594
+ }
44595
+ markedLast = markedLast || packet.isLast;
44596
+ yield {
44597
+ data: Buffer.from(packet.data),
44598
+ isLast: packet.isLast
44599
+ };
44600
+ if (packet.isLast) return;
44601
+ }
44602
+ if (!markedLast && subscription.closedReason !== "released") {
44603
+ this.log.warn("hksv: the fragment stream ended without a final packet", {
44604
+ tags: { deviceId: this.input.deviceId },
44605
+ meta: {
44606
+ streamId,
44607
+ packets,
44608
+ closedReason: subscription.closedReason,
44609
+ truncated: subscription.closedReason === "slow-consumer"
44610
+ }
44611
+ });
44612
+ if (packets === 0) throw new HDSProtocolError(HDSProtocolSpecificErrorReason.UNEXPECTED_FAILURE);
44613
+ yield {
44614
+ data: Buffer.alloc(0),
44615
+ isLast: true
44616
+ };
44617
+ }
44618
+ } finally {
44619
+ subscription.release();
44620
+ if (this.open?.streamId === streamId) this.open = null;
44621
+ const fragmentOverrun = longestFragmentGapMs > this.input.advertisedFragmentMs * 1.5;
44622
+ this.log.info("hksv: recording stream finished", {
44623
+ tags: { deviceId: this.input.deviceId },
44624
+ meta: {
44625
+ streamId,
44626
+ packets,
44627
+ bytes,
44628
+ durationMs: Date.now() - startedAt,
44629
+ prebufferSpanMs,
44630
+ longestFragmentGapMs,
44631
+ closedReason: subscription.closedReason,
44632
+ markedLast
44633
+ }
44634
+ });
44635
+ if (fragmentOverrun) this.log.warn("hksv: fragments arrived LONGER than the advertised length", {
44636
+ tags: { deviceId: this.input.deviceId },
44637
+ meta: {
44638
+ longestFragmentGapMs,
44639
+ advertisedMs: this.input.advertisedFragmentMs
44640
+ }
44641
+ });
44642
+ }
44643
+ }
44644
+ acknowledgeStream(streamId) {
44645
+ this.log.info("hksv: iOS acknowledged the end of stream — the clip landed", {
44646
+ tags: { deviceId: this.input.deviceId },
44647
+ meta: { streamId }
44648
+ });
44649
+ }
44650
+ closeRecordingStream(streamId, reason) {
44651
+ this.log.info("hksv: the recording stream was closed by the controller", {
44652
+ tags: { deviceId: this.input.deviceId },
44653
+ meta: {
44654
+ streamId,
44655
+ reason: reason === void 0 ? "connection-closed" : hdsReasonName(reason)
44656
+ }
44657
+ });
44658
+ const open = this.open;
44659
+ if (open?.streamId === streamId) {
44660
+ open.subscription.release();
44661
+ this.open = null;
44662
+ }
44663
+ }
44664
+ /** Tear the prebuffer down — the accessory is being unexposed. */
44665
+ async dispose() {
44666
+ this.open?.subscription.release();
44667
+ this.open = null;
44668
+ const source = this.source;
44669
+ this.source = null;
44670
+ if (source) await source.stop("accessory disposed");
44671
+ }
44672
+ /**
44673
+ * Start the prebuffer when iOS wants recording AND has chosen how, stop it
44674
+ * otherwise. Called from every state edge rather than each edge deciding for
44675
+ * itself: the two characteristics arrive in an order hap-nodejs explicitly
44676
+ * does not guarantee, and a per-edge decision has to re-derive the same
44677
+ * conjunction in two places.
44678
+ */
44679
+ async reconcile(trigger) {
44680
+ if (!(this.active && this.configuration !== void 0)) {
44681
+ const source = this.source;
44682
+ this.source = null;
44683
+ if (source) await source.stop(`recording no longer wanted (${trigger})`);
44684
+ return;
44685
+ }
44686
+ const configuration = this.configuration;
44687
+ if (configuration === void 0) return;
44688
+ const audioActive = this.input.isAudioActive();
44689
+ const existing = this.source;
44690
+ if (existing !== null) {
44691
+ await existing.setAudioActive(audioActive);
44692
+ if (!existing.isRunning) await existing.start();
44693
+ return;
44694
+ }
44695
+ const source = this.input.createSource({
44696
+ fragmentMs: configuration.mediaContainerConfiguration.fragmentLength,
44697
+ audioActive
44698
+ });
44699
+ this.source = source;
44700
+ try {
44701
+ await source.start();
44702
+ } catch (err) {
44703
+ this.source = null;
44704
+ this.log.error("hksv: the prebuffer FAILED to start — this camera will record nothing", {
44705
+ tags: { deviceId: this.input.deviceId },
44706
+ meta: {
44707
+ trigger,
44708
+ error: err instanceof Error ? err.message : String(err)
44709
+ }
44710
+ });
44711
+ }
44712
+ }
44713
+ };
44714
+ //#endregion
44715
+ //#region src/hksv/recording-source.ts
44716
+ /** The tallest frame the recording path will hold in its prebuffer. */
44717
+ var MAX_RECORDING_HEIGHT = 1080;
44718
+ /** HKSV takes H.264 only — AAC audio and H.264 video, no negotiation. */
44719
+ var RECORDABLE_CODEC = "h264";
44720
+ /**
44721
+ * Pick the slot the recording child pulls.
44722
+ *
44723
+ * Deliberately NOT `pickPreferredRtspEntry`: that picker resolves the operator's
44724
+ * LIVE preference and, on `auto`, steers by the resolution iOS negotiated for a
44725
+ * live session — neither is a fact about recording, and on 615 it selects `mid`,
44726
+ * a 10 fps slot. Recording has one criterion, applied here and nowhere else:
44727
+ * the largest copyable frame that does not exceed {@link MAX_RECORDING_HEIGHT}.
44728
+ */
44729
+ function pickRecordingSource(entries) {
44730
+ const enabled = entries.filter((e) => e.enabled);
44731
+ if (enabled.length === 0) return {
44732
+ ok: false,
44733
+ refusal: "no-enabled-stream"
44734
+ };
44735
+ const h264 = enabled.filter((e) => normaliseCodec(e.codec) === RECORDABLE_CODEC);
44736
+ if (h264.length === 0) return {
44737
+ ok: false,
44738
+ refusal: "no-h264-stream"
44739
+ };
44740
+ const sized = h264.filter(hasUsableResolution);
44741
+ if (sized.length === 0) return {
44742
+ ok: false,
44743
+ refusal: "no-resolution"
44744
+ };
44745
+ const withinCeiling = sized.filter((e) => height(e) <= MAX_RECORDING_HEIGHT);
44746
+ const best = [...withinCeiling.length > 0 ? withinCeiling : sized].sort((a, b) => withinCeiling.length > 0 ? height(b) - height(a) : height(a) - height(b))[0];
44747
+ if (best === void 0 || best.resolution === void 0) return {
44748
+ ok: false,
44749
+ refusal: "no-resolution"
44750
+ };
44751
+ return {
44752
+ ok: true,
44753
+ source: {
44754
+ brokerId: best.brokerId,
44755
+ profile: best.profile ?? best.brokerId,
44756
+ url: best.url,
44757
+ mutedUrl: best.mutedUrl,
44758
+ width: best.resolution.width,
44759
+ height: best.resolution.height
44760
+ }
44761
+ };
44762
+ }
44763
+ /** A one-line reason for the log — silence about a withdrawn service reads as a bug. */
44764
+ function refusalReason(refusal) {
44765
+ switch (refusal) {
44766
+ case "no-enabled-stream": return "the camera has no enabled RTSP profile";
44767
+ case "no-h264-stream": return "every enabled profile is H.265 — HKSV takes H.264 only, and a permanent transcode costs 128x a copy";
44768
+ case "no-resolution": return "no enabled profile declares a resolution, so nothing honest could be advertised";
44769
+ }
44770
+ }
44771
+ function height(entry) {
44772
+ return entry.resolution?.height ?? 0;
44773
+ }
44774
+ function hasUsableResolution(entry) {
44775
+ const r = entry.resolution;
44776
+ return r !== void 0 && r.width > 0 && r.height > 0;
44777
+ }
44778
+ /** Publishers spell H.265 four ways; the same normalisation the broker uses. */
44779
+ function normaliseCodec(codec) {
44780
+ return (codec ?? "").toLowerCase().replace(/[.\s-]/g, "");
44781
+ }
44782
+ //#endregion
44783
+ //#region src/hksv/build-recording.ts
44784
+ /**
44785
+ * Assemble HomeKit Secure Video for one camera — the ADVERTISEMENT and the
44786
+ * DELEGATE, together, or neither.
44787
+ *
44788
+ * That pairing is the whole rule and it is why nothing shipped for HKSV before
44789
+ * this: `recording` is optional on `CameraControllerOptions`, and passing it IS
44790
+ * the entire user-visible change. There is no "phase 1 behind a flag" for an
44791
+ * advertisement — either iOS is offered a recording toggle backed by a delegate
44792
+ * that yields real fragments, or the services are not on the accessory at all
44793
+ * ([D50](../../../../docs/decisions/adr-0050.md)).
44794
+ *
44795
+ * So this returns `null` for every reason a camera cannot record, and each of
44796
+ * them is logged at `info`/`warn` with `tags: { deviceId }`. A withdrawn
44797
+ * capability that says nothing is indistinguishable from a bug — and on this
44798
+ * surface the operator's first question is always "why does 617 have it and 615
44799
+ * not?".
44800
+ */
44801
+ /**
44802
+ * The ffmpeg on `PATH`, exactly as the live streaming path resolves it
44803
+ * (`camera-streams.ts` spawns `'ffmpeg'`). One resolution per addon, not two.
44804
+ */
44805
+ var FFMPEG_BINARY = "ffmpeg";
44806
+ /** Fallback when nothing measured a rate for the picked slot. */
44807
+ var ASSUMED_RECORDING_FPS = 15;
44808
+ async function buildHksvRecording(input) {
44809
+ const { bctx } = input;
44810
+ const { ctx, numericDeviceId } = bctx;
44811
+ const log = ctx.logger.withTags({ deviceId: numericDeviceId });
44812
+ const entries = await readProfileEntries(bctx);
44813
+ if (entries === null) {
44814
+ log.warn("export-hap: HKSV withheld — could not read the camera profiles", {});
44815
+ return null;
44816
+ }
44817
+ const choice = pickRecordingSource(entries);
44818
+ if (!choice.ok) {
44819
+ log.info("export-hap: HKSV withheld — no recordable stream", { meta: {
44820
+ refusal: choice.refusal,
44821
+ reason: refusalReason(choice.refusal)
44822
+ } });
44823
+ return null;
44824
+ }
44825
+ const source = choice.source;
44826
+ const gopMs = await readSourceGopMs(bctx, source.width, source.height);
44827
+ const fragmentLengthMs = deriveFragmentLengthMs(gopMs);
44828
+ if (fragmentLengthMs === null) {
44829
+ log.warn("export-hap: HKSV withheld — the camera key-frame interval is longer than any fragment length we advertise", { meta: {
44830
+ gopMs,
44831
+ brokerId: source.brokerId
44832
+ } });
44833
+ return null;
44834
+ }
44835
+ const fps = resolveFps(input.fpsByProfile, source.profile);
44836
+ const options = buildRecordingOptions({
44837
+ width: source.width,
44838
+ height: source.height,
44839
+ fps,
44840
+ fragmentLengthMs
44841
+ });
44842
+ const delegate = new HksvRecordingDelegate({
44843
+ logger: log,
44844
+ deviceId: numericDeviceId,
44845
+ isAudioActive: input.isAudioActive,
44846
+ advertisedFragmentMs: fragmentLengthMs,
44847
+ createSource: ({ fragmentMs, audioActive }) => new HksvFragmentSource({
44848
+ logger: log,
44849
+ deviceId: numericDeviceId,
44850
+ ffmpegBinaryPath: FFMPEG_BINARY,
44851
+ spawnFn: spawn,
44852
+ source,
44853
+ fragmentMs,
44854
+ audioActive
44855
+ })
44856
+ });
44857
+ log.info("export-hap: HKSV ADVERTISED — recording is offered for this camera", { meta: {
44858
+ brokerId: source.brokerId,
44859
+ profile: source.profile,
44860
+ resolution: `${source.width}x${source.height}`,
44861
+ fps,
44862
+ fragmentLengthMs,
44863
+ sourceGopMs: gopMs ?? "unknown"
44864
+ } });
44865
+ return {
44866
+ options,
44867
+ delegate,
44868
+ dispose: () => delegate.dispose()
44869
+ };
44870
+ }
44871
+ /** `cameraStreams.getProfileRtspEntries`, or `null` when the cap is unreachable. */
44872
+ async function readProfileEntries(bctx) {
44873
+ try {
44874
+ return await bctx.proxy.cameraStreams?.getProfileRtspEntries({}) ?? null;
44875
+ } catch {
44876
+ return null;
44877
+ }
44878
+ }
44879
+ /**
44880
+ * The camera's own key-frame interval in ms, from `stream-params`, matched to
44881
+ * the picked slot BY RESOLUTION.
44882
+ *
44883
+ * By resolution and not by name on purpose: `stream-params` names its profiles
44884
+ * `main`/`sub`/`ext` while the broker names its slots `high`/`mid`/`low`, and
44885
+ * on 615 `ext` is the 1280×720 slot the broker calls `mid` — a name-based match
44886
+ * would silently read the 4K slot's GOP for a 720p recording.
44887
+ *
44888
+ * `undefined` when the cap is not bound, which is most non-Hikvision providers.
44889
+ */
44890
+ async function readSourceGopMs(bctx, width, height) {
44891
+ try {
44892
+ const status = await bctx.proxy.streamParams?.getStatus({});
44893
+ if (!status) return void 0;
44894
+ for (const profile of [
44895
+ status.main,
44896
+ status.sub,
44897
+ status.ext
44898
+ ]) {
44899
+ if (!profile) continue;
44900
+ if (profile.width !== width || profile.height !== height) continue;
44901
+ const { gop, framerate } = profile;
44902
+ if (gop === void 0 || gop <= 0 || framerate <= 0) return void 0;
44903
+ return Math.round(gop / framerate * 1e3);
44904
+ }
44905
+ return;
44906
+ } catch {
44907
+ return;
44908
+ }
44909
+ }
44910
+ function resolveFps(fpsByProfile, profile) {
44911
+ return fpsByProfile.get(profile)?.fps ?? ASSUMED_RECORDING_FPS;
44912
+ }
44913
+ //#endregion
41630
44914
  //#region src/mappers/builders/child-switch.ts
41631
44915
  /**
41632
44916
  * Child-switch builder — turns a camstack accessory child device (siren,
@@ -41671,7 +44955,7 @@ async function buildChildSwitch(bctx, subtype, deviceType) {
41671
44955
  const isLightingDevice = deviceType === DeviceType.Light || deviceType === DeviceType.Generic;
41672
44956
  const useLightbulb = hasBrightness && isLightingDevice;
41673
44957
  const service = useLightbulb ? accessory.addService(Service.Lightbulb, displayName, subtype) : accessory.addService(Service.Switch, displayName, subtype);
41674
- service.setCharacteristic(Characteristic.Name, displayName);
44958
+ applyServiceLabel(service, displayName);
41675
44959
  try {
41676
44960
  const switchStatus = await proxy.switch?.getStatus({});
41677
44961
  if (switchStatus && typeof switchStatus.on === "boolean") service.updateCharacteristic(Characteristic.On, switchStatus.on);
@@ -41832,19 +45116,39 @@ async function buildCameraAccessory(input) {
41832
45116
  displayName,
41833
45117
  options
41834
45118
  };
41835
- const streams = buildCameraStreamingDelegate(bctx, await probeAdvertisedVideoProfile(bctx));
45119
+ const advertisedVideo = await probeAdvertisedVideoProfile(bctx);
45120
+ const streams = buildCameraStreamingDelegate(bctx, advertisedVideo);
41836
45121
  const handles = [];
41837
45122
  if (capNames.has("intercom")) handles.push(await buildIntercom({
41838
45123
  bctx,
41839
45124
  streamingOptions: streams.streamingOptions
41840
45125
  }));
45126
+ const recordingEnabled = options.hapDeviceSettings.hksvRecording === true;
45127
+ let recordingAudioActive = true;
45128
+ const recording = recordingEnabled ? await buildHksvRecording({
45129
+ bctx,
45130
+ fpsByProfile: advertisedVideo.fpsByProfile,
45131
+ isAudioActive: () => recordingAudioActive
45132
+ }) : null;
41841
45133
  const controller = new (isDoorbell ? DoorbellController : CameraController)({
41842
45134
  delegate: streams.delegate,
41843
45135
  streamingOptions: streams.streamingOptions,
41844
- cameraStreamCount: 2
45136
+ cameraStreamCount: 2,
45137
+ ...recording === null ? {} : { recording },
45138
+ ...recording === null || !capNames.has("motion-detection") ? {} : { sensors: { motion: true } }
41845
45139
  });
41846
45140
  accessory.configureController(controller);
41847
- if (capNames.has("motion-detection")) handles.push(await buildMotionSensor(bctx));
45141
+ if (recording !== null) {
45142
+ handles.push({ dispose: () => recording.dispose() });
45143
+ const audioCharacteristic = (controller.recordingManagement?.operatingModeService)?.getCharacteristic(Characteristic.RecordingAudioActive);
45144
+ if (audioCharacteristic) {
45145
+ recordingAudioActive = audioCharacteristic.value !== 0 && audioCharacteristic.value !== false;
45146
+ audioCharacteristic.on("change", ({ newValue }) => {
45147
+ recordingAudioActive = newValue !== 0 && newValue !== false;
45148
+ });
45149
+ }
45150
+ }
45151
+ if (capNames.has("motion-detection")) handles.push(await buildMotionSensor(bctx, recording === null ? null : controller.motionService ?? null));
41848
45152
  if (isDoorbell && controller instanceof DoorbellController) handles.push(await buildDoorbell({
41849
45153
  bctx,
41850
45154
  controller
@@ -41911,6 +45215,118 @@ function pickMapperKind(_capabilities) {
41911
45215
  return "camera";
41912
45216
  }
41913
45217
  //#endregion
45218
+ //#region src/mappers/builders/stream-hwaccel-memo.ts
45219
+ /**
45220
+ * The two bounded memos HomeKit's decode path owns.
45221
+ *
45222
+ * ## Why they exist
45223
+ *
45224
+ * Everything D67 was actually about — one argv builder, one set of constants,
45225
+ * one hwaccel authority — HomeKit already had. What it did NOT have were the
45226
+ * two things the broker gained alongside them:
45227
+ *
45228
+ * 1. **A memo.** `probeDecoderHwaccel` issued a cross-process
45229
+ * `decoder.getInfo` per SESSION. iOS starts sessions in bursts — one on
45230
+ * record was started three times in 16 s — and every one of those paid a
45231
+ * cap call on a hub whose main thread is the scarce resource.
45232
+ * 2. **Failure feedback.** When HomeKit's hardware child died at init and
45233
+ * `shouldRetryInSoftware` saved the session, HomeKit told nobody. The next
45234
+ * session re-picked the same corpse and paid the same two-second death.
45235
+ * `EgressTranscodeManager` fixed exactly this for its own children
45236
+ * (26a522cd5) by reporting the dead backend into the broker's 60 s memo.
45237
+ *
45238
+ * ## Both ride `HwAccelCache`, deliberately
45239
+ *
45240
+ * `createHwAccelCache` from `@camstack/types` is the primitive the broker's own
45241
+ * `egressHwAccelCache` is built from, and the discipline it encodes is the
45242
+ * point: **caller-owned, never a module global** — a module global would
45243
+ * outlive an addon respawn and survive an operator changing the decoder
45244
+ * backend. Same TTL as the broker's, so "has hardware come back yet" cannot
45245
+ * answer differently depending on which consumer asked.
45246
+ *
45247
+ * ## The cross-process gap, stated honestly
45248
+ *
45249
+ * These memos are scoped to the `export-hap` PROCESS. When HomeKit's vaapi
45250
+ * child dies, the broker's next child still pays its own two-second death, and
45251
+ * vice versa — because addons may never import each other and there is no
45252
+ * capability for "this backend is dead on this node right now". Closing that
45253
+ * would need a new cap surface, which Phase 0 explicitly does not take. What is
45254
+ * closed here is HomeKit's own repetition of the cost, across cameras and
45255
+ * across sessions.
45256
+ */
45257
+ /**
45258
+ * The window both memos answer for.
45259
+ *
45260
+ * 60 s, the same as `stream-broker-manager`'s `egressHwAccelCache`. Long enough
45261
+ * that a burst of session restarts pays one read; short enough that an operator
45262
+ * who changes the decoder backend, or a host whose accelerator recovers, is
45263
+ * obeyed on the next session rather than after an addon respawn.
45264
+ */
45265
+ var HAP_DECODE_MEMO_TTL_MS = 6e4;
45266
+ /**
45267
+ * Separator inside the encoded reading. A control character, because a backend
45268
+ * name is `[a-z0-9]+` and the decoder's non-backend choices are `auto` /
45269
+ * `none` / `''`, none of which can contain one — so the split is total.
45270
+ */
45271
+ var READING_SEPARATOR = "";
45272
+ /**
45273
+ * Marks a `null` FIELD, distinct from an EMPTY one.
45274
+ *
45275
+ * `probedBestHwaccel: ''` means the decoder answered and has never probed
45276
+ * (=> `not-probed`); `null` means the field was absent altogether. Encoding
45277
+ * both as `''` would lose a distinction `selectHwDecode` acts on.
45278
+ */
45279
+ var NULL_FIELD = "\0";
45280
+ function encodeField(value) {
45281
+ return value === null ? NULL_FIELD : value;
45282
+ }
45283
+ function decodeField(value) {
45284
+ return value === NULL_FIELD ? null : value;
45285
+ }
45286
+ /**
45287
+ * A reading as ONE `string | null`, which is what {@link HwAccelCache} stores.
45288
+ *
45289
+ * The cache's three states are exactly the three a memoised reading needs:
45290
+ * `undefined` (never asked, or expired), `null` (asked, and the decoder could
45291
+ * not be reached), and a value. Encoding into the one cache rather than
45292
+ * splitting across two is what keeps those three from skewing — two caches
45293
+ * written together can still be READ across an expiry boundary.
45294
+ */
45295
+ function encodeDecoderReading(reading) {
45296
+ if (reading === null) return null;
45297
+ return `${encodeField(reading.hwaccel)}${READING_SEPARATOR}${encodeField(reading.probedBestHwaccel)}`;
45298
+ }
45299
+ function decodeDecoderReading(value) {
45300
+ if (value === null) return null;
45301
+ const [hwaccel = NULL_FIELD, probed = NULL_FIELD] = value.split(READING_SEPARATOR);
45302
+ return {
45303
+ hwaccel: decodeField(hwaccel),
45304
+ probedBestHwaccel: decodeField(probed)
45305
+ };
45306
+ }
45307
+ function createDecoderReadingMemo(options) {
45308
+ const cache = createHwAccelCache(options);
45309
+ return {
45310
+ read() {
45311
+ const cached = cache.read();
45312
+ return cached === void 0 ? void 0 : decodeDecoderReading(cached);
45313
+ },
45314
+ write(reading) {
45315
+ cache.write(encodeDecoderReading(reading));
45316
+ }
45317
+ };
45318
+ }
45319
+ function createHapDecodeMemos(now) {
45320
+ const options = {
45321
+ ttlMs: HAP_DECODE_MEMO_TTL_MS,
45322
+ ...now ? { now } : {}
45323
+ };
45324
+ return {
45325
+ reading: createDecoderReadingMemo(options),
45326
+ failedBackend: createHwAccelCache(options)
45327
+ };
45328
+ }
45329
+ //#endregion
41914
45330
  //#region src/reconcile/sync-state.ts
41915
45331
  function syncStateFromJson(json) {
41916
45332
  const map = /* @__PURE__ */ new Map();
@@ -41977,7 +45393,10 @@ function syncStateToJson(map) {
41977
45393
  * intercom upload bridge, HomeKit Secure Video, recording, native
41978
45394
  * H.264 stream tap (currently uses RTSP + ffmpeg copy).
41979
45395
  */
41980
- var DEFAULT_DEVICE_SETTINGS = { streamPreference: "auto" };
45396
+ var DEFAULT_DEVICE_SETTINGS = {
45397
+ streamPreference: "auto",
45398
+ hksvRecording: false
45399
+ };
41981
45400
  var HAP_STREAM_PREFERENCE_OPTIONS = [
41982
45401
  {
41983
45402
  value: "auto",
@@ -42053,6 +45472,18 @@ var ExportHapAddon = class extends BaseAddon {
42053
45472
  pincode = "";
42054
45473
  /** Optional mDNS/bind interface (config.interfaceName), or undefined. */
42055
45474
  bind;
45475
+ /**
45476
+ * What this PROCESS remembers about decode hardware, shared by every camera
45477
+ * mapper: the decoder addon's per-node reading (60 s), and the backend that
45478
+ * last died at init (60 s).
45479
+ *
45480
+ * Owned here rather than as a module global for the reason `HwAccelCache`
45481
+ * itself records — a module global outlives an addon respawn and survives an
45482
+ * operator changing the decoder backend. Owned here rather than per mapper
45483
+ * because the whole point is that camera B does not re-pay camera A's failed
45484
+ * hardware init.
45485
+ */
45486
+ decodeMemos = createHapDecodeMemos();
42056
45487
  constructor() {
42057
45488
  super({ ...DEFAULT_CONFIG });
42058
45489
  }
@@ -42194,13 +45625,14 @@ var ExportHapAddon = class extends BaseAddon {
42194
45625
  const mapperKind = pickMapperKind(capabilities);
42195
45626
  if (!mapperKind) throw new Error(`export-hap: no mapper for capabilities ${JSON.stringify(capabilities ?? [])}`);
42196
45627
  const displayName = await this.resolveDisplayName(deviceId);
42197
- const baseEntry = {
45628
+ const previous = this.config.exposed.find((e) => e.deviceId === deviceId);
45629
+ const baseEntry = carryForward({
42198
45630
  deviceId,
42199
45631
  displayName,
42200
45632
  mapperKind,
42201
- addedAt: Date.now(),
45633
+ addedAt: previous?.addedAt ?? Date.now(),
42202
45634
  ...capabilities ? { capabilities: [...capabilities] } : {}
42203
- };
45635
+ }, previous, ["settings", "capabilities"]);
42204
45636
  const attached = await this.attachMapper(baseEntry);
42205
45637
  const finalEntry = {
42206
45638
  ...baseEntry,
@@ -42219,13 +45651,13 @@ var ExportHapAddon = class extends BaseAddon {
42219
45651
  childCount: attached.childAccessoryUuids.length
42220
45652
  } });
42221
45653
  }
42222
- async unexposeDevice(deviceId) {
45654
+ async unexposeDevice(deviceId, options = {}) {
42223
45655
  const numericId = Number.parseInt(deviceId, 10);
42224
45656
  const log = this.ctx.logger.withTags({ deviceId: numericId });
42225
45657
  await this.detachMapper(deviceId);
42226
45658
  const next = this.config.exposed.filter((e) => e.deviceId !== deviceId);
42227
45659
  if (next.length !== this.config.exposed.length) await this.updateGlobalSettings({ exposed: next });
42228
- clearPairingFiles(uuid.generate(`camstack:camera:${numericId}`), this.ctx.logger);
45660
+ if (options.clearPairing !== false) clearPairingFiles(uuid.generate(`camstack:camera:${numericId}`), this.ctx.logger);
42229
45661
  await this.forgetFingerprint(numericId);
42230
45662
  log.info("export-hap: unexposed device");
42231
45663
  }
@@ -42238,7 +45670,11 @@ var ExportHapAddon = class extends BaseAddon {
42238
45670
  displayName: entry.displayName,
42239
45671
  options: {
42240
45672
  ptzPulseMs: this.config.ptzPulseMs,
42241
- hapDeviceSettings: { streamPreference: entrySettings.streamPreference ?? "auto" }
45673
+ decodeMemos: this.decodeMemos,
45674
+ hapDeviceSettings: {
45675
+ streamPreference: entrySettings.streamPreference ?? "auto",
45676
+ hksvRecording: entrySettings.hksvRecording === true
45677
+ }
42242
45678
  }
42243
45679
  });
42244
45680
  for (const accessory of mapper.accessories) await publishStandalone(accessory, {
@@ -42513,6 +45949,7 @@ var ExportHapAddon = class extends BaseAddon {
42513
45949
  const enabled = entry !== null;
42514
45950
  const enabledKey = `hap:${deviceId}:enabled`;
42515
45951
  const streamPreferenceKey = `hap:${deviceId}:streamPreference`;
45952
+ const hksvKey = `hap:${deviceId}:hksvRecording`;
42516
45953
  const mapper = this.exposed.get(String(deviceId)) ?? null;
42517
45954
  const paired = mapper ? accessoryPaired(mapper.accessory) : false;
42518
45955
  const name = entry?.displayName ?? `Device ${deviceId}`;
@@ -42580,6 +46017,19 @@ var ExportHapAddon = class extends BaseAddon {
42580
46017
  equals: true
42581
46018
  },
42582
46019
  immediate: true
46020
+ },
46021
+ {
46022
+ type: "boolean",
46023
+ key: hksvKey,
46024
+ label: "HomeKit recording (Secure Video)",
46025
+ description: "Offer “Stream and Allow Recording” in iOS Home. Requires iCloud+ and a home hub. Keeps a continuous 8s prebuffer for this camera (~0.7% of one CPU core, H.264 sources only).",
46026
+ style: "switch",
46027
+ value: settings.hksvRecording === true,
46028
+ showWhen: {
46029
+ field: enabledKey,
46030
+ equals: true
46031
+ },
46032
+ immediate: true
42583
46033
  }
42584
46034
  ]
42585
46035
  }]
@@ -42604,12 +46054,15 @@ var ExportHapAddon = class extends BaseAddon {
42604
46054
  const wasEnabled = this.exposed.has(deviceIdStr);
42605
46055
  const enabledKey = `hap:${deviceId}:enabled`;
42606
46056
  const streamPreferenceKey = `hap:${deviceId}:streamPreference`;
46057
+ const hksvKey = `hap:${deviceId}:hksvRecording`;
42607
46058
  const enabledValue = enabledKey in patch ? Boolean(patch[enabledKey]) : wasEnabled;
42608
46059
  const streamPreferenceRaw = streamPreferenceKey in patch ? patch[streamPreferenceKey] : current?.settings?.streamPreference;
42609
46060
  const streamPreference = typeof streamPreferenceRaw === "string" && streamPreferenceRaw.trim().length > 0 ? streamPreferenceRaw : "auto";
46061
+ const hksvRecording = hksvKey in patch ? Boolean(patch[hksvKey]) : current?.settings?.hksvRecording === true;
42610
46062
  const nextSettings = {
42611
46063
  ...current?.settings ?? DEFAULT_DEVICE_SETTINGS,
42612
- streamPreference
46064
+ streamPreference,
46065
+ hksvRecording
42613
46066
  };
42614
46067
  if (!enabledValue) {
42615
46068
  if (wasEnabled) await this.unexposeDevice(deviceIdStr);
@@ -42621,18 +46074,24 @@ var ExportHapAddon = class extends BaseAddon {
42621
46074
  return { success: true };
42622
46075
  }
42623
46076
  const currentPref = current?.settings?.streamPreference ?? "auto";
46077
+ const currentHksv = current?.settings?.hksvRecording === true;
42624
46078
  await this.updateEntrySettings(deviceIdStr, nextSettings);
42625
- if (currentPref !== streamPreference) {
42626
- log.info("export-hap: streamPreference changed — refreshing accessory", { meta: {
42627
- from: currentPref,
42628
- to: streamPreference
46079
+ if (currentPref !== streamPreference || currentHksv !== hksvRecording) {
46080
+ log.info("export-hap: per-camera export settings changed — refreshing accessory", { meta: {
46081
+ streamPreference: {
46082
+ from: currentPref,
46083
+ to: streamPreference
46084
+ },
46085
+ hksvRecording: {
46086
+ from: currentHksv,
46087
+ to: hksvRecording
46088
+ }
42629
46089
  } });
42630
46090
  try {
42631
- await this.unexposeDevice(deviceIdStr);
46091
+ await this.unexposeDevice(deviceIdStr, { clearPairing: false });
42632
46092
  await this.exposeDevice(deviceIdStr);
42633
- await this.updateEntrySettings(deviceIdStr, nextSettings);
42634
46093
  } catch (err) {
42635
- log.warn("export-hap: failed to refresh accessory after streamPreference change", { meta: { error: errMsg(err) } });
46094
+ log.warn("export-hap: failed to refresh accessory after a settings change", { meta: { error: errMsg(err) } });
42636
46095
  }
42637
46096
  }
42638
46097
  return { success: true };