@camstack/addon-export-hap 1.2.13 → 1.2.14

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,6 +1,7 @@
1
1
  import { createRequire } from "node:module";
2
2
  import { createHash, randomBytes } from "node:crypto";
3
3
  import * as path from "node:path";
4
+ import { readFileSync } from "node:fs";
4
5
  import { spawn } from "node:child_process";
5
6
  import { Accessory, AudioBitrate, AudioStreamingCodecType, AudioStreamingSamplerate, CameraController, Categories, Characteristic, DoorbellController, H264Level, H264Profile, HAPStorage, SRTPCryptoSuites, Service, uuid } from "@homebridge/hap-nodejs";
6
7
  import * as fs from "node:fs/promises";
@@ -10,6 +11,43 @@ import { networkInterfaces } from "node:os";
10
11
  var __commonJSMin = (cb, mod) => () => (mod || (cb((mod = { exports: {} }).exports, mod), cb = null), mod.exports);
11
12
  var __require = /* @__PURE__ */ createRequire(import.meta.url);
12
13
  //#endregion
14
+ //#region src/exposed-entry.ts
15
+ /**
16
+ * Carrying an exposed-device entry across a re-expose.
17
+ *
18
+ * `exposeDevice` rebuilds its entry from scratch — display name, mapper kind,
19
+ * timestamp — and then REPLACES the stored one. Anything the rebuilt object
20
+ * does not mention is therefore destroyed, and two things it never mentioned
21
+ * were the per-camera settings and the capability list.
22
+ *
23
+ * The visible cost: the operator's "Source stream (HomeKit)" selector writes
24
+ * `low`, the addon logs `streamPreference changed — refreshing accessory
25
+ * {from=auto to=low}`, and the accessory that comes back derives its
26
+ * advertisement from DEFAULTS — `streamPreference=auto` — because the settings
27
+ * were dropped between the write and the rebuild. The selector only ever took
28
+ * effect after a full addon restart, when the settings were loaded first. A
29
+ * second write after the re-expose hid this: the store ended up correct, so
30
+ * nothing looked wrong except the stream nobody could explain.
31
+ */
32
+ /**
33
+ * Fill `base` from `existing` for the given keys, letting `base` win wherever
34
+ * it actually says something.
35
+ *
36
+ * That asymmetry is the point: a caller who passes `capabilities` is stating a
37
+ * new truth and must not be overruled by the stored copy, while a caller who
38
+ * says nothing about `settings` is not asking for them to be erased.
39
+ */
40
+ function carryForward(base, existing, keys) {
41
+ if (existing === void 0) return base;
42
+ const out = { ...base };
43
+ for (const key of keys) {
44
+ if (out[key] !== void 0) continue;
45
+ const carried = existing[key];
46
+ if (carried !== void 0) out[key] = carried;
47
+ }
48
+ return out;
49
+ }
50
+ //#endregion
13
51
  //#region ../types/dist/event-category-41fKf-q9.mjs
14
52
  var EventCategory = /* @__PURE__ */ function(EventCategory) {
15
53
  EventCategory["SystemBoot"] = "system.boot";
@@ -6521,9 +6559,38 @@ var CAP_NODE_PIN_CONTEXT_KEY = "__camstackNodePin";
6521
6559
  /**
6522
6560
  * Build the tRPC request options that pin a single capability call to `nodeId`.
6523
6561
  * Pass as the second argument to `.query(input, …)` / `.mutate(input, …)`.
6562
+ *
6563
+ * ## The id is normalised here, and it has to be
6564
+ *
6565
+ * A forked addon reads its own node from `ctx.kernel.localNodeId`, and inside a
6566
+ * worker that value is a RUNNER id — `hub/export-hap`, not `hub`. Routing
6567
+ * compares a pin against real node ids, so such a pin matches nothing and the
6568
+ * call fails with `no provider registered for cap "…"`. The local-first
6569
+ * resolver already guarded against this (`localNodeId.split('/')[0]`), which
6570
+ * made the hazard invisible: unpinned calls worked, and only an explicit pin —
6571
+ * the thing you reach for when you specifically need THIS node — silently
6572
+ * addressed a node that does not exist.
6573
+ *
6574
+ * Cost of it being missing: `addon-export-hap` pinned `decoder.getInfo` to its
6575
+ * own node to read the host's hardware-decode backend. It never once answered,
6576
+ * so every HomeKit egress transcode decoded in SOFTWARE — including 4K H.265 —
6577
+ * while D67's whole premise was that the decoder addon is the authority on
6578
+ * hardware. The warn said `decoding in SOFTWARE` and read as "this node has no
6579
+ * hardware", which was false.
6580
+ *
6581
+ * Normalising in the ONE constructor fixes every caller at once, which is why
6582
+ * it is here and not at the call sites.
6524
6583
  */
6525
6584
  function nodePin(nodeId) {
6526
- return { context: { [CAP_NODE_PIN_CONTEXT_KEY]: nodeId } };
6585
+ return { context: { [CAP_NODE_PIN_CONTEXT_KEY]: toNodeId(nodeId) } };
6586
+ }
6587
+ /**
6588
+ * A runner id is `<nodeId>/<addonId>`; a node id has no slash. Taking the head
6589
+ * is idempotent, so passing an already-clean id costs nothing.
6590
+ */
6591
+ function toNodeId(idOrRunnerId) {
6592
+ const head = idOrRunnerId.split("/")[0];
6593
+ return head === void 0 || head.length === 0 ? idOrRunnerId : head;
6527
6594
  }
6528
6595
  /**
6529
6596
  * Output schema shared by the contribution + live methods.
@@ -7072,6 +7139,270 @@ var EncodeProfileSchema = object({
7072
7139
  */
7073
7140
  outputArgs: array(string()).optional()
7074
7141
  });
7142
+ var AUDIO_ENCODER_BY_CODEC = {
7143
+ opus: "libopus",
7144
+ aac: "aac",
7145
+ pcmu: "pcm_mulaw",
7146
+ pcma: "pcm_alaw"
7147
+ };
7148
+ /**
7149
+ * Camera-microphone audio, per codec. Lives HERE rather than in
7150
+ * `encode-defaults.ts` only to avoid an import cycle (`encode-defaults` depends
7151
+ * on these types); it is re-exported from there, which is where to read it.
7152
+ *
7153
+ * Every source in this repo is a mono camera mic. The former broker preset
7154
+ * encoded Opus at `channels: 2`, spending bitrate duplicating one channel —
7155
+ * that is the value this consolidation changed.
7156
+ */
7157
+ var AUDIO_PRESETS = {
7158
+ aac: {
7159
+ kind: "encode",
7160
+ codec: "aac",
7161
+ bitrateKbps: 128,
7162
+ sampleRateHz: 48e3,
7163
+ channels: 1
7164
+ },
7165
+ opus: {
7166
+ kind: "encode",
7167
+ codec: "opus",
7168
+ bitrateKbps: 64,
7169
+ sampleRateHz: 48e3,
7170
+ channels: 1
7171
+ },
7172
+ pcmu: {
7173
+ kind: "encode",
7174
+ codec: "pcmu",
7175
+ sampleRateHz: 8e3,
7176
+ channels: 1
7177
+ }
7178
+ };
7179
+ /** `-hide_banner -loglevel <level>` — every ffmpeg site opens with this. */
7180
+ function logBannerArgs(level) {
7181
+ return [
7182
+ "-hide_banner",
7183
+ "-loglevel",
7184
+ level
7185
+ ];
7186
+ }
7187
+ /** `true` when the resolved value means "decode in software" (⇒ no `-hwaccel`). */
7188
+ function isSoftwareDecode(decodeHwAccel) {
7189
+ return !decodeHwAccel || decodeHwAccel === "none" || decodeHwAccel === "copy";
7190
+ }
7191
+ /**
7192
+ * Every INPUT option, in order, terminated by `-i <url>`. Nothing may be
7193
+ * appended to this list by a caller — that is the whole point of the function.
7194
+ */
7195
+ function buildInputArgs(input, decodeHwAccel) {
7196
+ const args = [];
7197
+ if (!isSoftwareDecode(decodeHwAccel)) args.push("-hwaccel", String(decodeHwAccel));
7198
+ if (input.extraArgs?.length) args.push(...input.extraArgs);
7199
+ if (input.analyzeDurationUs !== void 0) args.push("-analyzeduration", String(input.analyzeDurationUs));
7200
+ if (input.probeSizeBytes !== void 0) args.push("-probesize", String(input.probeSizeBytes));
7201
+ if (input.fflags?.length) for (const flag of input.fflags) args.push("-fflags", flag);
7202
+ if (input.rtspTransport) args.push("-rtsp_transport", input.rtspTransport);
7203
+ args.push("-i", input.url);
7204
+ return args;
7205
+ }
7206
+ /** The `-vf` filter args, or `[]` when a consumer `-vf` already claims the slot. */
7207
+ function buildVideoFilterArgs(scale, outputArgs) {
7208
+ if (!scale) return [];
7209
+ if (outputArgs.some((a) => a === "-vf")) return [];
7210
+ if (scale.mode === "exact") return ["-vf", `scale=${scale.width}:${scale.height}`];
7211
+ return ["-vf", `scale='min(${scale.width},iw)':'min(${scale.height},ih)':force_original_aspect_ratio=decrease:force_divisible_by=2`];
7212
+ }
7213
+ /** Rate-control args for an encode plan. */
7214
+ function buildRateControlArgs(video) {
7215
+ const kbps = video.bitrateKbps;
7216
+ if (kbps === void 0) return [];
7217
+ const rc = video.rateControl ?? {
7218
+ kind: "cap",
7219
+ vbvSeconds: 2
7220
+ };
7221
+ const bufsize = Math.max(1, Math.round(kbps * rc.vbvSeconds));
7222
+ return [
7223
+ ...rc.kind === "cbr" ? ["-b:v", `${kbps}k`] : [],
7224
+ "-maxrate",
7225
+ `${kbps}k`,
7226
+ "-bufsize",
7227
+ `${bufsize}k`
7228
+ ];
7229
+ }
7230
+ /** The whole video block (`-vf` … `-c:v` … knobs), after `-i`. */
7231
+ function buildVideoArgs(video, outputArgs) {
7232
+ if (video.kind === "copy") return [
7233
+ "-c:v",
7234
+ "copy",
7235
+ ...video.bitstreamFilter ? ["-bsf:v", video.bitstreamFilter] : []
7236
+ ];
7237
+ const args = [
7238
+ ...buildVideoFilterArgs(video.scale, outputArgs),
7239
+ "-c:v",
7240
+ video.encoder
7241
+ ];
7242
+ if (video.preset !== void 0) args.push("-preset", video.preset);
7243
+ if (video.tune !== void 0) args.push("-tune", video.tune);
7244
+ if (video.profile !== void 0) args.push("-profile:v", video.profile);
7245
+ if (video.level !== void 0) args.push("-level", video.level);
7246
+ if (video.pixelFormat !== void 0) args.push("-pix_fmt", video.pixelFormat);
7247
+ if (video.fps !== void 0) args.push("-r", String(video.fps));
7248
+ if (video.gopFrames !== void 0) args.push("-g", String(video.gopFrames));
7249
+ if (video.forceKeyFramesSeconds !== void 0) args.push("-force_key_frames", `expr:gte(t,n_forced*${video.forceKeyFramesSeconds})`);
7250
+ if (video.bf !== void 0) args.push("-bf", String(video.bf));
7251
+ args.push(...buildRateControlArgs(video));
7252
+ if (video.bitstreamFilter !== void 0) args.push("-bsf:v", video.bitstreamFilter);
7253
+ return args;
7254
+ }
7255
+ /** The whole audio block, after `-i`. */
7256
+ function buildAudioArgs(audio) {
7257
+ if (audio.kind === "none") return ["-an"];
7258
+ if (audio.kind === "copy") return ["-c:a", "copy"];
7259
+ const args = [];
7260
+ if (audio.filter !== void 0) args.push("-af", audio.filter);
7261
+ args.push("-c:a", AUDIO_ENCODER_BY_CODEC[audio.codec]);
7262
+ if (audio.application !== void 0) args.push("-application", audio.application);
7263
+ if (audio.frameDurationMs !== void 0) args.push("-frame_duration", String(audio.frameDurationMs));
7264
+ if (audio.globalHeader === true) args.push("-flags", "+global_header");
7265
+ if (audio.sampleRateHz !== void 0) args.push("-ar", String(audio.sampleRateHz));
7266
+ if (audio.bitrateKbps !== void 0) args.push("-b:a", `${audio.bitrateKbps}k`);
7267
+ if (audio.vbvBufferKbits !== void 0) args.push("-bufsize", `${audio.vbvBufferKbits}k`);
7268
+ if (audio.channels !== void 0) args.push("-ac", String(audio.channels));
7269
+ return args;
7270
+ }
7271
+ /** RTP output-leg args (`-payload_type`, `-ssrc`, `-sdp_file`, `-f rtp <url>`). */
7272
+ function buildRtpOutputArgs(out) {
7273
+ const args = [];
7274
+ if (out.payloadType !== void 0) args.push("-payload_type", String(out.payloadType));
7275
+ if (out.ssrc !== void 0) args.push("-ssrc", String(out.ssrc));
7276
+ if (out.sdpFile !== void 0) args.push("-sdp_file", out.sdpFile);
7277
+ args.push("-f", "rtp", out.url);
7278
+ return args;
7279
+ }
7280
+ /** `true` when the sink is a raw elementary bytestream that cannot mux audio. */
7281
+ function isElementaryVideoSink(sink) {
7282
+ return sink.kind === "stdout" && (sink.container === "h264" || sink.container === "hevc");
7283
+ }
7284
+ /**
7285
+ * The fragmented-MP4 muxer flags, in the order the recorder has proven them
7286
+ * (`recorder/addon/ffmpeg-args.ts` passes the same `movflags` string through
7287
+ * `-segment_format_options`, across every vendor in the fleet):
7288
+ *
7289
+ * - `frag_keyframe` — cut a fragment at each key frame, so every fragment
7290
+ * opens on a sync sample. HKSV's whole requirement.
7291
+ * - `empty_moov` — write `ftyp`+`moov` up front with no samples in it, which
7292
+ * is what makes the head a standalone INITIALISATION segment.
7293
+ * - `default_base_moof` — fragment offsets are self-relative, so a fragment is
7294
+ * demuxable without the bytes that preceded it. D31's byte-range read path
7295
+ * depends on exactly this property of the recorder's segments.
7296
+ */
7297
+ var FMP4_MOVFLAGS = "+frag_keyframe+empty_moov+default_base_moof";
7298
+ /**
7299
+ * The terminal sink args for every non-`rtp-outputs` sink. Exhaustive over the
7300
+ * union so a new member cannot fall through to `['-f', container, 'pipe:1']`,
7301
+ * which is what a plain `container` read would have done for `mp4` — a valid
7302
+ * argv that writes a NON-fragmented, unseekable-to-a-pipe MP4 and produces one
7303
+ * unusable byte stream.
7304
+ */
7305
+ function buildStdoutOrRtspSinkArgs(sink) {
7306
+ if (sink.kind === "rtsp-listen") return [
7307
+ "-f",
7308
+ "rtsp",
7309
+ "-rtsp_transport",
7310
+ "tcp",
7311
+ "-rtsp_flags",
7312
+ "listen",
7313
+ sink.url
7314
+ ];
7315
+ if (sink.kind === "rtp-outputs") return [];
7316
+ return sink.container === "mp4" ? buildFmp4SinkArgs(sink) : [
7317
+ "-f",
7318
+ sink.container,
7319
+ "pipe:1"
7320
+ ];
7321
+ }
7322
+ /** `-movflags … -min_frag_duration <us> -f mp4 pipe:1`. */
7323
+ function buildFmp4SinkArgs(sink) {
7324
+ return [
7325
+ "-movflags",
7326
+ FMP4_MOVFLAGS,
7327
+ "-min_frag_duration",
7328
+ String(Math.max(0, Math.round(sink.fragmentMs * 1e3))),
7329
+ "-f",
7330
+ "mp4",
7331
+ "pipe:1"
7332
+ ];
7333
+ }
7334
+ /**
7335
+ * A second output mapping source audio to RTP-over-UDP. `0:a:0?` makes the
7336
+ * audio optional so a source with no audio skips it instead of failing the
7337
+ * whole invocation.
7338
+ */
7339
+ function buildAudioSidecarArgs(sidecar) {
7340
+ return [
7341
+ "-map",
7342
+ "0:a:0?",
7343
+ ...buildAudioArgs(sidecar.codec === "pcma" ? {
7344
+ kind: "encode",
7345
+ codec: "pcma",
7346
+ sampleRateHz: 8e3,
7347
+ channels: 1
7348
+ } : AUDIO_PRESETS[sidecar.codec]),
7349
+ ...buildRtpOutputArgs({
7350
+ url: sidecar.rtpUrl,
7351
+ sdpFile: sidecar.sdpFile
7352
+ })
7353
+ ];
7354
+ }
7355
+ /**
7356
+ * Assemble the full ffmpeg argument list. Layout:
7357
+ *
7358
+ * -hide_banner -loglevel <level>
7359
+ * [-hwaccel <backend|auto>] ─┐ INPUT options — strictly before -i.
7360
+ * [<input.extraArgs>] │
7361
+ * [-fflags <flag>…] │
7362
+ * [-rtsp_transport tcp] │
7363
+ * -i <url> ─┘
7364
+ * <video block> <threads> <audio block> ─┐ OUTPUT options.
7365
+ * <consumer outputArgs verbatim> │
7366
+ * <sink> ─┘ terminal
7367
+ */
7368
+ function buildFfmpegArgs(inv) {
7369
+ const head = [...logBannerArgs(inv.logLevel), ...buildInputArgs(inv.input, inv.decodeHwAccel)];
7370
+ const threadArgs = inv.threadCount > 0 ? ["-threads", String(inv.threadCount)] : [];
7371
+ if (inv.sink.kind === "rtp-outputs") {
7372
+ const videoLeg = inv.sink.video ? [
7373
+ "-an",
7374
+ "-map",
7375
+ "0:v:0",
7376
+ ...buildVideoArgs(inv.video, inv.outputArgs),
7377
+ ...threadArgs,
7378
+ ...inv.outputArgs,
7379
+ ...buildRtpOutputArgs(inv.sink.video)
7380
+ ] : [];
7381
+ const audioLeg = inv.sink.audio ? [
7382
+ "-vn",
7383
+ "-map",
7384
+ "0:a:0?",
7385
+ ...buildAudioArgs(inv.audio),
7386
+ ...buildRtpOutputArgs(inv.sink.audio)
7387
+ ] : [];
7388
+ return [
7389
+ ...head,
7390
+ ...videoLeg,
7391
+ ...audioLeg
7392
+ ];
7393
+ }
7394
+ const audioArgs = isElementaryVideoSink(inv.sink) ? ["-an"] : buildAudioArgs(inv.audio);
7395
+ const sinkArgs = buildStdoutOrRtspSinkArgs(inv.sink);
7396
+ return [
7397
+ ...head,
7398
+ ...buildVideoArgs(inv.video, inv.outputArgs),
7399
+ ...threadArgs,
7400
+ ...audioArgs,
7401
+ ...inv.outputArgs,
7402
+ ...sinkArgs,
7403
+ ...inv.audioSidecar ? buildAudioSidecarArgs(inv.audioSidecar) : []
7404
+ ];
7405
+ }
7075
7406
  /**
7076
7407
  * The shape every live egress starts from: H.264 Baseline 3.1 at 720p25.
7077
7408
  * Baseline because it is the one profile every consumer in this repo decodes
@@ -7095,6 +7426,34 @@ var BASE_LIVE_EGRESS_PROFILE = {
7095
7426
  };
7096
7427
  ({ ...BASE_LIVE_EGRESS_PROFILE }), { ...BASE_LIVE_EGRESS_PROFILE.video };
7097
7428
  ({ ...BASE_LIVE_EGRESS_PROFILE });
7429
+ /** VBV window for a consumer whose budget is enforced per second (HomeKit). */
7430
+ var RATE_CONTROL_TIGHT = {
7431
+ kind: "cbr",
7432
+ vbvSeconds: 1
7433
+ };
7434
+ var HAP_AUDIO_BASE = {
7435
+ kind: "encode",
7436
+ codec: "opus",
7437
+ bitrateKbps: 24,
7438
+ channels: 1,
7439
+ application: "lowdelay",
7440
+ globalHeader: true,
7441
+ filter: "aresample=async=1000:first_pts=0"
7442
+ };
7443
+ function createHwAccelCache(options) {
7444
+ const now = options.now ?? (() => Date.now());
7445
+ let value = null;
7446
+ let writtenAt = Number.NEGATIVE_INFINITY;
7447
+ return {
7448
+ read() {
7449
+ return now() - writtenAt < options.ttlMs ? value : void 0;
7450
+ },
7451
+ write(next) {
7452
+ value = next;
7453
+ writtenAt = now();
7454
+ }
7455
+ };
7456
+ }
7098
7457
  /**
7099
7458
  * Deep wiring healthcheck — snapshot of active reachability probes across
7100
7459
  * every declared capability + widget of every installed plugin, on every
@@ -7151,7 +7510,7 @@ object({
7151
7510
  * ## This file adds no state
7152
7511
  *
7153
7512
  * Every switch here is a VIEW onto an authority that already existed
7154
- * ([D61](../../../../docs/decisions/adr-0062.md)). The whole point of the
7513
+ * ([D62](../../../../docs/decisions/adr-0062.md)). The whole point of the
7155
7514
  * group is that there is exactly one place each function is turned off, and
7156
7515
  * the group routes to it:
7157
7516
  *
@@ -7162,6 +7521,40 @@ object({
7162
7521
  * | `audio-analysis` | `deviceManager.setWrapperActive('audio-analysis')` | `AudioSubscriptionController.subscribeAudioStream` returns `null` before opening the stream |
7163
7522
  * | `recording` | `recording.setDeviceConfig` → `RecordingConfig.enabled` | `band-decision.shouldRecord` returns false; the controller detaches the device |
7164
7523
  * | `notifications` | `notificationRules.setDeviceMuted` | `NotificationCenter.evaluateAndEnqueue` returns before any rule is evaluated |
7524
+ * | `privacy-mask` | `privacyMask.setMask({ enabled })` → the CAMERA | the camera blanks the masked regions itself; every stream and recording carries the black boxes |
7525
+ * | `device-audio` | `privacyMask.setAudioEnabled` → the CAMERA | the camera stops encoding an audio track at all; every consumer sees silent video |
7526
+ *
7527
+ * ## The two switches whose authority is not on this server
7528
+ *
7529
+ * `privacy-mask` and `device-audio` write the CAMERA. That is not a loophole
7530
+ * in "the group stores nothing" — it is the purest form of it: the camera
7531
+ * holds the fact, every read is a read-through, and there is no server-side
7532
+ * copy that could drift. Their availability therefore cannot come from
7533
+ * `listBindableCapsForDeviceType` (a device-NATIVE cap carries no wrappers and
7534
+ * is filtered out there); it comes from the cap's own camera-probed
7535
+ * `privacyMask.getOptions()`, which is strictly more honest — it answers for
7536
+ * THIS camera rather than for the device type
7537
+ * ([D74](../../../../docs/decisions/adr-0074.md)).
7538
+ *
7539
+ * ## `privacy-mask` is the one row whose ON is not "the function is working"
7540
+ *
7541
+ * Every other switch means *this camera's function is doing its job*, so
7542
+ * `enabled: false` is a thing an operator took away. `privacy-mask` means **the
7543
+ * MASK is active** — `enabled: true` is video deliberately obscured. The
7544
+ * polarity is not a choice made here: `addon-export-hap`'s privacy `Switch`
7545
+ * (`builders/privacy-switch.ts`) already mirrors `patch.enabled` verbatim, and
7546
+ * a HomeKit toggle that disagreed with the app's toggle for the same camera is
7547
+ * worse than either surface not having one.
7548
+ *
7549
+ * Two consequences follow and both are load-bearing:
7550
+ *
7551
+ * - **It never counts as `switchedOff`.** `countsAsSwitchedOff` is `false` for
7552
+ * exactly this row. With the polarity above, every camera that has NOT drawn
7553
+ * a privacy mask would otherwise report `switchedOff: ['privacy-mask']` — the
7554
+ * normal, healthy state of most cameras rendered as an operator disablement.
7555
+ * - **Its cost line names BOTH directions.** `costWhenOff` is rendered
7556
+ * unconditionally by both clients, so for this row it has to read correctly
7557
+ * whichever way the switch is sitting.
7165
7558
  *
7166
7559
  * The wrapper-binding pair is not a new idea: `legacy-migrations.ts` already
7167
7560
  * migrated the legacy `audioEnabled` / `pipelineEnabled` /
@@ -7180,14 +7573,17 @@ object({
7180
7573
  * `CameraStatus.switchedOff`.
7181
7574
  */
7182
7575
  /**
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
7576
+ * The functions the operator named — five on 2026-08-05, plus the camera's own
7577
+ * microphone on 2026-08-07. Deliberately NOT one id per pipeline step: face
7578
+ * recognition and plate/LPR are per-step toggles on
7185
7579
  * `pipelineOrchestrator.setCameraStepToggle` and belong in the pipeline
7186
- * editor, not in a five-button safety group.
7580
+ * editor, not in a safety group.
7187
7581
  */
7188
7582
  var CameraSwitchIdSchema = _enum([
7189
7583
  "stream-broker",
7190
7584
  "object-detection",
7585
+ "privacy-mask",
7586
+ "device-audio",
7191
7587
  "audio-analysis",
7192
7588
  "recording",
7193
7589
  "notifications"
@@ -7205,14 +7601,26 @@ var CameraSwitchAuthoritySchema = discriminatedUnion("kind", [
7205
7601
  capName: string()
7206
7602
  }),
7207
7603
  object({ kind: literal("recording-config") }),
7208
- object({ kind: literal("notification-mute") })
7604
+ object({ kind: literal("notification-mute") }),
7605
+ object({
7606
+ kind: literal("camera-audio"),
7607
+ capName: string()
7608
+ }),
7609
+ object({
7610
+ kind: literal("camera-mask"),
7611
+ capName: string()
7612
+ })
7209
7613
  ]);
7210
7614
  /**
7211
7615
  * Why a switch is not offered for this camera. Rendered instead of the
7212
7616
  * control, never as a dead control — an absent function and a broken one must
7213
7617
  * not look the same.
7214
7618
  */
7215
- var CameraSwitchUnavailableReasonSchema = _enum(["no-provider", "source-unreachable"]);
7619
+ var CameraSwitchUnavailableReasonSchema = _enum([
7620
+ "no-provider",
7621
+ "source-unreachable",
7622
+ "not-configured"
7623
+ ]);
7216
7624
  /**
7217
7625
  * One switch, resolved for one camera.
7218
7626
  *
@@ -9234,6 +9642,26 @@ var EgressTranscodeRequestSchema = object({
9234
9642
  "h264_mp4toannexb",
9235
9643
  "hevc_mp4toannexb"
9236
9644
  ]).optional(),
9645
+ /**
9646
+ * Publish the transcode as a LOCAL push cam stream, instead of leaving the
9647
+ * consumer to dial the returned url. The broker picks the id and returns it
9648
+ * as `camStreamId` — a caller-supplied one would be circular, since the
9649
+ * sharing key is computed FROM this request.
9650
+ *
9651
+ * The url is still returned and still the contract for a transcode pinned to
9652
+ * another node. But dialling it locally costs an RTSP round trip that changes
9653
+ * the transport underneath the consumer: a dialled stream is an RTP source,
9654
+ * so `isRtpSource()` is true and the session takes the RTP-passthrough +
9655
+ * repacketizer branch. The push branch — the one the derived mechanism has
9656
+ * live hours on — is never reached. Measured on Alexa: broker registered, RTP
9657
+ * arriving, key frame arriving, black screen, on a chain healthy at every
9658
+ * other point.
9659
+ *
9660
+ * Same idea the transport already applies to CALLS, where `classifyCapRoute`
9661
+ * gives priority to `hub-in-process` so a local call never leaves the node.
9662
+ * This is that rule for media.
9663
+ */
9664
+ publishLocally: boolean().optional(),
9237
9665
  pixelFormat: _enum(["yuv420p", "nv12"]).optional(),
9238
9666
  /**
9239
9667
  * Operator/consumer override for decode hardware. ABSENT is the normal case
@@ -9278,7 +9706,13 @@ var EgressTranscodeSchema = object({
9278
9706
  * Returned rather than assumed: a consumer that asked for hardware and got
9279
9707
  * software needs to be able to see that without reading the broker's logs.
9280
9708
  */
9281
- decodeHwAccel: string().nullable()
9709
+ decodeHwAccel: string().nullable(),
9710
+ /**
9711
+ * Set when `publishLocally` was honoured: attach to THIS instead of dialling
9712
+ * `url`, and the session takes the push/deframe transport rather than the
9713
+ * RTP-passthrough one. `null` means the consumer must dial.
9714
+ */
9715
+ camStreamId: string().nullable()
9282
9716
  });
9283
9717
  method(object({
9284
9718
  deviceId: number().int().nonnegative(),
@@ -17120,9 +17554,15 @@ DeviceType.Camera, method(object({
17120
17554
  * Bypass the cache freshness check and fetch directly from the
17121
17555
  * native (or stream-broker fallback). Triggered by the UI's
17122
17556
  * "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.
17557
+ * even when the cache is well within the device's
17558
+ * `snapshotMaxAgeS` window.
17559
+ *
17560
+ * **`force` is an OPERATOR signal, not a freshness preference.** On a
17561
+ * battery camera it is the one thing that walks past the wrapper's
17562
+ * sleep gate and wakes the camera, so a background caller — a poller,
17563
+ * an event handler, a thumbnail — must NEVER set it. Every such caller
17564
+ * gets the cached frame, which on a sleeping battery camera is the
17565
+ * correct answer: stale but honest beats woken.
17126
17566
  */
17127
17567
  force: boolean().optional()
17128
17568
  }), SnapshotImageSchema.nullable()), method(object({ deviceId: number() }), _void(), {
@@ -21404,12 +21844,30 @@ object({
21404
21844
  });
21405
21845
  DeviceType.Sensor;
21406
21846
  /**
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).
21847
+ * PRIVACY what the camera deliberately does not capture. Two planes:
21848
+ *
21849
+ * - **video**: up to `maxRegions` SHAPES the camera blanks out (NOT a cell
21850
+ * grid). Reolink `<shelterList>` zones are rectangles; Hikvision ISAPI
21851
+ * `<RegionCoordinatesList>` zones are free polygons (this camera: exactly
21852
+ * 4 vertices, not necessarily axis-aligned). The cap composes the shared
21853
+ * rect|polygon subset of the MaskShape vocabulary. All coords are
21854
+ * normalized 0..1 (top-left origin).
21855
+ * - **audio**: the camera's microphone. `setAudioEnabled(false)` stops the
21856
+ * camera encoding an audio track at all, so EVERY consumer — live view,
21857
+ * recording, the audio analyzer, an export — sees silent video. There is
21858
+ * no server-side copy of this fact; the camera is the store and every read
21859
+ * is a read-through, which is why a switch over it cannot drift
21860
+ * ([D62](../../../../docs/decisions/adr-0062.md)).
21861
+ *
21862
+ * Both belong here for one reason: they are the two things an operator turns
21863
+ * off when the answer to "what is this camera allowed to record" changes, and
21864
+ * both are applied ON the device, before anything leaves it.
21865
+ *
21866
+ * **The audio flag has exactly one writer.** `stream-params` used to carry a
21867
+ * per-profile `audio` in its patch schema — reachable from no UI and honoured
21868
+ * by one provider — and it was removed when this landed. A second writer onto
21869
+ * one device register is the shape of every knob this repo has shipped that
21870
+ * disagreed with the one the reader read.
21413
21871
  */
21414
21872
  /** A privacy-mask region's geometry — rectangle or free polygon. */
21415
21873
  var PrivacyMaskShapeSchema = discriminatedUnion("kind", [MaskRectShapeSchema, MaskPolygonShapeSchema]);
@@ -21425,16 +21883,40 @@ object({
21425
21883
  enabled: boolean(),
21426
21884
  /** Active zones (normalized 0..1). Length ≤ maxRegions. */
21427
21885
  regions: array(PrivacyMaskRegionSchema),
21886
+ /**
21887
+ * Is the camera capturing sound right now? Read from the camera, never from
21888
+ * a server-side mirror.
21889
+ *
21890
+ * `null` means "no answer" — either this camera exposes no controllable
21891
+ * microphone (`getOptions().supportsAudioMute === false`) or the read
21892
+ * failed. A consumer must render `null` as UNKNOWN and never as `false`:
21893
+ * "the microphone is off" and "we could not ask" look identical to an
21894
+ * operator only until one of them is wrong.
21895
+ *
21896
+ * On a camera whose profiles carry the flag independently (Reolink writes
21897
+ * it per stream), `true` means AT LEAST ONE profile still carries audio —
21898
+ * privacy is only satisfied when every one of them is silent.
21899
+ */
21900
+ audioEnabled: boolean().nullable(),
21428
21901
  lastFetchedAt: number()
21429
21902
  });
21430
- /** Per-camera availability. */
21903
+ /** Per-camera availability. Probed, never assumed from the model name. */
21431
21904
  var PrivacyMaskOptionsSchema = object({
21432
21905
  /** Maximum number of supported zones. */
21433
21906
  maxRegions: number(),
21434
21907
  /** Shape kinds this camera accepts — Reolink: ['rect']; Hikvision: ['rect','polygon']. */
21435
21908
  supportedShapes: array(MaskShapeKindSchema),
21436
21909
  /** Polygon vertex bounds when 'polygon' is supported (Hikvision: {min:4,max:4}). */
21437
- polygonVertices: MaskPolygonVerticesSchema.optional()
21910
+ polygonVertices: MaskPolygonVerticesSchema.optional(),
21911
+ /**
21912
+ * Does this camera expose a microphone switch we can actually write?
21913
+ *
21914
+ * Camera-probed: `true` only when the firmware answered with an audio flag
21915
+ * we know how to patch. A camera that never answered is `false` — a control
21916
+ * the operator can press that changes nothing is worse than no control, and
21917
+ * the switch group renders "not available" instead.
21918
+ */
21919
+ supportsAudioMute: boolean()
21438
21920
  });
21439
21921
  /** Partial change — every field optional. */
21440
21922
  var PrivacyMaskPatchSchema = object({
@@ -21447,6 +21929,12 @@ DeviceType.Camera, method(object({ deviceId: number() }), PrivacyMaskOptionsSche
21447
21929
  }), _void(), {
21448
21930
  kind: "mutation",
21449
21931
  auth: "admin"
21932
+ }), method(object({
21933
+ deviceId: number(),
21934
+ enabled: boolean()
21935
+ }), _void(), {
21936
+ kind: "mutation",
21937
+ auth: "admin"
21450
21938
  });
21451
21939
  var PtzPresetSchema = object({
21452
21940
  id: string(),
@@ -21656,6 +22144,21 @@ var LocateSegmentResultSchema = discriminatedUnion("kind", [object({
21656
22144
  })]);
21657
22145
  /** Raw bytes of one finalized footage segment (read off disk on the recording node). */
21658
22146
  var ReadSegmentBytesResultSchema = object({ data: _instanceof(Uint8Array) });
22147
+ /**
22148
+ * One GOP of a finalized segment, cut by byte range through the segment's own
22149
+ * `mfra` (D31 on the D42 feeder path). `data` is the `ftyp`+`moov` head plus
22150
+ * the single `moof`+`mdat` covering the requested instant — standalone-
22151
+ * demuxable, never the whole file. When the segment's index cannot be parsed
22152
+ * the provider degrades INSIDE the mechanism to the whole segment (still one
22153
+ * `data`, `gopStartMs` = the segment start) — a worse read, not another path.
22154
+ */
22155
+ var ReadGopBytesResultSchema = object({
22156
+ data: _instanceof(Uint8Array),
22157
+ /** Absolute epoch ms of the returned fragment's first sample. */
22158
+ gopStartMs: number(),
22159
+ /** Media ms the returned fragment covers. */
22160
+ gopDurMs: number()
22161
+ });
21659
22162
  method(object({
21660
22163
  deviceId: number(),
21661
22164
  fromMs: number(),
@@ -21698,6 +22201,14 @@ method(object({
21698
22201
  }), ReadSegmentBytesResultSchema, {
21699
22202
  kind: "query",
21700
22203
  auth: "admin"
22204
+ }), method(object({
22205
+ deviceId: number(),
22206
+ profile: string(),
22207
+ startMs: number(),
22208
+ epochMs: number()
22209
+ }), ReadGopBytesResultSchema, {
22210
+ kind: "query",
22211
+ auth: "admin"
21701
22212
  }), method(object({
21702
22213
  deviceId: number(),
21703
22214
  config: RecordingConfigSchema
@@ -22178,6 +22689,16 @@ var StreamProfileConfigSchema = object({
22178
22689
  "baseline"
22179
22690
  ]).optional(),
22180
22691
  gop: number().optional(),
22692
+ /**
22693
+ * Whether THIS profile currently carries an audio track. READ-ONLY here.
22694
+ *
22695
+ * There is no matching field on {@link StreamProfilePatchSchema}: the
22696
+ * camera's microphone is owned by `privacy-mask` (`setAudioEnabled`), which
22697
+ * writes every profile at once so "audio off" means silent everywhere. A
22698
+ * per-profile writer beside it would let a camera be half-muted and would be
22699
+ * a second knob onto one device register — the failure D62 exists to
22700
+ * prevent. Absent when the firmware does not report the flag.
22701
+ */
22181
22702
  audio: boolean().optional()
22182
22703
  });
22183
22704
  object({
@@ -22218,7 +22739,13 @@ var StreamParamsOptionsSchema = object({
22218
22739
  ext: StreamProfileOptionsSchema.optional()
22219
22740
  });
22220
22741
  /** A partial change to one profile — every field optional; a provider
22221
- * ignores fields it doesn't support. */
22742
+ * ignores fields it doesn't support.
22743
+ *
22744
+ * There is deliberately NO `audio` here. It existed until 2026-08-07,
22745
+ * reachable from no form and honoured by exactly one provider, while the
22746
+ * camera's microphone is a whole-device fact. It now has one writer,
22747
+ * `privacyMask.setAudioEnabled`, which writes every profile — see
22748
+ * `privacy-mask.cap.ts`. */
22222
22749
  var StreamProfilePatchSchema = object({
22223
22750
  width: number().optional(),
22224
22751
  height: number().optional(),
@@ -22231,8 +22758,7 @@ var StreamProfilePatchSchema = object({
22231
22758
  "main",
22232
22759
  "baseline"
22233
22760
  ]).optional(),
22234
- gop: number().optional(),
22235
- audio: boolean().optional()
22761
+ gop: number().optional()
22236
22762
  });
22237
22763
  DeviceType.Camera, method(object({ deviceId: number() }), StreamParamsOptionsSchema), method(object({
22238
22764
  deviceId: number(),
@@ -26744,6 +27270,12 @@ Object.freeze({
26744
27270
  addonId: null,
26745
27271
  access: "view"
26746
27272
  },
27273
+ "privacyMask.setAudioEnabled": {
27274
+ capName: "privacy-mask",
27275
+ capScope: "device",
27276
+ addonId: null,
27277
+ access: "create"
27278
+ },
26747
27279
  "privacyMask.setMask": {
26748
27280
  capName: "privacy-mask",
26749
27281
  capScope: "device",
@@ -26912,6 +27444,12 @@ Object.freeze({
26912
27444
  addonId: null,
26913
27445
  access: "create"
26914
27446
  },
27447
+ "recording.readGopBytes": {
27448
+ capName: "recording",
27449
+ capScope: "system",
27450
+ addonId: null,
27451
+ access: "view"
27452
+ },
26915
27453
  "recording.readSegmentBytes": {
26916
27454
  capName: "recording",
26917
27455
  capScope: "system",
@@ -28333,6 +28871,88 @@ object({
28333
28871
  square: false
28334
28872
  }).paddingRatio;
28335
28873
  /**
28874
+ * WHICH delivered frames the decode worker retains a native copy of.
28875
+ *
28876
+ * - `all` — every frame the worker delivered to the runner. The shipped
28877
+ * behaviour, and the only correct one if something can ask for a crop of a
28878
+ * frame the runner never sent to inference.
28879
+ * - `inferred` — only the frames the runner ADMITTED to its detection queue.
28880
+ * A native-crop request always names a `frameId` that rode an inference
28881
+ * result, so that is the only set a request can name. How much it drops is
28882
+ * the two-plane governor's admit ratio and nothing else: measured at ~50% on
28883
+ * this cluster, not the ~80% the design sketch assumed, because the governor
28884
+ * was not throttling as hard as the sketch supposed. Read
28885
+ * `leaseAdmitted`/`leaseOffered` off the metrics line for the camera in front
28886
+ * of you rather than quoting a number from here. The newest delivered frame is
28887
+ * croppable regardless — it is still the worker's reserved slot, not a lease —
28888
+ * which covers the one-frame race between a mark and the supersede that
28889
+ * consumes it.
28890
+ */
28891
+ var NativeLeaseAdmissionSchema = _enum(["all", "inferred"]);
28892
+ object({
28893
+ /**
28894
+ * How long a retained native frame is served before it counts as a miss.
28895
+ *
28896
+ * Must cover the FULL late-crop horizon: detection inference + the
28897
+ * cross-process inference-result hop to hub post-analysis + tracking + the
28898
+ * tRPC crop round-trip back. Below ~500 ms the busiest cameras' subject crops
28899
+ * outrun it and fall back to the ≤640 detection frame; above ~3 s the resident
28900
+ * RAM per busy camera grows linearly with no measured hit-rate gain.
28901
+ */
28902
+ ttlMs: number().int().min(250).max(1e4),
28903
+ /**
28904
+ * Hard per-decode-worker RAM ceiling for retained native frames, in MB.
28905
+ *
28906
+ * Intended as a SAFETY ceiling with the TTL as the effective cap — but check
28907
+ * which one is actually binding before reasoning from that. At the shipped
28908
+ * 1024 MB and a 2 800 ms TTL, a 4K camera hits the CEILING first (~43 frames
28909
+ * at ~24 MB each) and the TTL never gets to expire anything; `leaseMb` /
28910
+ * `leaseFrames` on the metrics line say which. When the ceiling binds, a
28911
+ * change that admits fewer frames buys retention WINDOW at constant RAM
28912
+ * rather than giving RAM back — lower this knob if RAM is what you wanted.
28913
+ * `0` DISABLES the lease entirely and falls the worker back to the tiny
28914
+ * leak-prone GPU surface ring (~85% crop miss; that is what the lease exists
28915
+ * to replace).
28916
+ */
28917
+ budgetMb: number().int().min(0).max(4096),
28918
+ /**
28919
+ * Demand window: eager per-frame native retention runs only within this many
28920
+ * ms of the last native-crop request (or of the dial starting).
28921
+ *
28922
+ * `0` means ALWAYS ON — it disables the gate, it does not disable retention.
28923
+ * That is the legacy behaviour that saturated an N100 (24 native-4K downloads
28924
+ * per second on a camera with zero crop demand), so leave it non-zero unless
28925
+ * you are reproducing that.
28926
+ */
28927
+ activityMs: number().int().min(0).max(12e4),
28928
+ /**
28929
+ * Which delivered frames are retained at all — see
28930
+ * {@link NativeLeaseAdmissionSchema}. This is the only knob of the four that
28931
+ * changes WHAT is kept rather than for how long, so it is also the only one
28932
+ * that can turn a crop that used to hit into a miss. The worker counts every
28933
+ * crop request naming a frame it did NOT see marked
28934
+ * (`leaseUnmarkedCrops` on the session-decode metrics line): a non-zero value
28935
+ * there is the signal that some caller names frames outside the inference set
28936
+ * and that this must go back to `all`.
28937
+ */
28938
+ admission: NativeLeaseAdmissionSchema
28939
+ });
28940
+ /**
28941
+ * The values in force when the operator has set nothing — byte-for-byte the
28942
+ * constants the decode worker shipped with as env-var defaults, so making these
28943
+ * settings changed no behaviour on the day it landed.
28944
+ */
28945
+ var DEFAULT_NATIVE_LEASE_SETTINGS = {
28946
+ ttlMs: 1200,
28947
+ budgetMb: 1024,
28948
+ activityMs: 15e3,
28949
+ admission: "inferred"
28950
+ };
28951
+ DEFAULT_NATIVE_LEASE_SETTINGS.ttlMs;
28952
+ DEFAULT_NATIVE_LEASE_SETTINGS.budgetMb;
28953
+ DEFAULT_NATIVE_LEASE_SETTINGS.activityMs;
28954
+ DEFAULT_NATIVE_LEASE_SETTINGS.admission;
28955
+ /**
28336
28956
  * Compute the stable 64-char lowercase-hex fingerprint of a device's
28337
28957
  * export-relevant shape. Two structurally-equal shapes (any feature order,
28338
28958
  * any duplicates, any deviceId) hash identically.
@@ -28471,7 +29091,7 @@ function clearPairingFiles(accessoryUuid, logger) {
28471
29091
  }
28472
29092
  //#endregion
28473
29093
  //#region src/hap-setup-uri.ts
28474
- function errMsg$10(e) {
29094
+ function errMsg$11(e) {
28475
29095
  return e instanceof Error ? e.message : String(e);
28476
29096
  }
28477
29097
  /**
@@ -28498,7 +29118,7 @@ function firstExposedAccessorySetupUri(exposed, logger) {
28498
29118
  try {
28499
29119
  return first.setupURI();
28500
29120
  } catch (err) {
28501
- logger.debug("export-hap: setupURI failed on first exposed accessory", { meta: { error: errMsg$10(err) } });
29121
+ logger.debug("export-hap: setupURI failed on first exposed accessory", { meta: { error: errMsg$11(err) } });
28502
29122
  return;
28503
29123
  }
28504
29124
  }
@@ -28536,13 +29156,19 @@ function hapServiceName(parts, fallback) {
28536
29156
  /**
28537
29157
  * The privacy-mask switch.
28538
29158
  *
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.
29159
+ * Just "Privacy". The camera name is NOT prefixed: this service lives inside
29160
+ * the camera's own accessory, iOS already renders it under the camera, and a
29161
+ * round that prefixed it gave the operator "Videocamera ingresso Privacy"
29162
+ * sitting inside a tile titled "Videocamera ingresso".
29163
+ *
29164
+ * The prefix was added for a real reason — two cameras publishing a switch
29165
+ * called "Privacy" — but that was a symptom of the label being the ONLY thing
29166
+ * shown, which stopped being true once `ConfiguredName` made the service
29167
+ * render in its accessory's context. Uniqueness is required WITHIN one
29168
+ * accessory, not across the bridge, and one camera has one privacy switch.
28543
29169
  */
28544
- function privacyServiceName(deviceName) {
28545
- return hapServiceName([deviceName, PRIVACY_SUFFIX], PRIVACY_SUFFIX);
29170
+ function privacyServiceName() {
29171
+ return hapServiceName([PRIVACY_SUFFIX], PRIVACY_SUFFIX);
28546
29172
  }
28547
29173
  /**
28548
29174
  * Deliberately not localised, and deliberately not a translation table.
@@ -28559,35 +29185,60 @@ var PRIVACY_SUFFIX = "Privacy";
28559
29185
  * the parent camera.
28560
29186
  *
28561
29187
  * 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
29188
+ * the operator's language, and an early rule threw it away: `role` was
28563
29189
  * consulted first and title-cased, so every siren on the fleet published as
28564
29190
  * the English word "Siren" no matter what the operator had called it.
28565
29191
  *
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.
29192
+ * Providers name children both ways "Sirena" and "Videocamera cucina
29193
+ * Sirena". The parent half is now REMOVED rather than added, because the
29194
+ * service is published inside the parent camera's own accessory and iOS
29195
+ * already shows it there. The result must be one form, not two.
28569
29196
  */
28570
29197
  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);
29198
+ const own = withoutParent(child.name.trim(), parentName);
29199
+ if (own.length > 0) return hapServiceName([own], own);
29200
+ return hapServiceName([typeof child.role === "string" ? titleCase(child.role) : ""], CHILD_FALLBACK);
28574
29201
  }
28575
29202
  /**
28576
- * A PTZ action switch.
29203
+ * Last resort for a child that carries neither a name nor a role. Better than
29204
+ * the parent's name, which would publish a service indistinguishable from the
29205
+ * accessory holding it — the exact defect this module keeps being asked to fix.
28577
29206
  *
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".
29207
+ * English, like the role slugs it stands in for ("Floodlight", "Siren"): the
29208
+ * only strings this module invents are English, and inventing one Italian word
29209
+ * would be a localisation layer that localises nothing.
28582
29210
  */
28583
- function ptzServiceName(deviceName, actionLabel) {
28584
- return hapServiceName([deviceName, actionLabel], actionLabel);
29211
+ var CHILD_FALLBACK = "Accessory";
29212
+ /**
29213
+ * A PTZ action switch: the bare action, "Preset ingresso" / "Pan Left" /
29214
+ * "Autotrack".
29215
+ *
29216
+ * The labels themselves stay in `ptz-labels.ts` — they name a HomeKit control,
29217
+ * not a device. This function exists only to put the operator-typed half of a
29218
+ * preset name through the same sanitisation everything else gets; it no longer
29219
+ * qualifies the label with the camera, because all eight PTZ services live on
29220
+ * that camera's accessory and are unique among themselves.
29221
+ */
29222
+ function ptzServiceName(actionLabel) {
29223
+ return hapServiceName([actionLabel], actionLabel);
28585
29224
  }
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);
29225
+ /**
29226
+ * Drop `parentName` from the front of `name`.
29227
+ *
29228
+ * A PREFIX only. "Videocamera cucina Sirena" → "Sirena"; "Sirena" is already
29229
+ * bare and untouched. A parent name appearing anywhere else in the child's
29230
+ * name is left alone — cutting from the middle of a string the operator typed
29231
+ * would mangle it, and this function must never make a label WORSE.
29232
+ *
29233
+ * Returns `name` unchanged when stripping would leave nothing: a child the
29234
+ * operator called exactly what the camera is called still needs a label.
29235
+ */
29236
+ function withoutParent(name, parentName) {
29237
+ const needle = parentName.trim();
29238
+ if (needle.length === 0) return name;
29239
+ if (!name.toLowerCase().startsWith(needle.toLowerCase())) return name;
29240
+ const rest = name.slice(needle.length).trim();
29241
+ return rest.length > 0 ? rest : name;
28591
29242
  }
28592
29243
  /**
28593
29244
  * Truncate to the HAP ceiling and shave any leading/trailing character the
@@ -28636,7 +29287,7 @@ async function buildBattery(bctx) {
28636
29287
  const status = await proxy.battery?.getStatus({});
28637
29288
  if (status) applyToService(service, status);
28638
29289
  } catch (err) {
28639
- log.debug("export-hap: battery getStatus hydrate failed (non-fatal)", { meta: { error: errMsg$9(err) } });
29290
+ log.debug("export-hap: battery getStatus hydrate failed (non-fatal)", { meta: { error: errMsg$10(err) } });
28640
29291
  }
28641
29292
  const unsubscribes = [];
28642
29293
  if (proxy.state.battery) {
@@ -28662,7 +29313,7 @@ function applyToService(service, status) {
28662
29313
  const lowBattery = pct <= LOW_BATTERY_THRESHOLD_PCT ? Characteristic.StatusLowBattery.BATTERY_LEVEL_LOW : Characteristic.StatusLowBattery.BATTERY_LEVEL_NORMAL;
28663
29314
  service.updateCharacteristic(Characteristic.StatusLowBattery, lowBattery);
28664
29315
  }
28665
- function errMsg$9(err) {
29316
+ function errMsg$10(err) {
28666
29317
  return err instanceof Error ? err.message : String(err);
28667
29318
  }
28668
29319
  //#endregion
@@ -39158,9 +39809,121 @@ function ingestDecryptedRtcp(plaintext, tally) {
39158
39809
  failure: null
39159
39810
  };
39160
39811
  }
39812
+ function classifyConnection(input) {
39813
+ if (input.negotiatedWidth < 640) return "watch";
39814
+ if (input.audioPacketTimeMs >= 60) return "remote";
39815
+ return input.viaHomeHub ? "home-hub" : "local";
39816
+ }
39817
+ /**
39818
+ * The slot each class asks for.
39819
+ *
39820
+ * ## `local` takes the camera's best stream — settled by measurement
39821
+ *
39822
+ * This function was pinned to `low` for EVERY class by one number. On 615/high,
39823
+ * 3840x2160 pass-through:
39824
+ *
39825
+ * durationMs=30820 videoPacketsForwarded=93 videoKeyframes=1
39826
+ * audioPacketsForwarded=1497 lost=0
39827
+ *
39828
+ * Three video datagrams a second, one key frame in half a minute, while the
39829
+ * AUDIO leg of the *same* ffmpeg ran perfectly. It read as "our path cannot
39830
+ * carry a high-bitrate stream".
39831
+ *
39832
+ * **It was not HomeKit, not 4K and not SRTP. The loopback UDP socket ffmpeg
39833
+ * writes its RTP into had no `SO_RCVBUF` at all** (2026-08-07). It ran on
39834
+ * `net.core.rmem_default`, 212 992 B — about a fifth of one 4K IDR, which
39835
+ * arrives as ~750 datagrams at `pkt_size=1378` in a single burst. The kernel
39836
+ * discarded the overflow, and a datagram dropped there never reaches a
39837
+ * `message` handler, so it lowered the forwarded count exactly like a packet
39838
+ * ffmpeg never wrote and the controller reported no loss for it either. Audio,
39839
+ * a few hundred bytes every 20 ms, never filled the buffer. That is the whole
39840
+ * asymmetry. See `stream-socket-buffer.ts`.
39841
+ *
39842
+ * With an 8 MiB buffer (granted — this hub's `net.core.rmem_max` is 16 MiB),
39843
+ * the same camera and the same slot, session
39844
+ * `12308100-7dbe-4ad1-b277-1f046ba54ec2` on 2026-08-07:
39845
+ *
39846
+ * selectedProfile=high transcode=false slotMeasuredKbps=5097
39847
+ * videoPacketsForwarded=6512 durationMs=9867 (~660/s, was ~3/s)
39848
+ * msToFirstKeyframe=858 deliveredFps=24 worstFractionLostPct=0.4
39849
+ * videoLoopRcvbufBytes=16777216 clamped=false
39850
+ *
39851
+ * Operator: loaded instantly, and visibly not the low stream. A 220x increase
39852
+ * in delivered packet rate from sizing one socket.
39853
+ *
39854
+ * ## Why the remote classes stay `low`
39855
+ *
39856
+ * Not caution left over from the freeze — a different, UNMEASURED question.
39857
+ * `watch`, `remote` and `home-hub` all send video across a link whose budget
39858
+ * nothing here has measured; the buffer fix says something about a loopback hop
39859
+ * inside one host and nothing whatsoever about a WAN. 4K pass-through at ~5 Mbps
39860
+ * to a phone on LTE is a decision that needs its own evidence, and `watch` has
39861
+ * a panel under 640 px wide that could not use the pixels anyway. Raise these
39862
+ * only with a measurement of the remote link, not by analogy with this one.
39863
+ *
39864
+ * `mid` remains excluded from every class, unrelated to all of the above: it is
39865
+ * a 10 fps stream on this fleet and it has never rendered under any combination
39866
+ * tried.
39867
+ */
39868
+ function slotForConnection(connection) {
39869
+ switch (connection) {
39870
+ case "watch": return "low";
39871
+ case "remote": return "low";
39872
+ case "home-hub": return "low";
39873
+ case "local": return "high";
39874
+ }
39875
+ }
39161
39876
  //#endregion
39162
39877
  //#region src/mappers/builders/stream-bitrate.ts
39163
39878
  /**
39879
+ * Send a stream that FITS the rate HomeKit negotiated. (R5)
39880
+ *
39881
+ * The controller's own Receiver Reports, read on the live hub on 2026-08-06,
39882
+ * closed a year of guessing: one 19.27 s session on `615/mid` forwarded 737
39883
+ * video packets at `mtu=1378` — roughly **421 kbps** — against a negotiated
39884
+ * `max_bit_rate` of **299**, and iOS reported losing **450 of those 737
39885
+ * packets (61 %)**, worst fraction lost 51.2 %, peak jitter 3.03 s. Under
39886
+ * `-c:v copy` the accessory has no lever at all: it forwards whatever the
39887
+ * camera's encoder produces, at whatever cadence it produces it.
39888
+ *
39889
+ * So the fix has two halves, and this module owns both:
39890
+ *
39891
+ * 1. **Choose a slot that fits.** Among the slots that can be passed through
39892
+ * (H.264) and whose rate is known to be within budget, the existing
39893
+ * resolution-closest picker decides — so the "which slot serves which
39894
+ * resolution" opinion stays single, exactly as D51 requires.
39895
+ * 2. **Transcode only when none does**, with a real cap
39896
+ * (`-b:v` / `-maxrate` / `-bufsize`) at the negotiated rate.
39897
+ *
39898
+ * ## Where the authoritative rate comes from, and why it is NOT the obvious one
39899
+ *
39900
+ * `webrtcSession.listStreams` reports a `bitrateKbps` per slot and it is a
39901
+ * **measured flow rate**, which is meaningless for a slot nobody is consuming.
39902
+ * Live on 2026-08-06 it reported `mid = 9 kbps` for the very slot that had just
39903
+ * delivered ~421 kbps, `low = 5 kbps`, and `high = 5441 kbps` (high was being
39904
+ * consumed, hence plausible). Selecting on that reading would admit every slot.
39905
+ *
39906
+ * The authority is therefore the camera's **configured** encoder rate, from
39907
+ * `streamParams.getStatus` — `main` / `sub` / `ext`, each carrying the
39908
+ * `bitrate` the operator (or the vendor default) set. On 615 that is
39909
+ * `main 8192`, `sub 2048`, `ext 2048` kbps. It is mapped onto a profile slot
39910
+ * through the slot's assigned cam-stream, matched on resolution and frame
39911
+ * rate; an ambiguous or absent match is reported as **unknown**, never as a
39912
+ * number.
39913
+ *
39914
+ * This inverts D51's ordering — there, `measured` outranks `published` — and
39915
+ * the inversion is deliberate:
39916
+ *
39917
+ * - a frame rate is a stable property of the source and a measurement of it
39918
+ * is the *best* evidence;
39919
+ * - a bitrate under VBR is an envelope. A measurement is a **lower bound**
39920
+ * on it, and a lower bound can prove a slot does NOT fit but can never
39921
+ * prove that it does.
39922
+ *
39923
+ * So `measured` is kept, and used only in the direction it is sound in.
39924
+ * Everything here is pure; the cap reads live in `stream-bitrate-probe.ts`.
39925
+ */
39926
+ /**
39164
39927
  * Fraction of the negotiated ceiling we actually aim the encoder at.
39165
39928
  *
39166
39929
  * `max_bit_rate` is what the controller budgeted for the stream; what crosses
@@ -39253,7 +40016,8 @@ function classifyBitrateFit(evidence, budgetKbps) {
39253
40016
  function selectStreamForBudget(input) {
39254
40017
  const budgetKbps = budgetForNegotiatedRate(input.negotiatedMaxBitrateKbps);
39255
40018
  const notes = fitNotes(input.entries, input.bitrates, budgetKbps);
39256
- const fallback = pickPreferredRtspEntry(input.entries, input.pref, input.deviceId, { targetResolution: input.targetResolution });
40019
+ const effectivePref = input.pref === "auto" ? slotForConnection(input.connection) : input.pref;
40020
+ const fallback = pickPreferredRtspEntry(input.entries, effectivePref, input.deviceId, { targetResolution: input.targetResolution });
39257
40021
  if (fallback === null) return null;
39258
40022
  const fallbackProfile = toCamProfile$1(fallback.profileId);
39259
40023
  if (budgetKbps === null) {
@@ -39278,7 +40042,7 @@ function selectStreamForBudget(input) {
39278
40042
  return classifyBitrateFit(input.bitrates.get(entry.profile), budgetKbps) === "fits";
39279
40043
  });
39280
40044
  if (affordable.length > 0) {
39281
- const picked = pickPreferredRtspEntry(affordable, input.pref, input.deviceId, { targetResolution: input.targetResolution });
40045
+ const picked = pickPreferredRtspEntry(affordable, effectivePref, input.deviceId, { targetResolution: input.targetResolution });
39282
40046
  if (picked !== null) return {
39283
40047
  kind: "copy",
39284
40048
  reason: "source-fits-budget",
@@ -39323,16 +40087,15 @@ function withSlotCodecs(entries, slots) {
39323
40087
  });
39324
40088
  }
39325
40089
  /**
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.
40090
+ * The video half of the ffmpeg plan, in the SHARED vocabulary
40091
+ * (`@camstack/types` `ffmpeg/invocation.ts`). This function used to emit
40092
+ * arguments; it now describes them, and `buildFfmpegArgs` emits every one — the
40093
+ * repo keeps exactly one argv builder, and HomeKit stopped being an exception
40094
+ * to that (D67, `scripts/check-ffmpeg-primitive.ts` Rule 1).
40095
+ *
40096
+ * Nothing about the RESULT changed except the rescale spelling: `-s WxH` became
40097
+ * `-vf scale=W:H`. Equivalent for a plain rescale, and worth knowing because
40098
+ * the two are NOT interchangeable once another `-vf` is in play.
39336
40099
  *
39337
40100
  * Pass-through carries `-bsf:v dump_extra` so every IDR inlines its own
39338
40101
  * SPS/PPS — sources that publish parameter sets only in the RTSP SDP hand iOS
@@ -39343,54 +40106,42 @@ function deliverableFps(negotiatedFps, slotFps) {
39343
40106
  * **one-second** `-bufsize` bounds any one-second window at the negotiated
39344
40107
  * rate, which is also the only lever available on the 3.03 s peak jitter — the
39345
40108
  * 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
- ];
40109
+ * emitting it as one tight burst. That window is {@link RATE_CONTROL_TIGHT},
40110
+ * the shared constant whose whole reason to exist is HomeKit's per-second
40111
+ * budget; the browser and Echo use the relaxed two-second one.
40112
+ *
40113
+ * **The encoder stays `libx264`, deliberately.** `h264_vaapi` / `h264_qsv`
40114
+ * carry their own rate-control model, do not accept `-profile:v baseline`, and
40115
+ * emit parameter sets on their own schedule rather than x264's which puts the
40116
+ * two load-bearing flags below back in play, with no hardware here to prove
40117
+ * they still hold. Hardware DECODE is where the measured cost is.
40118
+ */
40119
+ function buildVideoPlan(input) {
40120
+ if (!input.transcode) return {
40121
+ kind: "copy",
40122
+ bitstreamFilter: "dump_extra"
40123
+ };
40124
+ return {
40125
+ kind: "encode",
40126
+ encoder: "libx264",
40127
+ scale: {
40128
+ mode: "exact",
40129
+ width: input.width,
40130
+ height: input.height
40131
+ },
40132
+ preset: "ultrafast",
40133
+ tune: "zerolatency",
40134
+ profile: "baseline",
40135
+ level: "3.1",
40136
+ pixelFormat: "yuv420p",
40137
+ fps: input.fps,
40138
+ gopFrames: Math.max(1, Math.round(input.fps * 4)),
40139
+ ...input.budgetKbps === null ? {} : {
40140
+ bitrateKbps: input.budgetKbps,
40141
+ rateControl: RATE_CONTROL_TIGHT
40142
+ },
40143
+ bitstreamFilter: "dump_extra"
40144
+ };
39394
40145
  }
39395
40146
  /** Compact `mid=2048pub/9meas:over-budget` rendering for a single log field. */
39396
40147
  function formatFitNotes(notes) {
@@ -39470,23 +40221,129 @@ function toCamProfile$1(profileId) {
39470
40221
  return CAM_PROFILES$1.find((p) => p === profileId) ?? null;
39471
40222
  }
39472
40223
  //#endregion
40224
+ //#region src/mappers/builders/deadline.ts
40225
+ /**
40226
+ * Bound a piece of optional work in time.
40227
+ *
40228
+ * HomeKit answers `Selected RTP Stream Configuration` inside a write handler
40229
+ * hap-nodejs expects back quickly, and the start path behind it makes six
40230
+ * sequential cross-process cap calls into a stream-broker that regularly
40231
+ * freezes for two to three seconds at a time. Measured on the live hub: the
40232
+ * controller negotiated at :11, gave up at 9.1 s, and the bitrate fit resolved
40233
+ * at :32 — twenty-one seconds — with the start then failing on `Not running`
40234
+ * because the session it was preparing no longer existed.
40235
+ *
40236
+ * The evidence those calls gather is genuinely optional: an absent reading
40237
+ * classifies as `unknown`, and the tolerated branch still picks a slot. So the
40238
+ * right trade under load is to answer with less evidence rather than late, and
40239
+ * this makes that trade explicit at each call site instead of leaving it to
40240
+ * whatever the broker's latency happens to be.
40241
+ *
40242
+ * A late failure from work we stopped waiting on is swallowed on purpose: the
40243
+ * probe keeps running after the deadline fires, and an unhandled rejection
40244
+ * from an abandoned probe would take the process down over a reading nobody is
40245
+ * using any more.
40246
+ */
40247
+ var TIMED_OUT = Symbol("deadline:timed-out");
40248
+ var FAILED = Symbol("deadline:failed");
40249
+ async function withDeadline(work, ms, fallback, onTimeout) {
40250
+ let timer;
40251
+ const guard = new Promise((resolve) => {
40252
+ timer = setTimeout(() => resolve(TIMED_OUT), ms);
40253
+ });
40254
+ try {
40255
+ const settled = await Promise.race([work.catch(() => FAILED), guard]);
40256
+ if (settled === TIMED_OUT) {
40257
+ onTimeout();
40258
+ return fallback;
40259
+ }
40260
+ return settled === FAILED ? fallback : settled;
40261
+ } finally {
40262
+ if (timer !== void 0) clearTimeout(timer);
40263
+ work.catch(() => void 0);
40264
+ }
40265
+ }
40266
+ //#endregion
39473
40267
  //#region src/mappers/builders/stream-bitrate-probe.ts
39474
40268
  /**
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.
40269
+ * Total budget for the rate evidence, not per call the point is to bound
40270
+ * what the CONTROLLER waits for, and it waits for the sum.
40271
+ */
40272
+ var BITRATE_EVIDENCE_BUDGET_MS = 1500;
40273
+ var NO_EVIDENCE = {
40274
+ camStreams: null,
40275
+ streamParams: null,
40276
+ choices: null
40277
+ };
40278
+ /**
40279
+ * The last evidence that actually arrived, per device.
40280
+ *
40281
+ * Falling back to NO evidence on a slow read was not a neutral degradation: it
40282
+ * changed WHICH SLOT the picker chose. Measured on 615 within forty seconds,
40283
+ * same camera, same negotiated 1280x720:
40284
+ *
40285
+ * 10:22:11 mid 10 fps
40286
+ * 10:22:17 low 24 fps
40287
+ * 10:22:26 mid 10 fps
40288
+ * 10:22:50 low 24 fps
40289
+ *
40290
+ * With evidence, `low` classifies as a fit and wins; without it every slot is
40291
+ * `unknown` and the fallback takes `mid`. So the stream a controller received
40292
+ * depended on whether a cap read beat a 1500 ms timer — a coin flip, and one
40293
+ * that hands iOS a different profile on each retry.
40294
+ *
40295
+ * A rate is a property of the camera's encoder configuration, which changes
40296
+ * when an operator changes it and not otherwise. Yesterday's reading is a far
40297
+ * better answer than no reading, and the ONE case that must still see fresh
40298
+ * numbers — the operator lowering a substream — is a deliberate act followed
40299
+ * by a new session, by which time the background read has long landed.
40300
+ */
40301
+ var lastGoodEvidence = /* @__PURE__ */ new Map();
40302
+ /**
40303
+ * Resolve every profile slot's rate. Never throws and never outlives its
40304
+ * budget: a slow read falls back to this device's last good reading, and only
40305
+ * a device that has never answered at all ends up `unknown`.
39478
40306
  */
39479
40307
  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);
40308
+ const deviceId = input.bctx.numericDeviceId;
40309
+ const evidence = await gatherRateEvidence(input.bctx.proxy, input.log, lastGoodEvidence.get(deviceId));
40310
+ if (evidence.camStreams !== null || evidence.streamParams !== null) lastGoodEvidence.set(deviceId, evidence);
39484
40311
  return resolveProfileBitrates({
39485
40312
  slots: input.slots,
39486
- camStreams: camStreams ?? [],
40313
+ camStreams: evidence.camStreams ?? [],
40314
+ streamParams: evidence.streamParams,
40315
+ choices: evidence.choices ?? []
40316
+ });
40317
+ }
40318
+ /**
40319
+ * Issue the three reads CONCURRENTLY under one budget.
40320
+ *
40321
+ * Exported so the concurrency and the budget can be asserted directly: run in
40322
+ * sequence these latencies add, and adding them is what cost a session.
40323
+ */
40324
+ async function gatherRateEvidence(proxy, log, lastGood) {
40325
+ const startedAt = Date.now();
40326
+ const evidence = await withDeadline(Promise.all([
40327
+ probe$1(() => proxy.cameraStreams?.getCameraStreams({}), "cameraStreams.getCameraStreams", log),
40328
+ probe$1(() => proxy.streamParams?.getStatus({}), "streamParams.getStatus", log),
40329
+ probe$1(() => proxy.webrtcSession?.listStreams({}), "webrtcSession.listStreams", log)
40330
+ ]).then(([camStreams, streamParams, choices]) => ({
40331
+ camStreams,
39487
40332
  streamParams,
39488
- choices: choices ?? []
40333
+ choices
40334
+ })), BITRATE_EVIDENCE_BUDGET_MS, lastGood ?? NO_EVIDENCE, () => {
40335
+ log.warn("export-hap: rate evidence ABANDONED on its budget", { meta: {
40336
+ budgetMs: BITRATE_EVIDENCE_BUDGET_MS,
40337
+ fellBackTo: lastGood === void 0 ? "no-evidence" : "last-good",
40338
+ consequence: lastGood === void 0 ? "every slot rate reads UNKNOWN; the picker loses its rate preference" : "the previous reading decides the fit, so the chosen slot stays STABLE"
40339
+ } });
39489
40340
  });
40341
+ const elapsedMs = Date.now() - startedAt;
40342
+ if (elapsedMs > 1500 / 2) log.info("export-hap: rate evidence was slow", { meta: {
40343
+ elapsedMs,
40344
+ budgetMs: BITRATE_EVIDENCE_BUDGET_MS
40345
+ } });
40346
+ return evidence;
39490
40347
  }
39491
40348
  async function probe$1(call, label, log) {
39492
40349
  try {
@@ -39504,61 +40361,138 @@ async function probe$1(call, label, log) {
39504
40361
  return null;
39505
40362
  }
39506
40363
  }
40364
+ //#endregion
40365
+ //#region src/mappers/builders/stream-ffmpeg-args.ts
40366
+ /**
40367
+ * The ffmpeg PLAN for one HomeKit streaming session.
40368
+ *
40369
+ * This file used to assemble the argument vector by hand. It no longer emits a
40370
+ * single argument: it describes the session as an {@link FfmpegInvocation} and
40371
+ * `buildFfmpegArgs` (`@camstack/types` `ffmpeg/invocation.ts`) emits every one.
40372
+ * The repo keeps exactly ONE argv builder — `scripts/check-ffmpeg-primitive.ts`
40373
+ * Rule 1 refuses a second, and HomeKit was the last exception (D67).
40374
+ *
40375
+ * ## What moved behind the primitive, and what stayed here
40376
+ *
40377
+ * MOVED — everything that describes an ENCODE, because it is the same job every
40378
+ * other live egress does and the repo had five disagreeing copies of it: the
40379
+ * encoder, preset, tune, profile, level, pixel format, rate, GOP, the tight VBV
40380
+ * window ({@link RATE_CONTROL_TIGHT}), the bitstream filter, and the Opus block
40381
+ * ({@link HAP_AUDIO_BASE}).
40382
+ *
40383
+ * STAYED — everything that is a HAP PROTOCOL fact and belongs to no other
40384
+ * consumer: the payload types, the SSRCs (and their signed-int32 coercion), the
40385
+ * MTU baked into each `rtp://…?pkt_size=` target, the loopback ports the
40386
+ * JS-side SRTP encrypt reads from, and the negotiated audio sample rate and
40387
+ * packet time.
40388
+ *
40389
+ * ## The two flags this file exists to protect
40390
+ *
40391
+ * `-g` and `-bsf:v dump_extra` were two of the four causes of the year-long
40392
+ * failure, and both live in the encode plan now. They are asserted by token
40393
+ * AND by position in `__tests__/stream-ffmpeg-argv.spec.ts`, on both the copy
40394
+ * and the encode branch, so the move behind the primitive cannot quietly drop
40395
+ * either. Every other comment below records something learned the expensive
40396
+ * way; deleting one loses the reason a flag is there.
40397
+ */
40398
+ /**
40399
+ * Opus encoder targets — kept low because:
40400
+ * - Camera audio is overwhelmingly speech / ambient noise; 24 kbps mono
40401
+ * is the published "fullband speech" sweet spot for libopus (well
40402
+ * above the 20 kbps "wideband speech" floor).
40403
+ * - HAP audio is one-shot live (no buffering on the controller side),
40404
+ * so under-shooting the bitrate is cheaper than over-shooting it and
40405
+ * hitting jitter.
40406
+ * - Mono / low-delay profile matches Apple Home's published Opus decoder
40407
+ * expectations for camera accessories.
40408
+ *
40409
+ * The numbers themselves live in `@camstack/types` `ffmpeg/encode-defaults.ts`
40410
+ * now, alongside every other live-egress constant, so the five sets that used
40411
+ * to disagree about Opus channel count can be diffed in one place. Re-exported
40412
+ * here because the session telemetry reports the bitrate it dialled.
40413
+ */
40414
+ var OPUS_BITRATE_KBPS = 24;
40415
+ /**
40416
+ * The Opus plane, per session.
40417
+ *
40418
+ * Re-encoded regardless of source codec: the source pool is a mix of
40419
+ * PCM_MULAW, PCM_ALAW, G.711 and AAC depending on driver, and Apple Home
40420
+ * expects Opus on the wire.
40421
+ *
40422
+ * `sampleRateHz` and `frameDurationMs` are NEGOTIATED — the controller picks
40423
+ * them — which is why the shared {@link HAP_AUDIO_BASE} leaves both out and
40424
+ * they are filled in here.
40425
+ *
40426
+ * CRITICAL on the sample rate: encode at the rate iOS asked for, never a
40427
+ * constant. iOS's `AudioStreamingSamplerate` enum surfaces as 8 / 16 / 24 kHz;
40428
+ * encoding at 24 when iOS asked for 16 produces RTP timestamps stepping by 480
40429
+ * samples/packet against a clock expecting 320 — the SRTP frames decrypt
40430
+ * cleanly but the speaker stays mute, because the timestamps slide out of the
40431
+ * AV-sync window before the first Opus frame renders. The same request value
40432
+ * drives `audioIntervalScale` in the re-stamping pass, so the two MUST come
40433
+ * from one source.
40434
+ *
40435
+ * On the frame duration: libopus emits exactly one RTP packet per Opus frame at
40436
+ * that duration, and matching HAP's `packet_time` (20 ms on LAN, 30/40/60 on
40437
+ * LTE) is what keeps the 1:1 frame↔packet mapping the controller expects.
40438
+ */
40439
+ function audioPlan(input) {
40440
+ return {
40441
+ ...HAP_AUDIO_BASE,
40442
+ sampleRateHz: input.audioSampleRateKhz * 1e3,
40443
+ frameDurationMs: input.audioPacketTimeMs,
40444
+ vbvBufferKbits: 96
40445
+ };
40446
+ }
39507
40447
  /**
39508
40448
  * 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.
40449
+ * lifetime, one kill signal. The shared builder's `rtp-outputs` sink maps each
40450
+ * plane explicitly (`-an -map 0:v:0` / `-vn -map 0:a:0?`) so ffmpeg never
40451
+ * guesses which stream belongs where, and `0:a:0?` makes the audio optional so
40452
+ * a source with no microphone skips it instead of failing the invocation.
39511
40453
  */
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
- ];
40454
+ /**
40455
+ * How long ffmpeg may inspect the broker's restream before emitting.
40456
+ *
40457
+ * Not zero. A zero-length probe makes ffmpeg trust the SDP completely, and an
40458
+ * RTSP source that announces a track it then never sends would leave the
40459
+ * mapping wrong with no way to notice. 200 ms and 64 KB is far below the
40460
+ * shortest key-frame interval on this fleet while still letting the demuxer
40461
+ * see real packets — enough to be honest, short enough that nobody watches it.
40462
+ */
40463
+ var HAP_INPUT_PROBE = {
40464
+ analyzeDurationUs: 2e5,
40465
+ probeSizeBytes: 64 * 1024
40466
+ };
40467
+ function buildSessionInvocation(input) {
40468
+ return {
40469
+ logLevel: "warning",
40470
+ decodeHwAccel: input.decode.hwaccel,
40471
+ input: {
40472
+ url: input.rtspUrl,
40473
+ rtspTransport: "tcp",
40474
+ analyzeDurationUs: HAP_INPUT_PROBE.analyzeDurationUs,
40475
+ probeSizeBytes: HAP_INPUT_PROBE.probeSizeBytes,
40476
+ ...input.decode.extraInputArgs.length > 0 ? { extraArgs: input.decode.extraInputArgs } : {}
40477
+ },
40478
+ video: input.video,
40479
+ audio: audioPlan(input),
40480
+ threadCount: 0,
40481
+ outputArgs: [],
40482
+ sink: {
40483
+ kind: "rtp-outputs",
40484
+ video: {
40485
+ url: input.videoTarget,
40486
+ payloadType: input.videoPayloadType,
40487
+ ssrc: input.videoSsrcSigned
40488
+ },
40489
+ audio: {
40490
+ url: input.audioTarget,
40491
+ payloadType: input.audioPayloadType,
40492
+ ssrc: input.audioSsrcSigned
40493
+ }
40494
+ }
40495
+ };
39562
40496
  }
39563
40497
  /**
39564
40498
  * The resolutions we offer, before rates are attached. Same list the delegate
@@ -39698,6 +40632,84 @@ var CAM_PROFILES = [
39698
40632
  function toCamProfile(profileId) {
39699
40633
  return CAM_PROFILES.find((p) => p === profileId) ?? null;
39700
40634
  }
40635
+ //#endregion
40636
+ //#region src/mappers/builders/h264-idr.ts
40637
+ /**
40638
+ * Does this RTP packet carry the start of an H.264 IDR?
40639
+ *
40640
+ * A pass-through session cannot manufacture a key frame on demand — it can
40641
+ * only forward the one the camera decides to emit. So the number that decides
40642
+ * whether a controller sees a picture or a loader is *how long it waited for
40643
+ * the first IDR*, and until now nothing measured it: a session could report
40644
+ * a thousand packets forwarded, zero loss, and a blank screen, with no field
40645
+ * distinguishing "the stream is broken" from "the next key frame is 20
40646
+ * seconds away".
40647
+ *
40648
+ * That is the whole reason this exists, so it is deliberately narrow: a
40649
+ * boolean per packet, no state, no allocation, and it never throws. It runs on
40650
+ * every forwarded video packet, and a parser that throws on a malformed packet
40651
+ * would take the media path down with it.
40652
+ */
40653
+ /** NAL unit type carrying a coded slice of an IDR picture (RFC 6184 §5.2). */
40654
+ var NAL_TYPE_IDR = 5;
40655
+ /** Single-time aggregation packet — several NALs in one RTP payload. */
40656
+ var NAL_TYPE_STAP_A = 24;
40657
+ /** Fragmentation units: one NAL spread over several RTP payloads. */
40658
+ var NAL_TYPE_FU_A = 28;
40659
+ var NAL_TYPE_FU_B = 29;
40660
+ var RTP_MIN_HEADER_BYTES = 12;
40661
+ var NAL_TYPE_MASK = 31;
40662
+ /** FU header start bit — set only on the FIRST fragment of a fragmented NAL. */
40663
+ var FU_START_BIT = 128;
40664
+ function rtpPacketCarriesIdr(packet) {
40665
+ const payloadStart = rtpPayloadOffset(packet);
40666
+ if (payloadStart === null) return false;
40667
+ const firstPayloadByte = packet[payloadStart];
40668
+ if (firstPayloadByte === void 0) return false;
40669
+ const nalType = firstPayloadByte & NAL_TYPE_MASK;
40670
+ if (nalType === NAL_TYPE_FU_A || nalType === NAL_TYPE_FU_B) {
40671
+ const fuHeader = packet[payloadStart + 1];
40672
+ if (fuHeader === void 0) return false;
40673
+ if ((fuHeader & FU_START_BIT) === 0) return false;
40674
+ return (fuHeader & NAL_TYPE_MASK) === NAL_TYPE_IDR;
40675
+ }
40676
+ if (nalType === NAL_TYPE_STAP_A) return stapContainsIdr(packet, payloadStart + 1);
40677
+ return nalType === NAL_TYPE_IDR;
40678
+ }
40679
+ /**
40680
+ * Byte offset of the RTP payload, or `null` when the packet is too short to
40681
+ * hold one. The variable-length parts are what make this worth a function:
40682
+ * a fixed offset of 12 is right for every packet ffmpeg emits today and wrong
40683
+ * the moment one carries a CSRC list or a header extension.
40684
+ */
40685
+ function rtpPayloadOffset(packet) {
40686
+ if (packet.length <= RTP_MIN_HEADER_BYTES) return null;
40687
+ const flags = packet[0];
40688
+ if (flags === void 0) return null;
40689
+ const csrcCount = flags & 15;
40690
+ const hasExtension = (flags & 16) !== 0;
40691
+ let offset = RTP_MIN_HEADER_BYTES + csrcCount * 4;
40692
+ if (hasExtension) {
40693
+ if (offset + 4 > packet.length) return null;
40694
+ const words = packet.readUInt16BE(offset + 2);
40695
+ offset += 4 + words * 4;
40696
+ }
40697
+ return offset < packet.length ? offset : null;
40698
+ }
40699
+ /** Walk a STAP-A's `[size][nal]` pairs looking for an IDR. */
40700
+ function stapContainsIdr(packet, start) {
40701
+ let offset = start;
40702
+ while (offset + 2 <= packet.length) {
40703
+ const size = packet.readUInt16BE(offset);
40704
+ offset += 2;
40705
+ if (size === 0 || offset + size > packet.length) return false;
40706
+ const nalHeader = packet[offset];
40707
+ if (nalHeader === void 0) return false;
40708
+ if ((nalHeader & NAL_TYPE_MASK) === NAL_TYPE_IDR) return true;
40709
+ offset += size;
40710
+ }
40711
+ return false;
40712
+ }
39701
40713
  /**
39702
40714
  * How long after spawn an exit still counts as "hardware init failed".
39703
40715
  *
@@ -39762,34 +40774,65 @@ function software(reason) {
39762
40774
  return {
39763
40775
  kind: "software",
39764
40776
  reason,
40777
+ hwaccel: null,
40778
+ extraInputArgs: [],
39765
40779
  args: []
39766
40780
  };
39767
40781
  }
39768
40782
  function hardware(backend, source, input) {
40783
+ if (input.recentlyFailedBackend === backend) return software("hardware-attempt-failed");
40784
+ const { hwaccel, extraInputArgs } = decodePlan(backend, input);
39769
40785
  return {
39770
40786
  kind: "hardware",
39771
40787
  backend,
39772
40788
  source,
39773
- args: decodeArgs(backend, input)
40789
+ hwaccel,
40790
+ extraInputArgs,
40791
+ args: [
40792
+ "-hwaccel",
40793
+ hwaccel,
40794
+ ...extraInputArgs
40795
+ ]
39774
40796
  };
39775
40797
  }
39776
40798
  /**
39777
- * The input-side flags, and nothing else.
40799
+ * The input-side decode configuration, and nothing else.
39778
40800
  *
39779
40801
  * No `-hwaccel_output_format`: the decoded frames have to land in system
39780
40802
  * memory for libx264 to scale and encode them. Setting it would keep them on
39781
40803
  * the GPU, which only pays off with a GPU scale filter — and that is the
39782
40804
  * decoder addon's job, not a two-output SRTP session's.
40805
+ *
40806
+ * The two halves are returned SEPARATELY because the shared argv builder emits
40807
+ * `-hwaccel` itself (it is the only function allowed to, so the flag cannot
40808
+ * drift past `-i`) and takes everything else as the input plan's `extraArgs`.
39783
40809
  */
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;
40810
+ function decodePlan(backend, input) {
40811
+ if (backend === "videotoolbox" && input.platform === "darwin") return {
40812
+ hwaccel: "auto",
40813
+ extraInputArgs: []
40814
+ };
40815
+ return {
40816
+ hwaccel: backend,
40817
+ extraInputArgs: RENDER_NODE_BACKENDS.includes(backend) ? ["-hwaccel_device", input.renderDevice ?? "/dev/dri/renderD128"] : []
40818
+ };
39789
40819
  }
39790
40820
  //#endregion
39791
40821
  //#region src/mappers/builders/stream-hwaccel-probe.ts
39792
40822
  /**
40823
+ * The real read: `decoder.getInfo`, pinned to the LOCAL node.
40824
+ *
40825
+ * Pinned explicitly rather than left to routing, because an unpinned singleton
40826
+ * cap answers from whichever node owns it and would report the WRONG host's
40827
+ * hardware.
40828
+ */
40829
+ function decoderInfoSourceFromContext(ctx) {
40830
+ return {
40831
+ localNodeId: ctx.kernel?.localNodeId,
40832
+ readInfo: (nodeId) => ctx.api.decoder.getInfo.query(void 0, nodePin(nodeId))
40833
+ };
40834
+ }
40835
+ /**
39793
40836
  * Read this node's decode-hwaccel state, or `null` when nothing answered.
39794
40837
  *
39795
40838
  * Never throws. `null` means "we do not know", which
@@ -39797,27 +40840,144 @@ function decodeArgs(backend, input) {
39797
40840
  * the safe direction, because a guess here costs the whole stream.
39798
40841
  */
39799
40842
  async function probeDecoderHwaccel(input) {
39800
- const { ctx, log } = input;
39801
- const nodeId = ctx.kernel?.localNodeId;
40843
+ const { source, log, memo } = input;
40844
+ const memoised = memo.read();
40845
+ if (memoised !== void 0) return memoised;
40846
+ const nodeId = source.localNodeId;
39802
40847
  if (nodeId === void 0 || nodeId.length === 0) {
39803
40848
  log.warn("export-hap: hwaccel probe skipped — no local node id, decoding in SOFTWARE");
39804
40849
  return null;
39805
40850
  }
39806
40851
  try {
39807
- const info = await ctx.api.decoder.getInfo.query(void 0, nodePin(nodeId));
40852
+ const info = await source.readInfo(nodeId);
39808
40853
  if (info === null || info === void 0) {
39809
40854
  log.info("export-hap: hwaccel probe returned nothing — decoding in SOFTWARE", { meta: { nodeId } });
40855
+ memo.write(null);
39810
40856
  return null;
39811
40857
  }
39812
- return {
40858
+ const reading = {
39813
40859
  hwaccel: info.hwaccel ?? null,
39814
40860
  probedBestHwaccel: info.probedBestHwaccel ?? null
39815
40861
  };
40862
+ memo.write(reading);
40863
+ return reading;
39816
40864
  } catch (err) {
39817
40865
  log.warn("export-hap: hwaccel probe failed — decoding in SOFTWARE", { meta: {
39818
40866
  nodeId,
39819
40867
  error: err instanceof Error ? err.message : String(err)
39820
40868
  } });
40869
+ memo.write(null);
40870
+ return null;
40871
+ }
40872
+ }
40873
+ /**
40874
+ * What we ask for on the VIDEO loopback socket.
40875
+ *
40876
+ * Generous on purpose: the cost is virtual address space the kernel only
40877
+ * commits as datagrams actually queue, and the failure it prevents is a black
40878
+ * tile. Sized well above {@link KEYFRAME_BURST_FLOOR_BYTES} so a slow drain
40879
+ * (the JS forwarder is on the same event loop as everything else this addon
40880
+ * does) still has headroom.
40881
+ */
40882
+ var VIDEO_LOOPBACK_RCVBUF_BYTES = 8 * 1024 * 1024;
40883
+ /**
40884
+ * What we ask for on the AUDIO loopback socket.
40885
+ *
40886
+ * Audio never bursts — that is the control in this experiment, and it is why
40887
+ * the two legs get different numbers rather than one shared constant. If audio
40888
+ * ever starts dropping at the same buffer that carries video fine, the cause is
40889
+ * not burst size.
40890
+ */
40891
+ var AUDIO_LOOPBACK_RCVBUF_BYTES = 1024 * 1024;
40892
+ function errMsg$9(err) {
40893
+ return err instanceof Error ? err.message : String(err);
40894
+ }
40895
+ /**
40896
+ * Set `SO_RCVBUF` and READ IT BACK.
40897
+ *
40898
+ * Never throws: a platform that refuses the option must cost the buffer, never
40899
+ * the session. The read-back is the point — a request the kernel clamped and a
40900
+ * request it honoured are indistinguishable at the call site.
40901
+ */
40902
+ function applyReceiveBuffer(socket, requestedBytes) {
40903
+ let error = null;
40904
+ try {
40905
+ socket.setRecvBufferSize(requestedBytes);
40906
+ } catch (err) {
40907
+ error = errMsg$9(err);
40908
+ }
40909
+ let effectiveBytes = null;
40910
+ try {
40911
+ effectiveBytes = socket.getRecvBufferSize();
40912
+ } catch (err) {
40913
+ if (error === null) error = errMsg$9(err);
40914
+ }
40915
+ return {
40916
+ requestedBytes,
40917
+ effectiveBytes,
40918
+ clamped: effectiveBytes !== null && effectiveBytes < requestedBytes,
40919
+ sufficientForKeyframeBurst: effectiveBytes !== null && effectiveBytes >= 2097152,
40920
+ error
40921
+ };
40922
+ }
40923
+ /** Where the kernel publishes per-socket UDP counters, by address family. */
40924
+ var PROC_NET_UDP = {
40925
+ ipv4: "/proc/net/udp",
40926
+ ipv6: "/proc/net/udp6"
40927
+ };
40928
+ /**
40929
+ * The per-socket `drops` count for `port`, out of a `/proc/net/udp` table.
40930
+ *
40931
+ * Pure so the format assumption is pinned by a test rather than by a live
40932
+ * kernel. Returns `null` when the port has no row — which is NOT the same as
40933
+ * zero drops, and the two must never collapse: `0` is evidence the buffer held,
40934
+ * `null` is the absence of evidence.
40935
+ */
40936
+ function parseUdpSocketDrops(table, port) {
40937
+ const lines = table.split("\n");
40938
+ for (const line of lines) {
40939
+ const fields = line.trim().split(/\s+/);
40940
+ if (fields.length < 13) continue;
40941
+ const local = fields[1];
40942
+ if (local === void 0) continue;
40943
+ const hexPort = local.split(":")[1];
40944
+ if (hexPort === void 0) continue;
40945
+ const parsedPort = Number.parseInt(hexPort, 16);
40946
+ if (!Number.isFinite(parsedPort) || parsedPort !== port) continue;
40947
+ const drops = Number(fields[fields.length - 1]);
40948
+ return Number.isFinite(drops) ? drops : null;
40949
+ }
40950
+ return null;
40951
+ }
40952
+ /**
40953
+ * Fold a fresh drop sample into the one already held.
40954
+ *
40955
+ * A socket that has been CLOSED disappears from `/proc/net/udp`, so a resample
40956
+ * after teardown returns `null` — "I can no longer look", which must never
40957
+ * erase "I looked and it was 0". The first live session that proved the buffer
40958
+ * fix reported `videoLoopKernelDrops=null` for exactly this reason: on a
40959
+ * controller `stop` the sockets are closed synchronously while the summary is
40960
+ * emitted later from ffmpeg's `exit` handler.
40961
+ */
40962
+ function mergeDropSample(previous, sampled) {
40963
+ return sampled ?? previous;
40964
+ }
40965
+ /**
40966
+ * Read the kernel's drop counter for a bound local UDP port.
40967
+ *
40968
+ * Linux only — `null` on every other platform and on every read failure, which
40969
+ * is honest: "we could not look" and "nothing was dropped" are different
40970
+ * answers and this returns the first as `null`.
40971
+ *
40972
+ * Synchronous on purpose. It is called at the session heartbeat (5 s) and once
40973
+ * at teardown, against a memory-backed pseudo-file; making it async would mean
40974
+ * the SUMMARY line — the one line this experiment is read from — could not
40975
+ * carry a fresh count, which is the only reason it exists.
40976
+ */
40977
+ function readUdpSocketDrops(port, ipVersion) {
40978
+ try {
40979
+ return parseUdpSocketDrops(readFileSync(PROC_NET_UDP[ipVersion], "utf8"), port);
40980
+ } catch {
39821
40981
  return null;
39822
40982
  }
39823
40983
  }
@@ -39946,6 +41106,9 @@ function summariseSession(snapshot) {
39946
41106
  encodeBudgetKbps: slot?.budgetKbps ?? null,
39947
41107
  fitNotes: slot?.fitNotes ?? [],
39948
41108
  videoPacketsForwarded: snapshot.videoPacketsForwarded,
41109
+ msToFirstKeyframe: snapshot.firstKeyframeAtMs === null || snapshot.startedAtMs === null ? null : snapshot.firstKeyframeAtMs - snapshot.startedAtMs,
41110
+ videoKeyframes: snapshot.videoKeyframes,
41111
+ maxKeyframeGapMs: snapshot.maxKeyframeGapMs,
39949
41112
  audioPacketsForwarded: snapshot.audioPacketsForwarded,
39950
41113
  videoRtcpSrSent: snapshot.videoRtcpSrSent,
39951
41114
  audioRtcpSrSent: snapshot.audioRtcpSrSent,
@@ -39960,11 +41123,21 @@ function summariseSession(snapshot) {
39960
41123
  videoLossVerdict: lossVerdict(snapshot.videoReceiverReports),
39961
41124
  audioLossVerdict: lossVerdict(snapshot.audioReceiverReports),
39962
41125
  mediaStarved: snapshot.videoPacketsForwarded === 0,
41126
+ videoLoopRcvbufRequestedBytes: snapshot.videoLoopback.rcvbufRequestedBytes,
41127
+ videoLoopRcvbufBytes: snapshot.videoLoopback.rcvbufEffectiveBytes,
41128
+ videoLoopRcvbufClamped: snapshot.videoLoopback.rcvbufClamped,
41129
+ videoLoopKernelDrops: snapshot.videoLoopback.kernelDrops,
41130
+ videoLoopKernelDropped: (snapshot.videoLoopback.kernelDrops ?? 0) > 0,
41131
+ audioLoopRcvbufBytes: snapshot.audioLoopback.rcvbufEffectiveBytes,
41132
+ audioLoopKernelDrops: snapshot.audioLoopback.kernelDrops,
41133
+ audioLoopKernelDropped: (snapshot.audioLoopback.kernelDrops ?? 0) > 0,
39963
41134
  drops: nonZeroDrops(snapshot.drops)
39964
41135
  };
39965
41136
  }
39966
41137
  //#endregion
39967
41138
  //#region src/mappers/builders/camera-streams.ts
41139
+ /** A decoder that is slow to describe itself costs hardware decode, not the session. */
41140
+ var HWACCEL_PROBE_BUDGET_MS = 1e3;
39968
41141
  var SRTP_KEY_LEN = 16;
39969
41142
  var SRTP_SALT_LEN = 14;
39970
41143
  /**
@@ -40048,6 +41221,7 @@ function buildCameraStreamingDelegate(bctx, advertised) {
40048
41221
  const hadFfmpeg = session.ffmpeg !== null;
40049
41222
  killFfmpeg(session, ctx, numericDeviceId);
40050
41223
  stopHeartbeat(session);
41224
+ sampleLoopbackDrops(session);
40051
41225
  if (!hadFfmpeg) logSessionSummary(session, log, "accessory-dispose-no-ffmpeg");
40052
41226
  await closeIntercomTalkSession(session, bctx).catch(() => void 0);
40053
41227
  closeSocket(session.videoUdp);
@@ -40076,8 +41250,10 @@ async function prepareStream(request, sessions, bctx) {
40076
41250
  const localIp = pickLocalInterfaceIp(request.targetAddress, ipVersion);
40077
41251
  const videoUdp = await bindUdp(ipVersion, localIp);
40078
41252
  const audioUdp = await bindUdp(ipVersion, localIp);
40079
- const videoLoopUdp = await bindLoopback(ipVersion);
40080
- const audioLoopUdp = await bindLoopback(ipVersion);
41253
+ const videoLoop = await bindLoopback(ipVersion, VIDEO_LOOPBACK_RCVBUF_BYTES);
41254
+ const audioLoop = await bindLoopback(ipVersion, AUDIO_LOOPBACK_RCVBUF_BYTES);
41255
+ const videoLoopUdp = videoLoop.socket;
41256
+ const audioLoopUdp = audioLoop.socket;
40081
41257
  const localVideoPort = videoUdp.address().port;
40082
41258
  const localAudioPort = audioUdp.address().port;
40083
41259
  if (request.video.srtp_key.length !== SRTP_KEY_LEN || request.video.srtp_salt.length !== SRTP_SALT_LEN || request.audio.srtp_key.length !== SRTP_KEY_LEN || request.audio.srtp_salt.length !== SRTP_SALT_LEN) {
@@ -40153,6 +41329,10 @@ async function prepareStream(request, sessions, bctx) {
40153
41329
  drops: emptyDropCounters(),
40154
41330
  videoPacketsForwarded: 0,
40155
41331
  audioPacketsForwarded: 0,
41332
+ videoKeyframes: 0,
41333
+ firstKeyframeAtMs: null,
41334
+ lastKeyframeAtMs: null,
41335
+ maxKeyframeGapMs: 0,
40156
41336
  videoRtcpSrSent: 0,
40157
41337
  audioRtcpSrSent: 0,
40158
41338
  videoRtcpReceived: 0,
@@ -40192,6 +41372,12 @@ async function prepareStream(request, sessions, bctx) {
40192
41372
  audioInSrtcp,
40193
41373
  audioSendGate: null,
40194
41374
  ipVersion,
41375
+ videoLoopRcvbuf: videoLoop.buffer,
41376
+ audioLoopRcvbuf: audioLoop.buffer,
41377
+ videoLoopPort: videoLoopUdp.address().port,
41378
+ audioLoopPort: audioLoopUdp.address().port,
41379
+ videoLoopKernelDrops: null,
41380
+ audioLoopKernelDrops: null,
40195
41381
  ffmpeg: null,
40196
41382
  lastStartParams: null,
40197
41383
  upstreamAudioSrtp,
@@ -40231,6 +41417,7 @@ async function prepareStream(request, sessions, bctx) {
40231
41417
  });
40232
41418
  videoLoopUdp.on("message", (rtpPacket) => {
40233
41419
  session.videoPacketsForwarded += 1;
41420
+ if (rtpPacketCarriesIdr(rtpPacket)) recordKeyframe(session);
40234
41421
  if (session.videoPacketsForwarded === 1) tagLog.info("export-hap: first video packet from ffmpeg", { meta: {
40235
41422
  sessionId: session.sessionId,
40236
41423
  bytes: rtpPacket.length
@@ -40246,6 +41433,8 @@ async function prepareStream(request, sessions, bctx) {
40246
41433
  bctx.ctx.logger.withTags({ deviceId: bctx.numericDeviceId }).debug("export-hap: incoming-audio handler error (dropped)", { meta: { error: errMsg$8(err) } });
40247
41434
  });
40248
41435
  });
41436
+ logLoopbackBuffer(tagLog, request.sessionID, "video", videoLoop.buffer);
41437
+ logLoopbackBuffer(tagLog, request.sessionID, "audio", audioLoop.buffer);
40249
41438
  tagLog.info("export-hap: stream prepared", { meta: {
40250
41439
  sessionId: request.sessionID,
40251
41440
  controllerAddress: request.targetAddress,
@@ -40326,12 +41515,45 @@ function sameIpv4Subnet(a, mask, b) {
40326
41515
  return true;
40327
41516
  }
40328
41517
  /**
41518
+ * Report one loopback socket's receive buffer.
41519
+ *
41520
+ * `warn` when the video leg cannot hold a 4K key-frame burst: that is the state
41521
+ * in which this addon silently drops most of a key frame and the tile stays
41522
+ * black, and it was invisible for the whole life of this code path.
41523
+ */
41524
+ function logLoopbackBuffer(log, sessionId, leg, outcome) {
41525
+ const meta = {
41526
+ sessionId,
41527
+ leg,
41528
+ requestedBytes: outcome.requestedBytes,
41529
+ effectiveBytes: outcome.effectiveBytes,
41530
+ clamped: outcome.clamped,
41531
+ sufficientForKeyframeBurst: outcome.sufficientForKeyframeBurst,
41532
+ error: outcome.error
41533
+ };
41534
+ if (leg === "video" && !outcome.sufficientForKeyframeBurst) {
41535
+ log.warn("export-hap: loopback receive buffer is TOO SMALL for a key-frame burst — raise net.core.rmem_max on the host", { meta });
41536
+ return;
41537
+ }
41538
+ log.info("export-hap: loopback receive buffer", { meta });
41539
+ }
41540
+ /**
40329
41541
  * Resolve once-per-session: the local IP we bind iOS-facing sockets to
40330
41542
  * AND its mate on `127.0.0.1` for the ffmpeg loopback path. Both go
40331
41543
  * through the bounded-wait `dgram.bind` pattern.
41544
+ *
41545
+ * `SO_RCVBUF` is set AFTER the bind and read back, never assumed. Until
41546
+ * 2026-08-07 nothing set it at all, so these sockets ran on
41547
+ * `net.core.rmem_default` (212 992 B on this hub) — about a fifth of one 4K
41548
+ * key frame, which arrives as ~750 datagrams in one burst. See
41549
+ * `stream-socket-buffer.ts` for the measurement.
40332
41550
  */
40333
- async function bindLoopback(ipVersion) {
40334
- return bindUdp(ipVersion, ipVersion === "ipv6" ? "::1" : "127.0.0.1");
41551
+ async function bindLoopback(ipVersion, requestedRcvbufBytes) {
41552
+ const socket = await bindUdp(ipVersion, ipVersion === "ipv6" ? "::1" : "127.0.0.1");
41553
+ return {
41554
+ socket,
41555
+ buffer: applyReceiveBuffer(socket, requestedRcvbufBytes)
41556
+ };
40335
41557
  }
40336
41558
  /** Book a named drop. Every silent `return` on the streaming path routes here. */
40337
41559
  function drop(session, reason) {
@@ -40472,6 +41694,30 @@ function logReceiverReports(session, leg, reports, isFirst, log) {
40472
41694
  if (!shouldLogReceiverReport(session, leg)) return;
40473
41695
  log.info("export-hap: controller receiver report", { meta });
40474
41696
  }
41697
+ /**
41698
+ * Record a forwarded key frame. Kept separate from the packet counter because
41699
+ * the interesting quantity is TIMING, not a tally: the first arrival dates the
41700
+ * moment the controller could begin decoding, and the widest gap says how long
41701
+ * a mid-GOP join can be expected to stare at a loader.
41702
+ */
41703
+ function recordKeyframe(session) {
41704
+ const now = Date.now();
41705
+ session.videoKeyframes += 1;
41706
+ if (session.firstKeyframeAtMs === null) session.firstKeyframeAtMs = now;
41707
+ else if (session.lastKeyframeAtMs !== null) session.maxKeyframeGapMs = Math.max(session.maxKeyframeGapMs, now - session.lastKeyframeAtMs);
41708
+ session.lastKeyframeAtMs = now;
41709
+ }
41710
+ /**
41711
+ * Refresh the kernel's per-socket drop counters.
41712
+ *
41713
+ * Called immediately before every line that reports them, because a stale
41714
+ * sample on the summary would answer the experiment's central question with
41715
+ * data from five seconds earlier. Cheap: `/proc/net/udp` is memory-backed.
41716
+ */
41717
+ function sampleLoopbackDrops(session) {
41718
+ session.videoLoopKernelDrops = mergeDropSample(session.videoLoopKernelDrops, readUdpSocketDrops(session.videoLoopPort, session.ipVersion));
41719
+ session.audioLoopKernelDrops = mergeDropSample(session.audioLoopKernelDrops, readUdpSocketDrops(session.audioLoopPort, session.ipVersion));
41720
+ }
40475
41721
  /** Snapshot every counter into the summary meta. */
40476
41722
  function sessionSummaryMeta(session) {
40477
41723
  return summariseSession({
@@ -40482,6 +41728,9 @@ function sessionSummaryMeta(session) {
40482
41728
  selectedSlot: session.selectedSlot,
40483
41729
  videoPacketsForwarded: session.videoPacketsForwarded,
40484
41730
  audioPacketsForwarded: session.audioPacketsForwarded,
41731
+ videoKeyframes: session.videoKeyframes,
41732
+ firstKeyframeAtMs: session.firstKeyframeAtMs,
41733
+ maxKeyframeGapMs: session.maxKeyframeGapMs,
40485
41734
  videoRtcpSrSent: session.videoRtcpSrSent,
40486
41735
  audioRtcpSrSent: session.audioRtcpSrSent,
40487
41736
  videoRtcpReceived: session.videoRtcpReceived,
@@ -40494,7 +41743,19 @@ function sessionSummaryMeta(session) {
40494
41743
  audioReceiverReports: session.audioReceiverReports,
40495
41744
  drops: session.drops,
40496
41745
  ffmpegExit: session.ffmpegExit,
40497
- stopRequestedByController: session.stopRequestedByController
41746
+ stopRequestedByController: session.stopRequestedByController,
41747
+ videoLoopback: {
41748
+ rcvbufRequestedBytes: session.videoLoopRcvbuf.requestedBytes,
41749
+ rcvbufEffectiveBytes: session.videoLoopRcvbuf.effectiveBytes,
41750
+ rcvbufClamped: session.videoLoopRcvbuf.clamped,
41751
+ kernelDrops: session.videoLoopKernelDrops
41752
+ },
41753
+ audioLoopback: {
41754
+ rcvbufRequestedBytes: session.audioLoopRcvbuf.requestedBytes,
41755
+ rcvbufEffectiveBytes: session.audioLoopRcvbuf.effectiveBytes,
41756
+ rcvbufClamped: session.audioLoopRcvbuf.clamped,
41757
+ kernelDrops: session.audioLoopKernelDrops
41758
+ }
40498
41759
  });
40499
41760
  }
40500
41761
  /**
@@ -40508,6 +41769,7 @@ function armHeartbeat(session, log) {
40508
41769
  const timer = setInterval(() => {
40509
41770
  const forwarded = session.videoPacketsForwarded - lastVideo;
40510
41771
  lastVideo = session.videoPacketsForwarded;
41772
+ sampleLoopbackDrops(session);
40511
41773
  log.info("export-hap: stream heartbeat", { meta: {
40512
41774
  ...sessionSummaryMeta(session),
40513
41775
  videoPacketsSinceLastBeat: forwarded,
@@ -40546,6 +41808,7 @@ function stopHeartbeat(session) {
40546
41808
  */
40547
41809
  function logSessionSummary(session, log, trigger) {
40548
41810
  session.endedAtMs = Date.now();
41811
+ sampleLoopbackDrops(session);
40549
41812
  log.info("export-hap: stream session summary", { meta: {
40550
41813
  ...sessionSummaryMeta(session),
40551
41814
  trigger
@@ -40730,6 +41993,7 @@ async function handleStreamRequest(request, sessions, bctx, advertised) {
40730
41993
  const hadFfmpeg = session.ffmpeg !== null;
40731
41994
  killFfmpeg(session, bctx.ctx, bctx.numericDeviceId);
40732
41995
  stopHeartbeat(session);
41996
+ sampleLoopbackDrops(session);
40733
41997
  if (!hadFfmpeg) logSessionSummary(session, log, "controller-stop-no-ffmpeg");
40734
41998
  await closeIntercomTalkSession(session, bctx).catch(() => void 0);
40735
41999
  closeSocket(session.videoUdp);
@@ -40839,7 +42103,7 @@ async function handleStreamRequest(request, sessions, bctx, advertised) {
40839
42103
  async function startFfmpegForSession(bctx, session, sessionId, video, advertised) {
40840
42104
  const { ctx, proxy, numericDeviceId, options } = bctx;
40841
42105
  const startLog = ctx.logger.withTags({ deviceId: numericDeviceId });
40842
- const entries = await proxy.cameraStreams?.getProfileRtspEntries({}) ?? [];
42106
+ const [entries, brokerStreams] = await Promise.all([(async () => await proxy.cameraStreams?.getProfileRtspEntries({}) ?? [])(), (async () => await proxy.cameraStreams?.getBrokerStreams({}) ?? [])()]);
40843
42107
  if (entries.length === 0) {
40844
42108
  startLog.warn("export-hap: stream start DROPPED — device publishes no profile RTSP entries", { meta: {
40845
42109
  sessionId,
@@ -40848,16 +42112,21 @@ async function startFfmpegForSession(bctx, session, sessionId, video, advertised
40848
42112
  throw new Error(`export-hap: no profile RTSP entries for device ${numericDeviceId}`);
40849
42113
  }
40850
42114
  const pref = options.hapDeviceSettings.streamPreference;
40851
- const brokerStreams = await proxy.cameraStreams?.getBrokerStreams({}) ?? [];
40852
42115
  const bitrates = await probeProfileBitrates({
40853
42116
  bctx,
40854
42117
  slots: brokerStreams,
40855
42118
  log: startLog
40856
42119
  });
42120
+ const connection = classifyConnection({
42121
+ negotiatedWidth: video.width,
42122
+ audioPacketTimeMs: session.negotiated?.audioPacketTimeMs ?? 20,
42123
+ viaHomeHub: false
42124
+ });
40857
42125
  const fit = selectStreamForBudget({
40858
42126
  entries: withSlotCodecs(entries, brokerStreams),
40859
42127
  deviceId: numericDeviceId,
40860
42128
  pref,
42129
+ connection,
40861
42130
  targetResolution: {
40862
42131
  width: video.width,
40863
42132
  height: video.height
@@ -40883,7 +42152,7 @@ async function startFfmpegForSession(bctx, session, sessionId, video, advertised
40883
42152
  const resolvedFps = pickedProfile === null ? void 0 : advertised.fpsByProfile.get(pickedProfile);
40884
42153
  const advertisedFps = resolvedFps?.fps ?? video.fps;
40885
42154
  const advertisedFpsSource = resolvedFps?.source ?? "assumed";
40886
- const deliveredFps = needsTranscode ? deliverableFps(video.fps, resolvedFps?.fps ?? null) : advertisedFps;
42155
+ const deliveredFps = needsTranscode ? video.fps : advertisedFps;
40887
42156
  const slotEvidence = pickedProfile === null ? void 0 : bitrates.get(pickedProfile);
40888
42157
  const fitNotes = formatFitNotes(fit.notes);
40889
42158
  session.selectedSlot = {
@@ -40928,7 +42197,7 @@ async function startFfmpegForSession(bctx, session, sessionId, video, advertised
40928
42197
  const audioLoopPort = session.audioLoopUdp.address().port;
40929
42198
  const videoTarget = `rtp://127.0.0.1:${videoLoopPort}?pkt_size=${video.mtu}`;
40930
42199
  const audioTarget = `rtp://127.0.0.1:${audioLoopPort}?pkt_size=${video.mtu}`;
40931
- const videoArgs = buildVideoEncodeArgs({
42200
+ const videoPlan = buildVideoPlan({
40932
42201
  transcode: needsTranscode,
40933
42202
  width: video.width,
40934
42203
  height: video.height,
@@ -40937,19 +42206,21 @@ async function startFfmpegForSession(bctx, session, sessionId, video, advertised
40937
42206
  });
40938
42207
  const hwDecode = selectHwDecode({
40939
42208
  transcode: needsTranscode,
40940
- reading: needsTranscode ? await probeDecoderHwaccel({
40941
- ctx,
40942
- log: startLog
40943
- }) : null,
40944
- platform: process.platform
42209
+ reading: needsTranscode ? await withDeadline(probeDecoderHwaccel({
42210
+ source: decoderInfoSourceFromContext(ctx),
42211
+ log: startLog,
42212
+ memo: options.decodeMemos.reading
42213
+ }), HWACCEL_PROBE_BUDGET_MS, null, () => startLog.warn("export-hap: hwaccel probe ABANDONED on its budget — decoding in SOFTWARE", { meta: { budgetMs: HWACCEL_PROBE_BUDGET_MS } })) : null,
42214
+ platform: process.platform,
42215
+ recentlyFailedBackend: options.decodeMemos.failedBackend.read() ?? null
40945
42216
  });
40946
42217
  logDecodePath(startLog, sessionId, hwDecode, needsTranscode);
40947
42218
  const videoSsrcSigned = session.videoSsrc | 0;
40948
42219
  const audioSsrcSigned = video.audio_ssrc | 0;
40949
- const buildArgs = (decodeArgs) => buildSessionFfmpegArgs({
40950
- decodeArgs,
42220
+ const buildArgs = (decode) => buildFfmpegArgs(buildSessionInvocation({
42221
+ decode,
40951
42222
  rtspUrl,
40952
- videoArgs,
42223
+ video: videoPlan,
40953
42224
  videoTarget,
40954
42225
  audioTarget,
40955
42226
  videoPayloadType: video.pt,
@@ -40958,13 +42229,13 @@ async function startFfmpegForSession(bctx, session, sessionId, video, advertised
40958
42229
  audioSsrcSigned,
40959
42230
  audioPacketTimeMs: video.packet_time ?? 20,
40960
42231
  audioSampleRateKhz: video.sample_rate ?? 16
40961
- });
42232
+ }));
40962
42233
  const log = ctx.logger.withTags({ deviceId: numericDeviceId });
40963
42234
  let hardwareAlreadyFailed = false;
40964
42235
  const spawnFfmpeg = (decision) => {
40965
42236
  const spawnedAtMs = Date.now();
40966
42237
  const usedHardware = decision.kind === "hardware";
40967
- const proc = spawn("ffmpeg", buildArgs(decision.args), { stdio: [
42238
+ const proc = spawn("ffmpeg", buildArgs(decision), { stdio: [
40968
42239
  "ignore",
40969
42240
  "ignore",
40970
42241
  "pipe"
@@ -40987,6 +42258,7 @@ async function startFfmpegForSession(bctx, session, sessionId, video, advertised
40987
42258
  runtimeMs: Date.now() - spawnedAtMs
40988
42259
  })) {
40989
42260
  hardwareAlreadyFailed = true;
42261
+ if (decision.kind === "hardware") options.decodeMemos.failedBackend.write(decision.backend);
40990
42262
  log.warn("export-hap: hardware decode FAILED at init — respawning ffmpeg in software", { meta: {
40991
42263
  sessionId,
40992
42264
  backend: decision.kind === "hardware" ? decision.backend : null,
@@ -40996,11 +42268,7 @@ async function startFfmpegForSession(bctx, session, sessionId, video, advertised
40996
42268
  runtimeMs: Date.now() - spawnedAtMs
40997
42269
  } });
40998
42270
  if (session.ffmpeg === proc) session.ffmpeg = null;
40999
- spawnFfmpeg({
41000
- kind: "software",
41001
- reason: "hardware-attempt-failed",
41002
- args: []
41003
- });
42271
+ spawnFfmpeg(software("hardware-attempt-failed"));
41004
42272
  return;
41005
42273
  }
41006
42274
  onFfmpegExit(proc, code, signal);
@@ -41045,7 +42313,7 @@ async function startFfmpegForSession(bctx, session, sessionId, video, advertised
41045
42313
  fitReason: fit.reason,
41046
42314
  encodeBudgetKbps: fit.budgetKbps,
41047
42315
  audioCodec: "opus",
41048
- audioBitrateKbps: 24,
42316
+ audioBitrateKbps: OPUS_BITRATE_KBPS,
41049
42317
  videoDecode: hwDecode.kind === "hardware" ? hwDecode.backend : "software"
41050
42318
  } });
41051
42319
  }
@@ -41250,16 +42518,87 @@ function errMsg$8(err) {
41250
42518
  return err instanceof Error ? err.message : String(err);
41251
42519
  }
41252
42520
  //#endregion
42521
+ //#region src/mappers/builders/doorbell-delivery.ts
42522
+ function isRecord(value) {
42523
+ return typeof value === "object" && value !== null;
42524
+ }
42525
+ function numberOrNull(value) {
42526
+ return typeof value === "number" ? value : null;
42527
+ }
42528
+ function isConnectionLike(value) {
42529
+ return isRecord(value) && typeof value["hasEventNotifications"] === "function";
42530
+ }
42531
+ function isIterable(value) {
42532
+ return isRecord(value) && typeof value[Symbol.iterator] === "function";
42533
+ }
42534
+ /** `accessory._server.httpServer.connections`, or null at any missing hop. */
42535
+ function readConnections(accessory) {
42536
+ if (!isRecord(accessory)) return null;
42537
+ const server = accessory["_server"];
42538
+ if (!isRecord(server)) return null;
42539
+ const httpServer = server["httpServer"];
42540
+ if (!isRecord(httpServer)) return null;
42541
+ const connections = httpServer["connections"];
42542
+ return isIterable(connections) ? connections : null;
42543
+ }
42544
+ /**
42545
+ * Probe how far a ring on `characteristic` of `accessory` can travel RIGHT NOW.
42546
+ * Pure with respect to HAP state — it only reads. Never throws.
42547
+ */
42548
+ function describeDoorbellDelivery(accessory, characteristic) {
42549
+ const aid = isRecord(accessory) ? numberOrNull(accessory["aid"]) : null;
42550
+ const iid = isRecord(characteristic) ? numberOrNull(characteristic["iid"]) : null;
42551
+ const serverPublished = isRecord(accessory) && isRecord(accessory["_server"]);
42552
+ const connections = readConnections(accessory);
42553
+ if (connections === null) return {
42554
+ aid,
42555
+ iid,
42556
+ serverPublished,
42557
+ connectionCount: 0,
42558
+ subscriberCount: 0
42559
+ };
42560
+ let connectionCount = 0;
42561
+ let subscriberCount = 0;
42562
+ for (const connection of connections) {
42563
+ connectionCount += 1;
42564
+ if (aid === null || iid === null) continue;
42565
+ if (isConnectionLike(connection) && connection.hasEventNotifications(aid, iid)) subscriberCount += 1;
42566
+ }
42567
+ return {
42568
+ aid,
42569
+ iid,
42570
+ serverPublished,
42571
+ connectionCount,
42572
+ subscriberCount
42573
+ };
42574
+ }
42575
+ /**
42576
+ * True when the ring provably reached nobody: no connection is subscribed to
42577
+ * the characteristic, so hap-nodejs dropped every event frame silently. The
42578
+ * caller must say so out loud — this is a branch that discards work.
42579
+ */
42580
+ function ringReachedNobody(report) {
42581
+ return report.subscriberCount === 0;
42582
+ }
42583
+ //#endregion
41253
42584
  //#region src/mappers/builders/doorbell.ts
41254
42585
  async function buildDoorbell(input) {
41255
42586
  const { bctx, controller } = input;
41256
42587
  const { ctx, numericDeviceId } = bctx;
42588
+ const log = ctx.logger.withTags({ deviceId: numericDeviceId });
42589
+ log.info("export-hap: doorbell forward armed — HomeKit will ring on doorbell.onPressed");
41257
42590
  const unsubscribe = ctx.eventBus.subscribe({ category: EventCategory.DoorbellOnPressed }, (event) => {
41258
- if (event.data.deviceId !== numericDeviceId) return;
42591
+ if (event.data?.deviceId !== numericDeviceId) return;
41259
42592
  try {
42593
+ const delivery = describeDoorbellDelivery(bctx.accessory, bctx.accessory.getService(Service.Doorbell)?.getCharacteristic(Characteristic.ProgrammableSwitchEvent) ?? null);
41260
42594
  controller.ringDoorbell();
42595
+ if (ringReachedNobody(delivery)) {
42596
+ log.warn("export-hap: doorbell rang but NO HomeKit controller is subscribed — the press was dropped before it left the hub (no home hub connected, or the accessory was republished and iOS has not re-subscribed yet)", { meta: { ...delivery } });
42597
+ return;
42598
+ }
42599
+ log.info("export-hap: doorbell SINGLE_PRESS pushed to HomeKit", { meta: { ...delivery } });
41261
42600
  } catch (err) {
41262
- ctx.logger.withTags({ deviceId: numericDeviceId }).warn("export-hap: ringDoorbell() failed", { meta: { error: errMsg$7(err) } });
42601
+ log.warn("export-hap: ringDoorbell() failed", { meta: { error: errMsg$7(err) } });
41263
42602
  }
41264
42603
  });
41265
42604
  return { async dispose() {
@@ -41335,6 +42674,78 @@ function errMsg$6(err) {
41335
42674
  return err instanceof Error ? err.message : String(err);
41336
42675
  }
41337
42676
  //#endregion
42677
+ //#region src/mappers/builders/service-label.ts
42678
+ /**
42679
+ * The ONE place a secondary service on the camera accessory gets its label.
42680
+ *
42681
+ * A "secondary service" here is a Switch or Lightbulb published alongside the
42682
+ * camera on the same accessory — the privacy switch, each accessory child
42683
+ * (siren, floodlight), each PTZ action. iOS Home renders these as their own
42684
+ * controls, and the operator has seen them as "Interruttore 1", "Interruttore
42685
+ * 2" through three separate rounds of fixes.
42686
+ *
42687
+ * ## Why `Name` alone cannot rename anything
42688
+ *
42689
+ * Two facts about hap-nodejs 2.1.7, both measured against the installed copy
42690
+ * rather than reasoned about:
42691
+ *
42692
+ * 1. `accessory.addService(Type, displayName, subtype)` ALREADY writes
42693
+ * `displayName` to `Characteristic.Name` (`Service` constructor). So every
42694
+ * round of this bug — including the one that moved the label onto
42695
+ * `ConfiguredName` — shipped with `Name` correctly set. "iOS had no name
42696
+ * to render" was never true.
42697
+ * 2. The mDNS configuration number (`c#`) is a sha1 over
42698
+ * `internalHAPRepresentation(false)`, which OMITS characteristic VALUES.
42699
+ * Changing the string in `Name` therefore does not bump `c#`, a paired
42700
+ * controller gets no signal to re-read `/accessories`, and the name it
42701
+ * cached at first enumeration stands forever.
42702
+ *
42703
+ * `Name` is also declared `pr` only — paired read, no write, no notify. It is
42704
+ * the seed a controller seeds its database from once; it is not a channel.
42705
+ *
42706
+ * ## Why `ConfiguredName`
42707
+ *
42708
+ * `ConfiguredName` (`000000E3`) is declared `pr | pw | ev` — the only name
42709
+ * characteristic a controller may write and may subscribe to. It is what iOS
42710
+ * 16+ reads for a service the user can rename, and adding it CHANGES the
42711
+ * accessory structure, so `c#` does bump and the controller re-reads.
42712
+ *
42713
+ * It was removed once because hap-nodejs logged
42714
+ *
42715
+ * ```
42716
+ * Characteristic not in required or optional characteristic section for
42717
+ * service Switch. Adding anyway.
42718
+ * ```
42719
+ *
42720
+ * That line is a WARNING, not a rejection: `Service.getCharacteristic` calls
42721
+ * `addCharacteristic` unconditionally and only then emits the warning. The
42722
+ * characteristic was always present and always published. hap-nodejs'
42723
+ * per-service optional lists simply predate `ConfiguredName` being valid on
42724
+ * any service.
42725
+ *
42726
+ * Registering it with {@link Service.addOptionalCharacteristic} first takes
42727
+ * the branch above the warning, so the accessory still builds with ZERO
42728
+ * characteristic warnings — which is what `service-naming.spec.ts` asserts.
42729
+ *
42730
+ * ## Scope
42731
+ *
42732
+ * Switch- and Lightbulb-shaped services only. `Service.MotionSensor` on a
42733
+ * camera accessory is not a separately named tile in iOS Home, so giving it a
42734
+ * writable name would be a guess, and this module does not guess.
42735
+ */
42736
+ /**
42737
+ * Publish `name` as both the immutable `Name` and the controller-visible
42738
+ * `ConfiguredName` of `service`.
42739
+ *
42740
+ * `name` must already be HAP-valid — build it with `service-names.ts`, which
42741
+ * cannot return a string hap-nodejs' `checkName` would warn about.
42742
+ */
42743
+ function applyServiceLabel(service, name) {
42744
+ service.setCharacteristic(Characteristic.Name, name);
42745
+ if (!service.optionalCharacteristics.some((characteristic) => characteristic.UUID === Characteristic.ConfiguredName.UUID)) service.addOptionalCharacteristic(Characteristic.ConfiguredName);
42746
+ service.setCharacteristic(Characteristic.ConfiguredName, name);
42747
+ }
42748
+ //#endregion
41338
42749
  //#region src/mappers/builders/privacy-switch.ts
41339
42750
  /**
41340
42751
  * Privacy-mask switch builder — turns the camstack `privacy-mask` cap's
@@ -41352,12 +42763,12 @@ function errMsg$6(err) {
41352
42763
  * camera-enabled switch — distinct from privacy-mask).
41353
42764
  */
41354
42765
  async function buildPrivacySwitch(bctx) {
41355
- const { ctx, accessory, proxy, numericDeviceId, displayName } = bctx;
42766
+ const { ctx, accessory, proxy, numericDeviceId } = bctx;
41356
42767
  const log = ctx.logger.withTags({ deviceId: numericDeviceId });
41357
42768
  const subtype = "privacy-mask";
41358
- const serviceName = privacyServiceName(displayName);
42769
+ const serviceName = privacyServiceName();
41359
42770
  const service = accessory.addService(Service.Switch, serviceName, subtype);
41360
- service.setCharacteristic(Characteristic.Name, serviceName);
42771
+ applyServiceLabel(service, serviceName);
41361
42772
  try {
41362
42773
  const status = await proxy.privacyMask?.getStatus({});
41363
42774
  if (status && typeof status.enabled === "boolean") service.updateCharacteristic(Characteristic.On, status.enabled);
@@ -41447,23 +42858,17 @@ function ptzPresetLabel(presetName) {
41447
42858
  * `proxy.ptzAutotrack.setEnabled({enabled})`. Initial value is
41448
42859
  * hydrated from `getStatus({})`.
41449
42860
  *
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.
42861
+ * Naming: the bare action — "Preset stanza", "Pan Left", "Autotrack" — built
42862
+ * by `ptzServiceName` and published through `applyServiceLabel`, which writes
42863
+ * it to BOTH `Name` and `ConfiguredName`. The camera name is deliberately not
42864
+ * prefixed these eight services live on that camera's accessory and iOS
42865
+ * shows them there. THREE rounds of this bug have been through this file;
42866
+ * `service-label.ts` records what each got wrong, and why only the writable
42867
+ * characteristic can rename a service after pairing.
41463
42868
  */
41464
42869
  var MOMENTARY_RESET_MS = 1e3;
41465
42870
  async function buildPtz(bctx) {
41466
- const { ctx, accessory, proxy, numericDeviceId, displayName, options } = bctx;
42871
+ const { ctx, accessory, proxy, numericDeviceId, options } = bctx;
41467
42872
  const log = ctx.logger.withTags({ deviceId: numericDeviceId });
41468
42873
  const timers = /* @__PURE__ */ new Set();
41469
42874
  const armReset = (cb, delay) => {
@@ -41475,10 +42880,10 @@ async function buildPtz(bctx) {
41475
42880
  };
41476
42881
  const presets = await readPresets(bctx);
41477
42882
  for (const preset of presets) {
41478
- const label = ptzServiceName(displayName, ptzPresetLabel(preset.name));
42883
+ const label = ptzServiceName(ptzPresetLabel(preset.name));
41479
42884
  const subtype = `ptz-preset-${preset.id}`;
41480
42885
  const service = accessory.addService(Service.Switch, label, subtype);
41481
- service.setCharacteristic(Characteristic.Name, label);
42886
+ applyServiceLabel(service, label);
41482
42887
  service.getCharacteristic(Characteristic.On).onSet(async (value) => {
41483
42888
  if (value !== true) return;
41484
42889
  try {
@@ -41493,9 +42898,9 @@ async function buildPtz(bctx) {
41493
42898
  });
41494
42899
  }
41495
42900
  if (proxy.ptz) for (const dir of PTZ_DIRECTIONS) {
41496
- const label = ptzServiceName(displayName, dir.label);
42901
+ const label = ptzServiceName(dir.label);
41497
42902
  const service = accessory.addService(Service.Switch, label, dir.subtype);
41498
- service.setCharacteristic(Characteristic.Name, label);
42903
+ applyServiceLabel(service, label);
41499
42904
  service.getCharacteristic(Characteristic.On).onSet(async (value) => {
41500
42905
  if (value !== true) return;
41501
42906
  try {
@@ -41539,12 +42944,12 @@ async function readPresets(bctx) {
41539
42944
  }
41540
42945
  }
41541
42946
  async function tryBuildAutotrack(bctx) {
41542
- const { ctx, accessory, proxy, numericDeviceId, displayName } = bctx;
42947
+ const { ctx, accessory, proxy, numericDeviceId } = bctx;
41543
42948
  if (!proxy.ptzAutotrack) return { async dispose() {} };
41544
42949
  const log = ctx.logger.withTags({ deviceId: numericDeviceId });
41545
- const label = ptzServiceName(displayName, PTZ_AUTOTRACK_LABEL);
42950
+ const label = ptzServiceName(PTZ_AUTOTRACK_LABEL);
41546
42951
  const service = accessory.addService(Service.Switch, label, "ptz-autotrack");
41547
- service.setCharacteristic(Characteristic.Name, label);
42952
+ applyServiceLabel(service, label);
41548
42953
  try {
41549
42954
  const status = await proxy.ptzAutotrack.getStatus({});
41550
42955
  if (status && typeof status.enabled === "boolean") service.updateCharacteristic(Characteristic.On, status.enabled);
@@ -41671,7 +43076,7 @@ async function buildChildSwitch(bctx, subtype, deviceType) {
41671
43076
  const isLightingDevice = deviceType === DeviceType.Light || deviceType === DeviceType.Generic;
41672
43077
  const useLightbulb = hasBrightness && isLightingDevice;
41673
43078
  const service = useLightbulb ? accessory.addService(Service.Lightbulb, displayName, subtype) : accessory.addService(Service.Switch, displayName, subtype);
41674
- service.setCharacteristic(Characteristic.Name, displayName);
43079
+ applyServiceLabel(service, displayName);
41675
43080
  try {
41676
43081
  const switchStatus = await proxy.switch?.getStatus({});
41677
43082
  if (switchStatus && typeof switchStatus.on === "boolean") service.updateCharacteristic(Characteristic.On, switchStatus.on);
@@ -41911,6 +43316,118 @@ function pickMapperKind(_capabilities) {
41911
43316
  return "camera";
41912
43317
  }
41913
43318
  //#endregion
43319
+ //#region src/mappers/builders/stream-hwaccel-memo.ts
43320
+ /**
43321
+ * The two bounded memos HomeKit's decode path owns.
43322
+ *
43323
+ * ## Why they exist
43324
+ *
43325
+ * Everything D67 was actually about — one argv builder, one set of constants,
43326
+ * one hwaccel authority — HomeKit already had. What it did NOT have were the
43327
+ * two things the broker gained alongside them:
43328
+ *
43329
+ * 1. **A memo.** `probeDecoderHwaccel` issued a cross-process
43330
+ * `decoder.getInfo` per SESSION. iOS starts sessions in bursts — one on
43331
+ * record was started three times in 16 s — and every one of those paid a
43332
+ * cap call on a hub whose main thread is the scarce resource.
43333
+ * 2. **Failure feedback.** When HomeKit's hardware child died at init and
43334
+ * `shouldRetryInSoftware` saved the session, HomeKit told nobody. The next
43335
+ * session re-picked the same corpse and paid the same two-second death.
43336
+ * `EgressTranscodeManager` fixed exactly this for its own children
43337
+ * (26a522cd5) by reporting the dead backend into the broker's 60 s memo.
43338
+ *
43339
+ * ## Both ride `HwAccelCache`, deliberately
43340
+ *
43341
+ * `createHwAccelCache` from `@camstack/types` is the primitive the broker's own
43342
+ * `egressHwAccelCache` is built from, and the discipline it encodes is the
43343
+ * point: **caller-owned, never a module global** — a module global would
43344
+ * outlive an addon respawn and survive an operator changing the decoder
43345
+ * backend. Same TTL as the broker's, so "has hardware come back yet" cannot
43346
+ * answer differently depending on which consumer asked.
43347
+ *
43348
+ * ## The cross-process gap, stated honestly
43349
+ *
43350
+ * These memos are scoped to the `export-hap` PROCESS. When HomeKit's vaapi
43351
+ * child dies, the broker's next child still pays its own two-second death, and
43352
+ * vice versa — because addons may never import each other and there is no
43353
+ * capability for "this backend is dead on this node right now". Closing that
43354
+ * would need a new cap surface, which Phase 0 explicitly does not take. What is
43355
+ * closed here is HomeKit's own repetition of the cost, across cameras and
43356
+ * across sessions.
43357
+ */
43358
+ /**
43359
+ * The window both memos answer for.
43360
+ *
43361
+ * 60 s, the same as `stream-broker-manager`'s `egressHwAccelCache`. Long enough
43362
+ * that a burst of session restarts pays one read; short enough that an operator
43363
+ * who changes the decoder backend, or a host whose accelerator recovers, is
43364
+ * obeyed on the next session rather than after an addon respawn.
43365
+ */
43366
+ var HAP_DECODE_MEMO_TTL_MS = 6e4;
43367
+ /**
43368
+ * Separator inside the encoded reading. A control character, because a backend
43369
+ * name is `[a-z0-9]+` and the decoder's non-backend choices are `auto` /
43370
+ * `none` / `''`, none of which can contain one — so the split is total.
43371
+ */
43372
+ var READING_SEPARATOR = "";
43373
+ /**
43374
+ * Marks a `null` FIELD, distinct from an EMPTY one.
43375
+ *
43376
+ * `probedBestHwaccel: ''` means the decoder answered and has never probed
43377
+ * (=> `not-probed`); `null` means the field was absent altogether. Encoding
43378
+ * both as `''` would lose a distinction `selectHwDecode` acts on.
43379
+ */
43380
+ var NULL_FIELD = "\0";
43381
+ function encodeField(value) {
43382
+ return value === null ? NULL_FIELD : value;
43383
+ }
43384
+ function decodeField(value) {
43385
+ return value === NULL_FIELD ? null : value;
43386
+ }
43387
+ /**
43388
+ * A reading as ONE `string | null`, which is what {@link HwAccelCache} stores.
43389
+ *
43390
+ * The cache's three states are exactly the three a memoised reading needs:
43391
+ * `undefined` (never asked, or expired), `null` (asked, and the decoder could
43392
+ * not be reached), and a value. Encoding into the one cache rather than
43393
+ * splitting across two is what keeps those three from skewing — two caches
43394
+ * written together can still be READ across an expiry boundary.
43395
+ */
43396
+ function encodeDecoderReading(reading) {
43397
+ if (reading === null) return null;
43398
+ return `${encodeField(reading.hwaccel)}${READING_SEPARATOR}${encodeField(reading.probedBestHwaccel)}`;
43399
+ }
43400
+ function decodeDecoderReading(value) {
43401
+ if (value === null) return null;
43402
+ const [hwaccel = NULL_FIELD, probed = NULL_FIELD] = value.split(READING_SEPARATOR);
43403
+ return {
43404
+ hwaccel: decodeField(hwaccel),
43405
+ probedBestHwaccel: decodeField(probed)
43406
+ };
43407
+ }
43408
+ function createDecoderReadingMemo(options) {
43409
+ const cache = createHwAccelCache(options);
43410
+ return {
43411
+ read() {
43412
+ const cached = cache.read();
43413
+ return cached === void 0 ? void 0 : decodeDecoderReading(cached);
43414
+ },
43415
+ write(reading) {
43416
+ cache.write(encodeDecoderReading(reading));
43417
+ }
43418
+ };
43419
+ }
43420
+ function createHapDecodeMemos(now) {
43421
+ const options = {
43422
+ ttlMs: HAP_DECODE_MEMO_TTL_MS,
43423
+ ...now ? { now } : {}
43424
+ };
43425
+ return {
43426
+ reading: createDecoderReadingMemo(options),
43427
+ failedBackend: createHwAccelCache(options)
43428
+ };
43429
+ }
43430
+ //#endregion
41914
43431
  //#region src/reconcile/sync-state.ts
41915
43432
  function syncStateFromJson(json) {
41916
43433
  const map = /* @__PURE__ */ new Map();
@@ -42053,6 +43570,18 @@ var ExportHapAddon = class extends BaseAddon {
42053
43570
  pincode = "";
42054
43571
  /** Optional mDNS/bind interface (config.interfaceName), or undefined. */
42055
43572
  bind;
43573
+ /**
43574
+ * What this PROCESS remembers about decode hardware, shared by every camera
43575
+ * mapper: the decoder addon's per-node reading (60 s), and the backend that
43576
+ * last died at init (60 s).
43577
+ *
43578
+ * Owned here rather than as a module global for the reason `HwAccelCache`
43579
+ * itself records — a module global outlives an addon respawn and survives an
43580
+ * operator changing the decoder backend. Owned here rather than per mapper
43581
+ * because the whole point is that camera B does not re-pay camera A's failed
43582
+ * hardware init.
43583
+ */
43584
+ decodeMemos = createHapDecodeMemos();
42056
43585
  constructor() {
42057
43586
  super({ ...DEFAULT_CONFIG });
42058
43587
  }
@@ -42194,13 +43723,14 @@ var ExportHapAddon = class extends BaseAddon {
42194
43723
  const mapperKind = pickMapperKind(capabilities);
42195
43724
  if (!mapperKind) throw new Error(`export-hap: no mapper for capabilities ${JSON.stringify(capabilities ?? [])}`);
42196
43725
  const displayName = await this.resolveDisplayName(deviceId);
42197
- const baseEntry = {
43726
+ const previous = this.config.exposed.find((e) => e.deviceId === deviceId);
43727
+ const baseEntry = carryForward({
42198
43728
  deviceId,
42199
43729
  displayName,
42200
43730
  mapperKind,
42201
- addedAt: Date.now(),
43731
+ addedAt: previous?.addedAt ?? Date.now(),
42202
43732
  ...capabilities ? { capabilities: [...capabilities] } : {}
42203
- };
43733
+ }, previous, ["settings", "capabilities"]);
42204
43734
  const attached = await this.attachMapper(baseEntry);
42205
43735
  const finalEntry = {
42206
43736
  ...baseEntry,
@@ -42219,13 +43749,13 @@ var ExportHapAddon = class extends BaseAddon {
42219
43749
  childCount: attached.childAccessoryUuids.length
42220
43750
  } });
42221
43751
  }
42222
- async unexposeDevice(deviceId) {
43752
+ async unexposeDevice(deviceId, options = {}) {
42223
43753
  const numericId = Number.parseInt(deviceId, 10);
42224
43754
  const log = this.ctx.logger.withTags({ deviceId: numericId });
42225
43755
  await this.detachMapper(deviceId);
42226
43756
  const next = this.config.exposed.filter((e) => e.deviceId !== deviceId);
42227
43757
  if (next.length !== this.config.exposed.length) await this.updateGlobalSettings({ exposed: next });
42228
- clearPairingFiles(uuid.generate(`camstack:camera:${numericId}`), this.ctx.logger);
43758
+ if (options.clearPairing !== false) clearPairingFiles(uuid.generate(`camstack:camera:${numericId}`), this.ctx.logger);
42229
43759
  await this.forgetFingerprint(numericId);
42230
43760
  log.info("export-hap: unexposed device");
42231
43761
  }
@@ -42238,6 +43768,7 @@ var ExportHapAddon = class extends BaseAddon {
42238
43768
  displayName: entry.displayName,
42239
43769
  options: {
42240
43770
  ptzPulseMs: this.config.ptzPulseMs,
43771
+ decodeMemos: this.decodeMemos,
42241
43772
  hapDeviceSettings: { streamPreference: entrySettings.streamPreference ?? "auto" }
42242
43773
  }
42243
43774
  });
@@ -42628,9 +44159,8 @@ var ExportHapAddon = class extends BaseAddon {
42628
44159
  to: streamPreference
42629
44160
  } });
42630
44161
  try {
42631
- await this.unexposeDevice(deviceIdStr);
44162
+ await this.unexposeDevice(deviceIdStr, { clearPairing: false });
42632
44163
  await this.exposeDevice(deviceIdStr);
42633
- await this.updateEntrySettings(deviceIdStr, nextSettings);
42634
44164
  } catch (err) {
42635
44165
  log.warn("export-hap: failed to refresh accessory after streamPreference change", { meta: { error: errMsg(err) } });
42636
44166
  }