@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.
- package/dist/export-hap.addon.js +1773 -242
- package/dist/export-hap.addon.mjs +1772 -242
- package/package.json +1 -1
package/dist/export-hap.addon.js
CHANGED
|
@@ -29,12 +29,51 @@ let node_crypto = require("node:crypto");
|
|
|
29
29
|
let node_path = require("node:path");
|
|
30
30
|
__toESM(node_path, 1);
|
|
31
31
|
node_path = __toESM(node_path);
|
|
32
|
+
let node_fs = require("node:fs");
|
|
33
|
+
node_fs = __toESM(node_fs, 1);
|
|
32
34
|
let node_child_process = require("node:child_process");
|
|
33
35
|
let _homebridge_hap_nodejs = require("@homebridge/hap-nodejs");
|
|
34
36
|
let node_fs_promises = require("node:fs/promises");
|
|
35
37
|
node_fs_promises = __toESM(node_fs_promises);
|
|
36
38
|
let node_dgram = require("node:dgram");
|
|
37
39
|
let node_os = require("node:os");
|
|
40
|
+
//#region src/exposed-entry.ts
|
|
41
|
+
/**
|
|
42
|
+
* Carrying an exposed-device entry across a re-expose.
|
|
43
|
+
*
|
|
44
|
+
* `exposeDevice` rebuilds its entry from scratch — display name, mapper kind,
|
|
45
|
+
* timestamp — and then REPLACES the stored one. Anything the rebuilt object
|
|
46
|
+
* does not mention is therefore destroyed, and two things it never mentioned
|
|
47
|
+
* were the per-camera settings and the capability list.
|
|
48
|
+
*
|
|
49
|
+
* The visible cost: the operator's "Source stream (HomeKit)" selector writes
|
|
50
|
+
* `low`, the addon logs `streamPreference changed — refreshing accessory
|
|
51
|
+
* {from=auto to=low}`, and the accessory that comes back derives its
|
|
52
|
+
* advertisement from DEFAULTS — `streamPreference=auto` — because the settings
|
|
53
|
+
* were dropped between the write and the rebuild. The selector only ever took
|
|
54
|
+
* effect after a full addon restart, when the settings were loaded first. A
|
|
55
|
+
* second write after the re-expose hid this: the store ended up correct, so
|
|
56
|
+
* nothing looked wrong except the stream nobody could explain.
|
|
57
|
+
*/
|
|
58
|
+
/**
|
|
59
|
+
* Fill `base` from `existing` for the given keys, letting `base` win wherever
|
|
60
|
+
* it actually says something.
|
|
61
|
+
*
|
|
62
|
+
* That asymmetry is the point: a caller who passes `capabilities` is stating a
|
|
63
|
+
* new truth and must not be overruled by the stored copy, while a caller who
|
|
64
|
+
* says nothing about `settings` is not asking for them to be erased.
|
|
65
|
+
*/
|
|
66
|
+
function carryForward(base, existing, keys) {
|
|
67
|
+
if (existing === void 0) return base;
|
|
68
|
+
const out = { ...base };
|
|
69
|
+
for (const key of keys) {
|
|
70
|
+
if (out[key] !== void 0) continue;
|
|
71
|
+
const carried = existing[key];
|
|
72
|
+
if (carried !== void 0) out[key] = carried;
|
|
73
|
+
}
|
|
74
|
+
return out;
|
|
75
|
+
}
|
|
76
|
+
//#endregion
|
|
38
77
|
//#region ../types/dist/event-category-41fKf-q9.mjs
|
|
39
78
|
var EventCategory = /* @__PURE__ */ function(EventCategory) {
|
|
40
79
|
EventCategory["SystemBoot"] = "system.boot";
|
|
@@ -6546,9 +6585,38 @@ var CAP_NODE_PIN_CONTEXT_KEY = "__camstackNodePin";
|
|
|
6546
6585
|
/**
|
|
6547
6586
|
* Build the tRPC request options that pin a single capability call to `nodeId`.
|
|
6548
6587
|
* Pass as the second argument to `.query(input, …)` / `.mutate(input, …)`.
|
|
6588
|
+
*
|
|
6589
|
+
* ## The id is normalised here, and it has to be
|
|
6590
|
+
*
|
|
6591
|
+
* A forked addon reads its own node from `ctx.kernel.localNodeId`, and inside a
|
|
6592
|
+
* worker that value is a RUNNER id — `hub/export-hap`, not `hub`. Routing
|
|
6593
|
+
* compares a pin against real node ids, so such a pin matches nothing and the
|
|
6594
|
+
* call fails with `no provider registered for cap "…"`. The local-first
|
|
6595
|
+
* resolver already guarded against this (`localNodeId.split('/')[0]`), which
|
|
6596
|
+
* made the hazard invisible: unpinned calls worked, and only an explicit pin —
|
|
6597
|
+
* the thing you reach for when you specifically need THIS node — silently
|
|
6598
|
+
* addressed a node that does not exist.
|
|
6599
|
+
*
|
|
6600
|
+
* Cost of it being missing: `addon-export-hap` pinned `decoder.getInfo` to its
|
|
6601
|
+
* own node to read the host's hardware-decode backend. It never once answered,
|
|
6602
|
+
* so every HomeKit egress transcode decoded in SOFTWARE — including 4K H.265 —
|
|
6603
|
+
* while D67's whole premise was that the decoder addon is the authority on
|
|
6604
|
+
* hardware. The warn said `decoding in SOFTWARE` and read as "this node has no
|
|
6605
|
+
* hardware", which was false.
|
|
6606
|
+
*
|
|
6607
|
+
* Normalising in the ONE constructor fixes every caller at once, which is why
|
|
6608
|
+
* it is here and not at the call sites.
|
|
6549
6609
|
*/
|
|
6550
6610
|
function nodePin(nodeId) {
|
|
6551
|
-
return { context: { [CAP_NODE_PIN_CONTEXT_KEY]: nodeId } };
|
|
6611
|
+
return { context: { [CAP_NODE_PIN_CONTEXT_KEY]: toNodeId(nodeId) } };
|
|
6612
|
+
}
|
|
6613
|
+
/**
|
|
6614
|
+
* A runner id is `<nodeId>/<addonId>`; a node id has no slash. Taking the head
|
|
6615
|
+
* is idempotent, so passing an already-clean id costs nothing.
|
|
6616
|
+
*/
|
|
6617
|
+
function toNodeId(idOrRunnerId) {
|
|
6618
|
+
const head = idOrRunnerId.split("/")[0];
|
|
6619
|
+
return head === void 0 || head.length === 0 ? idOrRunnerId : head;
|
|
6552
6620
|
}
|
|
6553
6621
|
/**
|
|
6554
6622
|
* Output schema shared by the contribution + live methods.
|
|
@@ -7097,6 +7165,270 @@ var EncodeProfileSchema = object({
|
|
|
7097
7165
|
*/
|
|
7098
7166
|
outputArgs: array(string()).optional()
|
|
7099
7167
|
});
|
|
7168
|
+
var AUDIO_ENCODER_BY_CODEC = {
|
|
7169
|
+
opus: "libopus",
|
|
7170
|
+
aac: "aac",
|
|
7171
|
+
pcmu: "pcm_mulaw",
|
|
7172
|
+
pcma: "pcm_alaw"
|
|
7173
|
+
};
|
|
7174
|
+
/**
|
|
7175
|
+
* Camera-microphone audio, per codec. Lives HERE rather than in
|
|
7176
|
+
* `encode-defaults.ts` only to avoid an import cycle (`encode-defaults` depends
|
|
7177
|
+
* on these types); it is re-exported from there, which is where to read it.
|
|
7178
|
+
*
|
|
7179
|
+
* Every source in this repo is a mono camera mic. The former broker preset
|
|
7180
|
+
* encoded Opus at `channels: 2`, spending bitrate duplicating one channel —
|
|
7181
|
+
* that is the value this consolidation changed.
|
|
7182
|
+
*/
|
|
7183
|
+
var AUDIO_PRESETS = {
|
|
7184
|
+
aac: {
|
|
7185
|
+
kind: "encode",
|
|
7186
|
+
codec: "aac",
|
|
7187
|
+
bitrateKbps: 128,
|
|
7188
|
+
sampleRateHz: 48e3,
|
|
7189
|
+
channels: 1
|
|
7190
|
+
},
|
|
7191
|
+
opus: {
|
|
7192
|
+
kind: "encode",
|
|
7193
|
+
codec: "opus",
|
|
7194
|
+
bitrateKbps: 64,
|
|
7195
|
+
sampleRateHz: 48e3,
|
|
7196
|
+
channels: 1
|
|
7197
|
+
},
|
|
7198
|
+
pcmu: {
|
|
7199
|
+
kind: "encode",
|
|
7200
|
+
codec: "pcmu",
|
|
7201
|
+
sampleRateHz: 8e3,
|
|
7202
|
+
channels: 1
|
|
7203
|
+
}
|
|
7204
|
+
};
|
|
7205
|
+
/** `-hide_banner -loglevel <level>` — every ffmpeg site opens with this. */
|
|
7206
|
+
function logBannerArgs(level) {
|
|
7207
|
+
return [
|
|
7208
|
+
"-hide_banner",
|
|
7209
|
+
"-loglevel",
|
|
7210
|
+
level
|
|
7211
|
+
];
|
|
7212
|
+
}
|
|
7213
|
+
/** `true` when the resolved value means "decode in software" (⇒ no `-hwaccel`). */
|
|
7214
|
+
function isSoftwareDecode(decodeHwAccel) {
|
|
7215
|
+
return !decodeHwAccel || decodeHwAccel === "none" || decodeHwAccel === "copy";
|
|
7216
|
+
}
|
|
7217
|
+
/**
|
|
7218
|
+
* Every INPUT option, in order, terminated by `-i <url>`. Nothing may be
|
|
7219
|
+
* appended to this list by a caller — that is the whole point of the function.
|
|
7220
|
+
*/
|
|
7221
|
+
function buildInputArgs(input, decodeHwAccel) {
|
|
7222
|
+
const args = [];
|
|
7223
|
+
if (!isSoftwareDecode(decodeHwAccel)) args.push("-hwaccel", String(decodeHwAccel));
|
|
7224
|
+
if (input.extraArgs?.length) args.push(...input.extraArgs);
|
|
7225
|
+
if (input.analyzeDurationUs !== void 0) args.push("-analyzeduration", String(input.analyzeDurationUs));
|
|
7226
|
+
if (input.probeSizeBytes !== void 0) args.push("-probesize", String(input.probeSizeBytes));
|
|
7227
|
+
if (input.fflags?.length) for (const flag of input.fflags) args.push("-fflags", flag);
|
|
7228
|
+
if (input.rtspTransport) args.push("-rtsp_transport", input.rtspTransport);
|
|
7229
|
+
args.push("-i", input.url);
|
|
7230
|
+
return args;
|
|
7231
|
+
}
|
|
7232
|
+
/** The `-vf` filter args, or `[]` when a consumer `-vf` already claims the slot. */
|
|
7233
|
+
function buildVideoFilterArgs(scale, outputArgs) {
|
|
7234
|
+
if (!scale) return [];
|
|
7235
|
+
if (outputArgs.some((a) => a === "-vf")) return [];
|
|
7236
|
+
if (scale.mode === "exact") return ["-vf", `scale=${scale.width}:${scale.height}`];
|
|
7237
|
+
return ["-vf", `scale='min(${scale.width},iw)':'min(${scale.height},ih)':force_original_aspect_ratio=decrease:force_divisible_by=2`];
|
|
7238
|
+
}
|
|
7239
|
+
/** Rate-control args for an encode plan. */
|
|
7240
|
+
function buildRateControlArgs(video) {
|
|
7241
|
+
const kbps = video.bitrateKbps;
|
|
7242
|
+
if (kbps === void 0) return [];
|
|
7243
|
+
const rc = video.rateControl ?? {
|
|
7244
|
+
kind: "cap",
|
|
7245
|
+
vbvSeconds: 2
|
|
7246
|
+
};
|
|
7247
|
+
const bufsize = Math.max(1, Math.round(kbps * rc.vbvSeconds));
|
|
7248
|
+
return [
|
|
7249
|
+
...rc.kind === "cbr" ? ["-b:v", `${kbps}k`] : [],
|
|
7250
|
+
"-maxrate",
|
|
7251
|
+
`${kbps}k`,
|
|
7252
|
+
"-bufsize",
|
|
7253
|
+
`${bufsize}k`
|
|
7254
|
+
];
|
|
7255
|
+
}
|
|
7256
|
+
/** The whole video block (`-vf` … `-c:v` … knobs), after `-i`. */
|
|
7257
|
+
function buildVideoArgs(video, outputArgs) {
|
|
7258
|
+
if (video.kind === "copy") return [
|
|
7259
|
+
"-c:v",
|
|
7260
|
+
"copy",
|
|
7261
|
+
...video.bitstreamFilter ? ["-bsf:v", video.bitstreamFilter] : []
|
|
7262
|
+
];
|
|
7263
|
+
const args = [
|
|
7264
|
+
...buildVideoFilterArgs(video.scale, outputArgs),
|
|
7265
|
+
"-c:v",
|
|
7266
|
+
video.encoder
|
|
7267
|
+
];
|
|
7268
|
+
if (video.preset !== void 0) args.push("-preset", video.preset);
|
|
7269
|
+
if (video.tune !== void 0) args.push("-tune", video.tune);
|
|
7270
|
+
if (video.profile !== void 0) args.push("-profile:v", video.profile);
|
|
7271
|
+
if (video.level !== void 0) args.push("-level", video.level);
|
|
7272
|
+
if (video.pixelFormat !== void 0) args.push("-pix_fmt", video.pixelFormat);
|
|
7273
|
+
if (video.fps !== void 0) args.push("-r", String(video.fps));
|
|
7274
|
+
if (video.gopFrames !== void 0) args.push("-g", String(video.gopFrames));
|
|
7275
|
+
if (video.forceKeyFramesSeconds !== void 0) args.push("-force_key_frames", `expr:gte(t,n_forced*${video.forceKeyFramesSeconds})`);
|
|
7276
|
+
if (video.bf !== void 0) args.push("-bf", String(video.bf));
|
|
7277
|
+
args.push(...buildRateControlArgs(video));
|
|
7278
|
+
if (video.bitstreamFilter !== void 0) args.push("-bsf:v", video.bitstreamFilter);
|
|
7279
|
+
return args;
|
|
7280
|
+
}
|
|
7281
|
+
/** The whole audio block, after `-i`. */
|
|
7282
|
+
function buildAudioArgs(audio) {
|
|
7283
|
+
if (audio.kind === "none") return ["-an"];
|
|
7284
|
+
if (audio.kind === "copy") return ["-c:a", "copy"];
|
|
7285
|
+
const args = [];
|
|
7286
|
+
if (audio.filter !== void 0) args.push("-af", audio.filter);
|
|
7287
|
+
args.push("-c:a", AUDIO_ENCODER_BY_CODEC[audio.codec]);
|
|
7288
|
+
if (audio.application !== void 0) args.push("-application", audio.application);
|
|
7289
|
+
if (audio.frameDurationMs !== void 0) args.push("-frame_duration", String(audio.frameDurationMs));
|
|
7290
|
+
if (audio.globalHeader === true) args.push("-flags", "+global_header");
|
|
7291
|
+
if (audio.sampleRateHz !== void 0) args.push("-ar", String(audio.sampleRateHz));
|
|
7292
|
+
if (audio.bitrateKbps !== void 0) args.push("-b:a", `${audio.bitrateKbps}k`);
|
|
7293
|
+
if (audio.vbvBufferKbits !== void 0) args.push("-bufsize", `${audio.vbvBufferKbits}k`);
|
|
7294
|
+
if (audio.channels !== void 0) args.push("-ac", String(audio.channels));
|
|
7295
|
+
return args;
|
|
7296
|
+
}
|
|
7297
|
+
/** RTP output-leg args (`-payload_type`, `-ssrc`, `-sdp_file`, `-f rtp <url>`). */
|
|
7298
|
+
function buildRtpOutputArgs(out) {
|
|
7299
|
+
const args = [];
|
|
7300
|
+
if (out.payloadType !== void 0) args.push("-payload_type", String(out.payloadType));
|
|
7301
|
+
if (out.ssrc !== void 0) args.push("-ssrc", String(out.ssrc));
|
|
7302
|
+
if (out.sdpFile !== void 0) args.push("-sdp_file", out.sdpFile);
|
|
7303
|
+
args.push("-f", "rtp", out.url);
|
|
7304
|
+
return args;
|
|
7305
|
+
}
|
|
7306
|
+
/** `true` when the sink is a raw elementary bytestream that cannot mux audio. */
|
|
7307
|
+
function isElementaryVideoSink(sink) {
|
|
7308
|
+
return sink.kind === "stdout" && (sink.container === "h264" || sink.container === "hevc");
|
|
7309
|
+
}
|
|
7310
|
+
/**
|
|
7311
|
+
* The fragmented-MP4 muxer flags, in the order the recorder has proven them
|
|
7312
|
+
* (`recorder/addon/ffmpeg-args.ts` passes the same `movflags` string through
|
|
7313
|
+
* `-segment_format_options`, across every vendor in the fleet):
|
|
7314
|
+
*
|
|
7315
|
+
* - `frag_keyframe` — cut a fragment at each key frame, so every fragment
|
|
7316
|
+
* opens on a sync sample. HKSV's whole requirement.
|
|
7317
|
+
* - `empty_moov` — write `ftyp`+`moov` up front with no samples in it, which
|
|
7318
|
+
* is what makes the head a standalone INITIALISATION segment.
|
|
7319
|
+
* - `default_base_moof` — fragment offsets are self-relative, so a fragment is
|
|
7320
|
+
* demuxable without the bytes that preceded it. D31's byte-range read path
|
|
7321
|
+
* depends on exactly this property of the recorder's segments.
|
|
7322
|
+
*/
|
|
7323
|
+
var FMP4_MOVFLAGS = "+frag_keyframe+empty_moov+default_base_moof";
|
|
7324
|
+
/**
|
|
7325
|
+
* The terminal sink args for every non-`rtp-outputs` sink. Exhaustive over the
|
|
7326
|
+
* union so a new member cannot fall through to `['-f', container, 'pipe:1']`,
|
|
7327
|
+
* which is what a plain `container` read would have done for `mp4` — a valid
|
|
7328
|
+
* argv that writes a NON-fragmented, unseekable-to-a-pipe MP4 and produces one
|
|
7329
|
+
* unusable byte stream.
|
|
7330
|
+
*/
|
|
7331
|
+
function buildStdoutOrRtspSinkArgs(sink) {
|
|
7332
|
+
if (sink.kind === "rtsp-listen") return [
|
|
7333
|
+
"-f",
|
|
7334
|
+
"rtsp",
|
|
7335
|
+
"-rtsp_transport",
|
|
7336
|
+
"tcp",
|
|
7337
|
+
"-rtsp_flags",
|
|
7338
|
+
"listen",
|
|
7339
|
+
sink.url
|
|
7340
|
+
];
|
|
7341
|
+
if (sink.kind === "rtp-outputs") return [];
|
|
7342
|
+
return sink.container === "mp4" ? buildFmp4SinkArgs(sink) : [
|
|
7343
|
+
"-f",
|
|
7344
|
+
sink.container,
|
|
7345
|
+
"pipe:1"
|
|
7346
|
+
];
|
|
7347
|
+
}
|
|
7348
|
+
/** `-movflags … -min_frag_duration <us> -f mp4 pipe:1`. */
|
|
7349
|
+
function buildFmp4SinkArgs(sink) {
|
|
7350
|
+
return [
|
|
7351
|
+
"-movflags",
|
|
7352
|
+
FMP4_MOVFLAGS,
|
|
7353
|
+
"-min_frag_duration",
|
|
7354
|
+
String(Math.max(0, Math.round(sink.fragmentMs * 1e3))),
|
|
7355
|
+
"-f",
|
|
7356
|
+
"mp4",
|
|
7357
|
+
"pipe:1"
|
|
7358
|
+
];
|
|
7359
|
+
}
|
|
7360
|
+
/**
|
|
7361
|
+
* A second output mapping source audio to RTP-over-UDP. `0:a:0?` makes the
|
|
7362
|
+
* audio optional so a source with no audio skips it instead of failing the
|
|
7363
|
+
* whole invocation.
|
|
7364
|
+
*/
|
|
7365
|
+
function buildAudioSidecarArgs(sidecar) {
|
|
7366
|
+
return [
|
|
7367
|
+
"-map",
|
|
7368
|
+
"0:a:0?",
|
|
7369
|
+
...buildAudioArgs(sidecar.codec === "pcma" ? {
|
|
7370
|
+
kind: "encode",
|
|
7371
|
+
codec: "pcma",
|
|
7372
|
+
sampleRateHz: 8e3,
|
|
7373
|
+
channels: 1
|
|
7374
|
+
} : AUDIO_PRESETS[sidecar.codec]),
|
|
7375
|
+
...buildRtpOutputArgs({
|
|
7376
|
+
url: sidecar.rtpUrl,
|
|
7377
|
+
sdpFile: sidecar.sdpFile
|
|
7378
|
+
})
|
|
7379
|
+
];
|
|
7380
|
+
}
|
|
7381
|
+
/**
|
|
7382
|
+
* Assemble the full ffmpeg argument list. Layout:
|
|
7383
|
+
*
|
|
7384
|
+
* -hide_banner -loglevel <level>
|
|
7385
|
+
* [-hwaccel <backend|auto>] ─┐ INPUT options — strictly before -i.
|
|
7386
|
+
* [<input.extraArgs>] │
|
|
7387
|
+
* [-fflags <flag>…] │
|
|
7388
|
+
* [-rtsp_transport tcp] │
|
|
7389
|
+
* -i <url> ─┘
|
|
7390
|
+
* <video block> <threads> <audio block> ─┐ OUTPUT options.
|
|
7391
|
+
* <consumer outputArgs verbatim> │
|
|
7392
|
+
* <sink> ─┘ terminal
|
|
7393
|
+
*/
|
|
7394
|
+
function buildFfmpegArgs(inv) {
|
|
7395
|
+
const head = [...logBannerArgs(inv.logLevel), ...buildInputArgs(inv.input, inv.decodeHwAccel)];
|
|
7396
|
+
const threadArgs = inv.threadCount > 0 ? ["-threads", String(inv.threadCount)] : [];
|
|
7397
|
+
if (inv.sink.kind === "rtp-outputs") {
|
|
7398
|
+
const videoLeg = inv.sink.video ? [
|
|
7399
|
+
"-an",
|
|
7400
|
+
"-map",
|
|
7401
|
+
"0:v:0",
|
|
7402
|
+
...buildVideoArgs(inv.video, inv.outputArgs),
|
|
7403
|
+
...threadArgs,
|
|
7404
|
+
...inv.outputArgs,
|
|
7405
|
+
...buildRtpOutputArgs(inv.sink.video)
|
|
7406
|
+
] : [];
|
|
7407
|
+
const audioLeg = inv.sink.audio ? [
|
|
7408
|
+
"-vn",
|
|
7409
|
+
"-map",
|
|
7410
|
+
"0:a:0?",
|
|
7411
|
+
...buildAudioArgs(inv.audio),
|
|
7412
|
+
...buildRtpOutputArgs(inv.sink.audio)
|
|
7413
|
+
] : [];
|
|
7414
|
+
return [
|
|
7415
|
+
...head,
|
|
7416
|
+
...videoLeg,
|
|
7417
|
+
...audioLeg
|
|
7418
|
+
];
|
|
7419
|
+
}
|
|
7420
|
+
const audioArgs = isElementaryVideoSink(inv.sink) ? ["-an"] : buildAudioArgs(inv.audio);
|
|
7421
|
+
const sinkArgs = buildStdoutOrRtspSinkArgs(inv.sink);
|
|
7422
|
+
return [
|
|
7423
|
+
...head,
|
|
7424
|
+
...buildVideoArgs(inv.video, inv.outputArgs),
|
|
7425
|
+
...threadArgs,
|
|
7426
|
+
...audioArgs,
|
|
7427
|
+
...inv.outputArgs,
|
|
7428
|
+
...sinkArgs,
|
|
7429
|
+
...inv.audioSidecar ? buildAudioSidecarArgs(inv.audioSidecar) : []
|
|
7430
|
+
];
|
|
7431
|
+
}
|
|
7100
7432
|
/**
|
|
7101
7433
|
* The shape every live egress starts from: H.264 Baseline 3.1 at 720p25.
|
|
7102
7434
|
* Baseline because it is the one profile every consumer in this repo decodes
|
|
@@ -7120,6 +7452,34 @@ var BASE_LIVE_EGRESS_PROFILE = {
|
|
|
7120
7452
|
};
|
|
7121
7453
|
({ ...BASE_LIVE_EGRESS_PROFILE }), { ...BASE_LIVE_EGRESS_PROFILE.video };
|
|
7122
7454
|
({ ...BASE_LIVE_EGRESS_PROFILE });
|
|
7455
|
+
/** VBV window for a consumer whose budget is enforced per second (HomeKit). */
|
|
7456
|
+
var RATE_CONTROL_TIGHT = {
|
|
7457
|
+
kind: "cbr",
|
|
7458
|
+
vbvSeconds: 1
|
|
7459
|
+
};
|
|
7460
|
+
var HAP_AUDIO_BASE = {
|
|
7461
|
+
kind: "encode",
|
|
7462
|
+
codec: "opus",
|
|
7463
|
+
bitrateKbps: 24,
|
|
7464
|
+
channels: 1,
|
|
7465
|
+
application: "lowdelay",
|
|
7466
|
+
globalHeader: true,
|
|
7467
|
+
filter: "aresample=async=1000:first_pts=0"
|
|
7468
|
+
};
|
|
7469
|
+
function createHwAccelCache(options) {
|
|
7470
|
+
const now = options.now ?? (() => Date.now());
|
|
7471
|
+
let value = null;
|
|
7472
|
+
let writtenAt = Number.NEGATIVE_INFINITY;
|
|
7473
|
+
return {
|
|
7474
|
+
read() {
|
|
7475
|
+
return now() - writtenAt < options.ttlMs ? value : void 0;
|
|
7476
|
+
},
|
|
7477
|
+
write(next) {
|
|
7478
|
+
value = next;
|
|
7479
|
+
writtenAt = now();
|
|
7480
|
+
}
|
|
7481
|
+
};
|
|
7482
|
+
}
|
|
7123
7483
|
/**
|
|
7124
7484
|
* Deep wiring healthcheck — snapshot of active reachability probes across
|
|
7125
7485
|
* every declared capability + widget of every installed plugin, on every
|
|
@@ -7176,7 +7536,7 @@ object({
|
|
|
7176
7536
|
* ## This file adds no state
|
|
7177
7537
|
*
|
|
7178
7538
|
* Every switch here is a VIEW onto an authority that already existed
|
|
7179
|
-
* ([
|
|
7539
|
+
* ([D62](../../../../docs/decisions/adr-0062.md)). The whole point of the
|
|
7180
7540
|
* group is that there is exactly one place each function is turned off, and
|
|
7181
7541
|
* the group routes to it:
|
|
7182
7542
|
*
|
|
@@ -7187,6 +7547,40 @@ object({
|
|
|
7187
7547
|
* | `audio-analysis` | `deviceManager.setWrapperActive('audio-analysis')` | `AudioSubscriptionController.subscribeAudioStream` returns `null` before opening the stream |
|
|
7188
7548
|
* | `recording` | `recording.setDeviceConfig` → `RecordingConfig.enabled` | `band-decision.shouldRecord` returns false; the controller detaches the device |
|
|
7189
7549
|
* | `notifications` | `notificationRules.setDeviceMuted` | `NotificationCenter.evaluateAndEnqueue` returns before any rule is evaluated |
|
|
7550
|
+
* | `privacy-mask` | `privacyMask.setMask({ enabled })` → the CAMERA | the camera blanks the masked regions itself; every stream and recording carries the black boxes |
|
|
7551
|
+
* | `device-audio` | `privacyMask.setAudioEnabled` → the CAMERA | the camera stops encoding an audio track at all; every consumer sees silent video |
|
|
7552
|
+
*
|
|
7553
|
+
* ## The two switches whose authority is not on this server
|
|
7554
|
+
*
|
|
7555
|
+
* `privacy-mask` and `device-audio` write the CAMERA. That is not a loophole
|
|
7556
|
+
* in "the group stores nothing" — it is the purest form of it: the camera
|
|
7557
|
+
* holds the fact, every read is a read-through, and there is no server-side
|
|
7558
|
+
* copy that could drift. Their availability therefore cannot come from
|
|
7559
|
+
* `listBindableCapsForDeviceType` (a device-NATIVE cap carries no wrappers and
|
|
7560
|
+
* is filtered out there); it comes from the cap's own camera-probed
|
|
7561
|
+
* `privacyMask.getOptions()`, which is strictly more honest — it answers for
|
|
7562
|
+
* THIS camera rather than for the device type
|
|
7563
|
+
* ([D74](../../../../docs/decisions/adr-0074.md)).
|
|
7564
|
+
*
|
|
7565
|
+
* ## `privacy-mask` is the one row whose ON is not "the function is working"
|
|
7566
|
+
*
|
|
7567
|
+
* Every other switch means *this camera's function is doing its job*, so
|
|
7568
|
+
* `enabled: false` is a thing an operator took away. `privacy-mask` means **the
|
|
7569
|
+
* MASK is active** — `enabled: true` is video deliberately obscured. The
|
|
7570
|
+
* polarity is not a choice made here: `addon-export-hap`'s privacy `Switch`
|
|
7571
|
+
* (`builders/privacy-switch.ts`) already mirrors `patch.enabled` verbatim, and
|
|
7572
|
+
* a HomeKit toggle that disagreed with the app's toggle for the same camera is
|
|
7573
|
+
* worse than either surface not having one.
|
|
7574
|
+
*
|
|
7575
|
+
* Two consequences follow and both are load-bearing:
|
|
7576
|
+
*
|
|
7577
|
+
* - **It never counts as `switchedOff`.** `countsAsSwitchedOff` is `false` for
|
|
7578
|
+
* exactly this row. With the polarity above, every camera that has NOT drawn
|
|
7579
|
+
* a privacy mask would otherwise report `switchedOff: ['privacy-mask']` — the
|
|
7580
|
+
* normal, healthy state of most cameras rendered as an operator disablement.
|
|
7581
|
+
* - **Its cost line names BOTH directions.** `costWhenOff` is rendered
|
|
7582
|
+
* unconditionally by both clients, so for this row it has to read correctly
|
|
7583
|
+
* whichever way the switch is sitting.
|
|
7190
7584
|
*
|
|
7191
7585
|
* The wrapper-binding pair is not a new idea: `legacy-migrations.ts` already
|
|
7192
7586
|
* migrated the legacy `audioEnabled` / `pipelineEnabled` /
|
|
@@ -7205,14 +7599,17 @@ object({
|
|
|
7205
7599
|
* `CameraStatus.switchedOff`.
|
|
7206
7600
|
*/
|
|
7207
7601
|
/**
|
|
7208
|
-
* The
|
|
7209
|
-
*
|
|
7602
|
+
* The functions the operator named — five on 2026-08-05, plus the camera's own
|
|
7603
|
+
* microphone on 2026-08-07. Deliberately NOT one id per pipeline step: face
|
|
7604
|
+
* recognition and plate/LPR are per-step toggles on
|
|
7210
7605
|
* `pipelineOrchestrator.setCameraStepToggle` and belong in the pipeline
|
|
7211
|
-
* editor, not in a
|
|
7606
|
+
* editor, not in a safety group.
|
|
7212
7607
|
*/
|
|
7213
7608
|
var CameraSwitchIdSchema = _enum([
|
|
7214
7609
|
"stream-broker",
|
|
7215
7610
|
"object-detection",
|
|
7611
|
+
"privacy-mask",
|
|
7612
|
+
"device-audio",
|
|
7216
7613
|
"audio-analysis",
|
|
7217
7614
|
"recording",
|
|
7218
7615
|
"notifications"
|
|
@@ -7230,14 +7627,26 @@ var CameraSwitchAuthoritySchema = discriminatedUnion("kind", [
|
|
|
7230
7627
|
capName: string()
|
|
7231
7628
|
}),
|
|
7232
7629
|
object({ kind: literal("recording-config") }),
|
|
7233
|
-
object({ kind: literal("notification-mute") })
|
|
7630
|
+
object({ kind: literal("notification-mute") }),
|
|
7631
|
+
object({
|
|
7632
|
+
kind: literal("camera-audio"),
|
|
7633
|
+
capName: string()
|
|
7634
|
+
}),
|
|
7635
|
+
object({
|
|
7636
|
+
kind: literal("camera-mask"),
|
|
7637
|
+
capName: string()
|
|
7638
|
+
})
|
|
7234
7639
|
]);
|
|
7235
7640
|
/**
|
|
7236
7641
|
* Why a switch is not offered for this camera. Rendered instead of the
|
|
7237
7642
|
* control, never as a dead control — an absent function and a broken one must
|
|
7238
7643
|
* not look the same.
|
|
7239
7644
|
*/
|
|
7240
|
-
var CameraSwitchUnavailableReasonSchema = _enum([
|
|
7645
|
+
var CameraSwitchUnavailableReasonSchema = _enum([
|
|
7646
|
+
"no-provider",
|
|
7647
|
+
"source-unreachable",
|
|
7648
|
+
"not-configured"
|
|
7649
|
+
]);
|
|
7241
7650
|
/**
|
|
7242
7651
|
* One switch, resolved for one camera.
|
|
7243
7652
|
*
|
|
@@ -9259,6 +9668,26 @@ var EgressTranscodeRequestSchema = object({
|
|
|
9259
9668
|
"h264_mp4toannexb",
|
|
9260
9669
|
"hevc_mp4toannexb"
|
|
9261
9670
|
]).optional(),
|
|
9671
|
+
/**
|
|
9672
|
+
* Publish the transcode as a LOCAL push cam stream, instead of leaving the
|
|
9673
|
+
* consumer to dial the returned url. The broker picks the id and returns it
|
|
9674
|
+
* as `camStreamId` — a caller-supplied one would be circular, since the
|
|
9675
|
+
* sharing key is computed FROM this request.
|
|
9676
|
+
*
|
|
9677
|
+
* The url is still returned and still the contract for a transcode pinned to
|
|
9678
|
+
* another node. But dialling it locally costs an RTSP round trip that changes
|
|
9679
|
+
* the transport underneath the consumer: a dialled stream is an RTP source,
|
|
9680
|
+
* so `isRtpSource()` is true and the session takes the RTP-passthrough +
|
|
9681
|
+
* repacketizer branch. The push branch — the one the derived mechanism has
|
|
9682
|
+
* live hours on — is never reached. Measured on Alexa: broker registered, RTP
|
|
9683
|
+
* arriving, key frame arriving, black screen, on a chain healthy at every
|
|
9684
|
+
* other point.
|
|
9685
|
+
*
|
|
9686
|
+
* Same idea the transport already applies to CALLS, where `classifyCapRoute`
|
|
9687
|
+
* gives priority to `hub-in-process` so a local call never leaves the node.
|
|
9688
|
+
* This is that rule for media.
|
|
9689
|
+
*/
|
|
9690
|
+
publishLocally: boolean().optional(),
|
|
9262
9691
|
pixelFormat: _enum(["yuv420p", "nv12"]).optional(),
|
|
9263
9692
|
/**
|
|
9264
9693
|
* Operator/consumer override for decode hardware. ABSENT is the normal case
|
|
@@ -9303,7 +9732,13 @@ var EgressTranscodeSchema = object({
|
|
|
9303
9732
|
* Returned rather than assumed: a consumer that asked for hardware and got
|
|
9304
9733
|
* software needs to be able to see that without reading the broker's logs.
|
|
9305
9734
|
*/
|
|
9306
|
-
decodeHwAccel: string().nullable()
|
|
9735
|
+
decodeHwAccel: string().nullable(),
|
|
9736
|
+
/**
|
|
9737
|
+
* Set when `publishLocally` was honoured: attach to THIS instead of dialling
|
|
9738
|
+
* `url`, and the session takes the push/deframe transport rather than the
|
|
9739
|
+
* RTP-passthrough one. `null` means the consumer must dial.
|
|
9740
|
+
*/
|
|
9741
|
+
camStreamId: string().nullable()
|
|
9307
9742
|
});
|
|
9308
9743
|
method(object({
|
|
9309
9744
|
deviceId: number().int().nonnegative(),
|
|
@@ -17145,9 +17580,15 @@ DeviceType.Camera, method(object({
|
|
|
17145
17580
|
* Bypass the cache freshness check and fetch directly from the
|
|
17146
17581
|
* native (or stream-broker fallback). Triggered by the UI's
|
|
17147
17582
|
* "refresh" button so an operator can force a fresh frame
|
|
17148
|
-
* even when the cache is well within
|
|
17149
|
-
*
|
|
17150
|
-
*
|
|
17583
|
+
* even when the cache is well within the device's
|
|
17584
|
+
* `snapshotMaxAgeS` window.
|
|
17585
|
+
*
|
|
17586
|
+
* **`force` is an OPERATOR signal, not a freshness preference.** On a
|
|
17587
|
+
* battery camera it is the one thing that walks past the wrapper's
|
|
17588
|
+
* sleep gate and wakes the camera, so a background caller — a poller,
|
|
17589
|
+
* an event handler, a thumbnail — must NEVER set it. Every such caller
|
|
17590
|
+
* gets the cached frame, which on a sleeping battery camera is the
|
|
17591
|
+
* correct answer: stale but honest beats woken.
|
|
17151
17592
|
*/
|
|
17152
17593
|
force: boolean().optional()
|
|
17153
17594
|
}), SnapshotImageSchema.nullable()), method(object({ deviceId: number() }), _void(), {
|
|
@@ -21429,12 +21870,30 @@ object({
|
|
|
21429
21870
|
});
|
|
21430
21871
|
DeviceType.Sensor;
|
|
21431
21872
|
/**
|
|
21432
|
-
*
|
|
21433
|
-
*
|
|
21434
|
-
*
|
|
21435
|
-
*
|
|
21436
|
-
*
|
|
21437
|
-
*
|
|
21873
|
+
* PRIVACY — what the camera deliberately does not capture. Two planes:
|
|
21874
|
+
*
|
|
21875
|
+
* - **video**: up to `maxRegions` SHAPES the camera blanks out (NOT a cell
|
|
21876
|
+
* grid). Reolink `<shelterList>` zones are rectangles; Hikvision ISAPI
|
|
21877
|
+
* `<RegionCoordinatesList>` zones are free polygons (this camera: exactly
|
|
21878
|
+
* 4 vertices, not necessarily axis-aligned). The cap composes the shared
|
|
21879
|
+
* rect|polygon subset of the MaskShape vocabulary. All coords are
|
|
21880
|
+
* normalized 0..1 (top-left origin).
|
|
21881
|
+
* - **audio**: the camera's microphone. `setAudioEnabled(false)` stops the
|
|
21882
|
+
* camera encoding an audio track at all, so EVERY consumer — live view,
|
|
21883
|
+
* recording, the audio analyzer, an export — sees silent video. There is
|
|
21884
|
+
* no server-side copy of this fact; the camera is the store and every read
|
|
21885
|
+
* is a read-through, which is why a switch over it cannot drift
|
|
21886
|
+
* ([D62](../../../../docs/decisions/adr-0062.md)).
|
|
21887
|
+
*
|
|
21888
|
+
* Both belong here for one reason: they are the two things an operator turns
|
|
21889
|
+
* off when the answer to "what is this camera allowed to record" changes, and
|
|
21890
|
+
* both are applied ON the device, before anything leaves it.
|
|
21891
|
+
*
|
|
21892
|
+
* **The audio flag has exactly one writer.** `stream-params` used to carry a
|
|
21893
|
+
* per-profile `audio` in its patch schema — reachable from no UI and honoured
|
|
21894
|
+
* by one provider — and it was removed when this landed. A second writer onto
|
|
21895
|
+
* one device register is the shape of every knob this repo has shipped that
|
|
21896
|
+
* disagreed with the one the reader read.
|
|
21438
21897
|
*/
|
|
21439
21898
|
/** A privacy-mask region's geometry — rectangle or free polygon. */
|
|
21440
21899
|
var PrivacyMaskShapeSchema = discriminatedUnion("kind", [MaskRectShapeSchema, MaskPolygonShapeSchema]);
|
|
@@ -21450,16 +21909,40 @@ object({
|
|
|
21450
21909
|
enabled: boolean(),
|
|
21451
21910
|
/** Active zones (normalized 0..1). Length ≤ maxRegions. */
|
|
21452
21911
|
regions: array(PrivacyMaskRegionSchema),
|
|
21912
|
+
/**
|
|
21913
|
+
* Is the camera capturing sound right now? Read from the camera, never from
|
|
21914
|
+
* a server-side mirror.
|
|
21915
|
+
*
|
|
21916
|
+
* `null` means "no answer" — either this camera exposes no controllable
|
|
21917
|
+
* microphone (`getOptions().supportsAudioMute === false`) or the read
|
|
21918
|
+
* failed. A consumer must render `null` as UNKNOWN and never as `false`:
|
|
21919
|
+
* "the microphone is off" and "we could not ask" look identical to an
|
|
21920
|
+
* operator only until one of them is wrong.
|
|
21921
|
+
*
|
|
21922
|
+
* On a camera whose profiles carry the flag independently (Reolink writes
|
|
21923
|
+
* it per stream), `true` means AT LEAST ONE profile still carries audio —
|
|
21924
|
+
* privacy is only satisfied when every one of them is silent.
|
|
21925
|
+
*/
|
|
21926
|
+
audioEnabled: boolean().nullable(),
|
|
21453
21927
|
lastFetchedAt: number()
|
|
21454
21928
|
});
|
|
21455
|
-
/** Per-camera availability. */
|
|
21929
|
+
/** Per-camera availability. Probed, never assumed from the model name. */
|
|
21456
21930
|
var PrivacyMaskOptionsSchema = object({
|
|
21457
21931
|
/** Maximum number of supported zones. */
|
|
21458
21932
|
maxRegions: number(),
|
|
21459
21933
|
/** Shape kinds this camera accepts — Reolink: ['rect']; Hikvision: ['rect','polygon']. */
|
|
21460
21934
|
supportedShapes: array(MaskShapeKindSchema),
|
|
21461
21935
|
/** Polygon vertex bounds when 'polygon' is supported (Hikvision: {min:4,max:4}). */
|
|
21462
|
-
polygonVertices: MaskPolygonVerticesSchema.optional()
|
|
21936
|
+
polygonVertices: MaskPolygonVerticesSchema.optional(),
|
|
21937
|
+
/**
|
|
21938
|
+
* Does this camera expose a microphone switch we can actually write?
|
|
21939
|
+
*
|
|
21940
|
+
* Camera-probed: `true` only when the firmware answered with an audio flag
|
|
21941
|
+
* we know how to patch. A camera that never answered is `false` — a control
|
|
21942
|
+
* the operator can press that changes nothing is worse than no control, and
|
|
21943
|
+
* the switch group renders "not available" instead.
|
|
21944
|
+
*/
|
|
21945
|
+
supportsAudioMute: boolean()
|
|
21463
21946
|
});
|
|
21464
21947
|
/** Partial change — every field optional. */
|
|
21465
21948
|
var PrivacyMaskPatchSchema = object({
|
|
@@ -21472,6 +21955,12 @@ DeviceType.Camera, method(object({ deviceId: number() }), PrivacyMaskOptionsSche
|
|
|
21472
21955
|
}), _void(), {
|
|
21473
21956
|
kind: "mutation",
|
|
21474
21957
|
auth: "admin"
|
|
21958
|
+
}), method(object({
|
|
21959
|
+
deviceId: number(),
|
|
21960
|
+
enabled: boolean()
|
|
21961
|
+
}), _void(), {
|
|
21962
|
+
kind: "mutation",
|
|
21963
|
+
auth: "admin"
|
|
21475
21964
|
});
|
|
21476
21965
|
var PtzPresetSchema = object({
|
|
21477
21966
|
id: string(),
|
|
@@ -21681,6 +22170,21 @@ var LocateSegmentResultSchema = discriminatedUnion("kind", [object({
|
|
|
21681
22170
|
})]);
|
|
21682
22171
|
/** Raw bytes of one finalized footage segment (read off disk on the recording node). */
|
|
21683
22172
|
var ReadSegmentBytesResultSchema = object({ data: _instanceof(Uint8Array) });
|
|
22173
|
+
/**
|
|
22174
|
+
* One GOP of a finalized segment, cut by byte range through the segment's own
|
|
22175
|
+
* `mfra` (D31 on the D42 feeder path). `data` is the `ftyp`+`moov` head plus
|
|
22176
|
+
* the single `moof`+`mdat` covering the requested instant — standalone-
|
|
22177
|
+
* demuxable, never the whole file. When the segment's index cannot be parsed
|
|
22178
|
+
* the provider degrades INSIDE the mechanism to the whole segment (still one
|
|
22179
|
+
* `data`, `gopStartMs` = the segment start) — a worse read, not another path.
|
|
22180
|
+
*/
|
|
22181
|
+
var ReadGopBytesResultSchema = object({
|
|
22182
|
+
data: _instanceof(Uint8Array),
|
|
22183
|
+
/** Absolute epoch ms of the returned fragment's first sample. */
|
|
22184
|
+
gopStartMs: number(),
|
|
22185
|
+
/** Media ms the returned fragment covers. */
|
|
22186
|
+
gopDurMs: number()
|
|
22187
|
+
});
|
|
21684
22188
|
method(object({
|
|
21685
22189
|
deviceId: number(),
|
|
21686
22190
|
fromMs: number(),
|
|
@@ -21723,6 +22227,14 @@ method(object({
|
|
|
21723
22227
|
}), ReadSegmentBytesResultSchema, {
|
|
21724
22228
|
kind: "query",
|
|
21725
22229
|
auth: "admin"
|
|
22230
|
+
}), method(object({
|
|
22231
|
+
deviceId: number(),
|
|
22232
|
+
profile: string(),
|
|
22233
|
+
startMs: number(),
|
|
22234
|
+
epochMs: number()
|
|
22235
|
+
}), ReadGopBytesResultSchema, {
|
|
22236
|
+
kind: "query",
|
|
22237
|
+
auth: "admin"
|
|
21726
22238
|
}), method(object({
|
|
21727
22239
|
deviceId: number(),
|
|
21728
22240
|
config: RecordingConfigSchema
|
|
@@ -22203,6 +22715,16 @@ var StreamProfileConfigSchema = object({
|
|
|
22203
22715
|
"baseline"
|
|
22204
22716
|
]).optional(),
|
|
22205
22717
|
gop: number().optional(),
|
|
22718
|
+
/**
|
|
22719
|
+
* Whether THIS profile currently carries an audio track. READ-ONLY here.
|
|
22720
|
+
*
|
|
22721
|
+
* There is no matching field on {@link StreamProfilePatchSchema}: the
|
|
22722
|
+
* camera's microphone is owned by `privacy-mask` (`setAudioEnabled`), which
|
|
22723
|
+
* writes every profile at once so "audio off" means silent everywhere. A
|
|
22724
|
+
* per-profile writer beside it would let a camera be half-muted and would be
|
|
22725
|
+
* a second knob onto one device register — the failure D62 exists to
|
|
22726
|
+
* prevent. Absent when the firmware does not report the flag.
|
|
22727
|
+
*/
|
|
22206
22728
|
audio: boolean().optional()
|
|
22207
22729
|
});
|
|
22208
22730
|
object({
|
|
@@ -22243,7 +22765,13 @@ var StreamParamsOptionsSchema = object({
|
|
|
22243
22765
|
ext: StreamProfileOptionsSchema.optional()
|
|
22244
22766
|
});
|
|
22245
22767
|
/** A partial change to one profile — every field optional; a provider
|
|
22246
|
-
* ignores fields it doesn't support.
|
|
22768
|
+
* ignores fields it doesn't support.
|
|
22769
|
+
*
|
|
22770
|
+
* There is deliberately NO `audio` here. It existed until 2026-08-07,
|
|
22771
|
+
* reachable from no form and honoured by exactly one provider, while the
|
|
22772
|
+
* camera's microphone is a whole-device fact. It now has one writer,
|
|
22773
|
+
* `privacyMask.setAudioEnabled`, which writes every profile — see
|
|
22774
|
+
* `privacy-mask.cap.ts`. */
|
|
22247
22775
|
var StreamProfilePatchSchema = object({
|
|
22248
22776
|
width: number().optional(),
|
|
22249
22777
|
height: number().optional(),
|
|
@@ -22256,8 +22784,7 @@ var StreamProfilePatchSchema = object({
|
|
|
22256
22784
|
"main",
|
|
22257
22785
|
"baseline"
|
|
22258
22786
|
]).optional(),
|
|
22259
|
-
gop: number().optional()
|
|
22260
|
-
audio: boolean().optional()
|
|
22787
|
+
gop: number().optional()
|
|
22261
22788
|
});
|
|
22262
22789
|
DeviceType.Camera, method(object({ deviceId: number() }), StreamParamsOptionsSchema), method(object({
|
|
22263
22790
|
deviceId: number(),
|
|
@@ -26769,6 +27296,12 @@ Object.freeze({
|
|
|
26769
27296
|
addonId: null,
|
|
26770
27297
|
access: "view"
|
|
26771
27298
|
},
|
|
27299
|
+
"privacyMask.setAudioEnabled": {
|
|
27300
|
+
capName: "privacy-mask",
|
|
27301
|
+
capScope: "device",
|
|
27302
|
+
addonId: null,
|
|
27303
|
+
access: "create"
|
|
27304
|
+
},
|
|
26772
27305
|
"privacyMask.setMask": {
|
|
26773
27306
|
capName: "privacy-mask",
|
|
26774
27307
|
capScope: "device",
|
|
@@ -26937,6 +27470,12 @@ Object.freeze({
|
|
|
26937
27470
|
addonId: null,
|
|
26938
27471
|
access: "create"
|
|
26939
27472
|
},
|
|
27473
|
+
"recording.readGopBytes": {
|
|
27474
|
+
capName: "recording",
|
|
27475
|
+
capScope: "system",
|
|
27476
|
+
addonId: null,
|
|
27477
|
+
access: "view"
|
|
27478
|
+
},
|
|
26940
27479
|
"recording.readSegmentBytes": {
|
|
26941
27480
|
capName: "recording",
|
|
26942
27481
|
capScope: "system",
|
|
@@ -28358,6 +28897,88 @@ object({
|
|
|
28358
28897
|
square: false
|
|
28359
28898
|
}).paddingRatio;
|
|
28360
28899
|
/**
|
|
28900
|
+
* WHICH delivered frames the decode worker retains a native copy of.
|
|
28901
|
+
*
|
|
28902
|
+
* - `all` — every frame the worker delivered to the runner. The shipped
|
|
28903
|
+
* behaviour, and the only correct one if something can ask for a crop of a
|
|
28904
|
+
* frame the runner never sent to inference.
|
|
28905
|
+
* - `inferred` — only the frames the runner ADMITTED to its detection queue.
|
|
28906
|
+
* A native-crop request always names a `frameId` that rode an inference
|
|
28907
|
+
* result, so that is the only set a request can name. How much it drops is
|
|
28908
|
+
* the two-plane governor's admit ratio and nothing else: measured at ~50% on
|
|
28909
|
+
* this cluster, not the ~80% the design sketch assumed, because the governor
|
|
28910
|
+
* was not throttling as hard as the sketch supposed. Read
|
|
28911
|
+
* `leaseAdmitted`/`leaseOffered` off the metrics line for the camera in front
|
|
28912
|
+
* of you rather than quoting a number from here. The newest delivered frame is
|
|
28913
|
+
* croppable regardless — it is still the worker's reserved slot, not a lease —
|
|
28914
|
+
* which covers the one-frame race between a mark and the supersede that
|
|
28915
|
+
* consumes it.
|
|
28916
|
+
*/
|
|
28917
|
+
var NativeLeaseAdmissionSchema = _enum(["all", "inferred"]);
|
|
28918
|
+
object({
|
|
28919
|
+
/**
|
|
28920
|
+
* How long a retained native frame is served before it counts as a miss.
|
|
28921
|
+
*
|
|
28922
|
+
* Must cover the FULL late-crop horizon: detection inference + the
|
|
28923
|
+
* cross-process inference-result hop to hub post-analysis + tracking + the
|
|
28924
|
+
* tRPC crop round-trip back. Below ~500 ms the busiest cameras' subject crops
|
|
28925
|
+
* outrun it and fall back to the ≤640 detection frame; above ~3 s the resident
|
|
28926
|
+
* RAM per busy camera grows linearly with no measured hit-rate gain.
|
|
28927
|
+
*/
|
|
28928
|
+
ttlMs: number().int().min(250).max(1e4),
|
|
28929
|
+
/**
|
|
28930
|
+
* Hard per-decode-worker RAM ceiling for retained native frames, in MB.
|
|
28931
|
+
*
|
|
28932
|
+
* Intended as a SAFETY ceiling with the TTL as the effective cap — but check
|
|
28933
|
+
* which one is actually binding before reasoning from that. At the shipped
|
|
28934
|
+
* 1024 MB and a 2 800 ms TTL, a 4K camera hits the CEILING first (~43 frames
|
|
28935
|
+
* at ~24 MB each) and the TTL never gets to expire anything; `leaseMb` /
|
|
28936
|
+
* `leaseFrames` on the metrics line say which. When the ceiling binds, a
|
|
28937
|
+
* change that admits fewer frames buys retention WINDOW at constant RAM
|
|
28938
|
+
* rather than giving RAM back — lower this knob if RAM is what you wanted.
|
|
28939
|
+
* `0` DISABLES the lease entirely and falls the worker back to the tiny
|
|
28940
|
+
* leak-prone GPU surface ring (~85% crop miss; that is what the lease exists
|
|
28941
|
+
* to replace).
|
|
28942
|
+
*/
|
|
28943
|
+
budgetMb: number().int().min(0).max(4096),
|
|
28944
|
+
/**
|
|
28945
|
+
* Demand window: eager per-frame native retention runs only within this many
|
|
28946
|
+
* ms of the last native-crop request (or of the dial starting).
|
|
28947
|
+
*
|
|
28948
|
+
* `0` means ALWAYS ON — it disables the gate, it does not disable retention.
|
|
28949
|
+
* That is the legacy behaviour that saturated an N100 (24 native-4K downloads
|
|
28950
|
+
* per second on a camera with zero crop demand), so leave it non-zero unless
|
|
28951
|
+
* you are reproducing that.
|
|
28952
|
+
*/
|
|
28953
|
+
activityMs: number().int().min(0).max(12e4),
|
|
28954
|
+
/**
|
|
28955
|
+
* Which delivered frames are retained at all — see
|
|
28956
|
+
* {@link NativeLeaseAdmissionSchema}. This is the only knob of the four that
|
|
28957
|
+
* changes WHAT is kept rather than for how long, so it is also the only one
|
|
28958
|
+
* that can turn a crop that used to hit into a miss. The worker counts every
|
|
28959
|
+
* crop request naming a frame it did NOT see marked
|
|
28960
|
+
* (`leaseUnmarkedCrops` on the session-decode metrics line): a non-zero value
|
|
28961
|
+
* there is the signal that some caller names frames outside the inference set
|
|
28962
|
+
* and that this must go back to `all`.
|
|
28963
|
+
*/
|
|
28964
|
+
admission: NativeLeaseAdmissionSchema
|
|
28965
|
+
});
|
|
28966
|
+
/**
|
|
28967
|
+
* The values in force when the operator has set nothing — byte-for-byte the
|
|
28968
|
+
* constants the decode worker shipped with as env-var defaults, so making these
|
|
28969
|
+
* settings changed no behaviour on the day it landed.
|
|
28970
|
+
*/
|
|
28971
|
+
var DEFAULT_NATIVE_LEASE_SETTINGS = {
|
|
28972
|
+
ttlMs: 1200,
|
|
28973
|
+
budgetMb: 1024,
|
|
28974
|
+
activityMs: 15e3,
|
|
28975
|
+
admission: "inferred"
|
|
28976
|
+
};
|
|
28977
|
+
DEFAULT_NATIVE_LEASE_SETTINGS.ttlMs;
|
|
28978
|
+
DEFAULT_NATIVE_LEASE_SETTINGS.budgetMb;
|
|
28979
|
+
DEFAULT_NATIVE_LEASE_SETTINGS.activityMs;
|
|
28980
|
+
DEFAULT_NATIVE_LEASE_SETTINGS.admission;
|
|
28981
|
+
/**
|
|
28361
28982
|
* Compute the stable 64-char lowercase-hex fingerprint of a device's
|
|
28362
28983
|
* export-relevant shape. Two structurally-equal shapes (any feature order,
|
|
28363
28984
|
* any duplicates, any deviceId) hash identically.
|
|
@@ -28496,7 +29117,7 @@ function clearPairingFiles(accessoryUuid, logger) {
|
|
|
28496
29117
|
}
|
|
28497
29118
|
//#endregion
|
|
28498
29119
|
//#region src/hap-setup-uri.ts
|
|
28499
|
-
function errMsg$
|
|
29120
|
+
function errMsg$11(e) {
|
|
28500
29121
|
return e instanceof Error ? e.message : String(e);
|
|
28501
29122
|
}
|
|
28502
29123
|
/**
|
|
@@ -28523,7 +29144,7 @@ function firstExposedAccessorySetupUri(exposed, logger) {
|
|
|
28523
29144
|
try {
|
|
28524
29145
|
return first.setupURI();
|
|
28525
29146
|
} catch (err) {
|
|
28526
|
-
logger.debug("export-hap: setupURI failed on first exposed accessory", { meta: { error: errMsg$
|
|
29147
|
+
logger.debug("export-hap: setupURI failed on first exposed accessory", { meta: { error: errMsg$11(err) } });
|
|
28527
29148
|
return;
|
|
28528
29149
|
}
|
|
28529
29150
|
}
|
|
@@ -28561,13 +29182,19 @@ function hapServiceName(parts, fallback) {
|
|
|
28561
29182
|
/**
|
|
28562
29183
|
* The privacy-mask switch.
|
|
28563
29184
|
*
|
|
28564
|
-
* The camera
|
|
28565
|
-
* camera's
|
|
28566
|
-
*
|
|
28567
|
-
*
|
|
29185
|
+
* Just "Privacy". The camera name is NOT prefixed: this service lives inside
|
|
29186
|
+
* the camera's own accessory, iOS already renders it under the camera, and a
|
|
29187
|
+
* round that prefixed it gave the operator "Videocamera ingresso Privacy"
|
|
29188
|
+
* sitting inside a tile titled "Videocamera ingresso".
|
|
29189
|
+
*
|
|
29190
|
+
* The prefix was added for a real reason — two cameras publishing a switch
|
|
29191
|
+
* called "Privacy" — but that was a symptom of the label being the ONLY thing
|
|
29192
|
+
* shown, which stopped being true once `ConfiguredName` made the service
|
|
29193
|
+
* render in its accessory's context. Uniqueness is required WITHIN one
|
|
29194
|
+
* accessory, not across the bridge, and one camera has one privacy switch.
|
|
28568
29195
|
*/
|
|
28569
|
-
function privacyServiceName(
|
|
28570
|
-
return hapServiceName([
|
|
29196
|
+
function privacyServiceName() {
|
|
29197
|
+
return hapServiceName([PRIVACY_SUFFIX], PRIVACY_SUFFIX);
|
|
28571
29198
|
}
|
|
28572
29199
|
/**
|
|
28573
29200
|
* Deliberately not localised, and deliberately not a translation table.
|
|
@@ -28584,35 +29211,60 @@ var PRIVACY_SUFFIX = "Privacy";
|
|
|
28584
29211
|
* the parent camera.
|
|
28585
29212
|
*
|
|
28586
29213
|
* The child's OWN stored name wins. It is the string the operator typed, in
|
|
28587
|
-
* the operator's language, and
|
|
29214
|
+
* the operator's language, and an early rule threw it away: `role` was
|
|
28588
29215
|
* consulted first and title-cased, so every siren on the fleet published as
|
|
28589
29216
|
* the English word "Siren" no matter what the operator had called it.
|
|
28590
29217
|
*
|
|
28591
|
-
*
|
|
28592
|
-
*
|
|
28593
|
-
*
|
|
29218
|
+
* Providers name children both ways — "Sirena" and "Videocamera cucina
|
|
29219
|
+
* Sirena". The parent half is now REMOVED rather than added, because the
|
|
29220
|
+
* service is published inside the parent camera's own accessory and iOS
|
|
29221
|
+
* already shows it there. The result must be one form, not two.
|
|
28594
29222
|
*/
|
|
28595
29223
|
function childServiceName(parentName, child) {
|
|
28596
|
-
const own = child.name.trim();
|
|
28597
|
-
if (own.length > 0) return
|
|
28598
|
-
return hapServiceName([
|
|
29224
|
+
const own = withoutParent(child.name.trim(), parentName);
|
|
29225
|
+
if (own.length > 0) return hapServiceName([own], own);
|
|
29226
|
+
return hapServiceName([typeof child.role === "string" ? titleCase(child.role) : ""], CHILD_FALLBACK);
|
|
28599
29227
|
}
|
|
28600
29228
|
/**
|
|
28601
|
-
*
|
|
29229
|
+
* Last resort for a child that carries neither a name nor a role. Better than
|
|
29230
|
+
* the parent's name, which would publish a service indistinguishable from the
|
|
29231
|
+
* accessory holding it — the exact defect this module keeps being asked to fix.
|
|
28602
29232
|
*
|
|
28603
|
-
*
|
|
28604
|
-
*
|
|
28605
|
-
*
|
|
28606
|
-
* ingresso".
|
|
29233
|
+
* English, like the role slugs it stands in for ("Floodlight", "Siren"): the
|
|
29234
|
+
* only strings this module invents are English, and inventing one Italian word
|
|
29235
|
+
* would be a localisation layer that localises nothing.
|
|
28607
29236
|
*/
|
|
28608
|
-
|
|
28609
|
-
|
|
29237
|
+
var CHILD_FALLBACK = "Accessory";
|
|
29238
|
+
/**
|
|
29239
|
+
* A PTZ action switch: the bare action, "Preset ingresso" / "Pan Left" /
|
|
29240
|
+
* "Autotrack".
|
|
29241
|
+
*
|
|
29242
|
+
* The labels themselves stay in `ptz-labels.ts` — they name a HomeKit control,
|
|
29243
|
+
* not a device. This function exists only to put the operator-typed half of a
|
|
29244
|
+
* preset name through the same sanitisation everything else gets; it no longer
|
|
29245
|
+
* qualifies the label with the camera, because all eight PTZ services live on
|
|
29246
|
+
* that camera's accessory and are unique among themselves.
|
|
29247
|
+
*/
|
|
29248
|
+
function ptzServiceName(actionLabel) {
|
|
29249
|
+
return hapServiceName([actionLabel], actionLabel);
|
|
28610
29250
|
}
|
|
28611
|
-
/**
|
|
28612
|
-
|
|
28613
|
-
|
|
28614
|
-
|
|
28615
|
-
|
|
29251
|
+
/**
|
|
29252
|
+
* Drop `parentName` from the front of `name`.
|
|
29253
|
+
*
|
|
29254
|
+
* A PREFIX only. "Videocamera cucina Sirena" → "Sirena"; "Sirena" is already
|
|
29255
|
+
* bare and untouched. A parent name appearing anywhere else in the child's
|
|
29256
|
+
* name is left alone — cutting from the middle of a string the operator typed
|
|
29257
|
+
* would mangle it, and this function must never make a label WORSE.
|
|
29258
|
+
*
|
|
29259
|
+
* Returns `name` unchanged when stripping would leave nothing: a child the
|
|
29260
|
+
* operator called exactly what the camera is called still needs a label.
|
|
29261
|
+
*/
|
|
29262
|
+
function withoutParent(name, parentName) {
|
|
29263
|
+
const needle = parentName.trim();
|
|
29264
|
+
if (needle.length === 0) return name;
|
|
29265
|
+
if (!name.toLowerCase().startsWith(needle.toLowerCase())) return name;
|
|
29266
|
+
const rest = name.slice(needle.length).trim();
|
|
29267
|
+
return rest.length > 0 ? rest : name;
|
|
28616
29268
|
}
|
|
28617
29269
|
/**
|
|
28618
29270
|
* Truncate to the HAP ceiling and shave any leading/trailing character the
|
|
@@ -28661,7 +29313,7 @@ async function buildBattery(bctx) {
|
|
|
28661
29313
|
const status = await proxy.battery?.getStatus({});
|
|
28662
29314
|
if (status) applyToService(service, status);
|
|
28663
29315
|
} catch (err) {
|
|
28664
|
-
log.debug("export-hap: battery getStatus hydrate failed (non-fatal)", { meta: { error: errMsg$
|
|
29316
|
+
log.debug("export-hap: battery getStatus hydrate failed (non-fatal)", { meta: { error: errMsg$10(err) } });
|
|
28665
29317
|
}
|
|
28666
29318
|
const unsubscribes = [];
|
|
28667
29319
|
if (proxy.state.battery) {
|
|
@@ -28687,7 +29339,7 @@ function applyToService(service, status) {
|
|
|
28687
29339
|
const lowBattery = pct <= LOW_BATTERY_THRESHOLD_PCT ? _homebridge_hap_nodejs.Characteristic.StatusLowBattery.BATTERY_LEVEL_LOW : _homebridge_hap_nodejs.Characteristic.StatusLowBattery.BATTERY_LEVEL_NORMAL;
|
|
28688
29340
|
service.updateCharacteristic(_homebridge_hap_nodejs.Characteristic.StatusLowBattery, lowBattery);
|
|
28689
29341
|
}
|
|
28690
|
-
function errMsg$
|
|
29342
|
+
function errMsg$10(err) {
|
|
28691
29343
|
return err instanceof Error ? err.message : String(err);
|
|
28692
29344
|
}
|
|
28693
29345
|
//#endregion
|
|
@@ -39183,9 +39835,121 @@ function ingestDecryptedRtcp(plaintext, tally) {
|
|
|
39183
39835
|
failure: null
|
|
39184
39836
|
};
|
|
39185
39837
|
}
|
|
39838
|
+
function classifyConnection(input) {
|
|
39839
|
+
if (input.negotiatedWidth < 640) return "watch";
|
|
39840
|
+
if (input.audioPacketTimeMs >= 60) return "remote";
|
|
39841
|
+
return input.viaHomeHub ? "home-hub" : "local";
|
|
39842
|
+
}
|
|
39843
|
+
/**
|
|
39844
|
+
* The slot each class asks for.
|
|
39845
|
+
*
|
|
39846
|
+
* ## `local` takes the camera's best stream — settled by measurement
|
|
39847
|
+
*
|
|
39848
|
+
* This function was pinned to `low` for EVERY class by one number. On 615/high,
|
|
39849
|
+
* 3840x2160 pass-through:
|
|
39850
|
+
*
|
|
39851
|
+
* durationMs=30820 videoPacketsForwarded=93 videoKeyframes=1
|
|
39852
|
+
* audioPacketsForwarded=1497 lost=0
|
|
39853
|
+
*
|
|
39854
|
+
* Three video datagrams a second, one key frame in half a minute, while the
|
|
39855
|
+
* AUDIO leg of the *same* ffmpeg ran perfectly. It read as "our path cannot
|
|
39856
|
+
* carry a high-bitrate stream".
|
|
39857
|
+
*
|
|
39858
|
+
* **It was not HomeKit, not 4K and not SRTP. The loopback UDP socket ffmpeg
|
|
39859
|
+
* writes its RTP into had no `SO_RCVBUF` at all** (2026-08-07). It ran on
|
|
39860
|
+
* `net.core.rmem_default`, 212 992 B — about a fifth of one 4K IDR, which
|
|
39861
|
+
* arrives as ~750 datagrams at `pkt_size=1378` in a single burst. The kernel
|
|
39862
|
+
* discarded the overflow, and a datagram dropped there never reaches a
|
|
39863
|
+
* `message` handler, so it lowered the forwarded count exactly like a packet
|
|
39864
|
+
* ffmpeg never wrote and the controller reported no loss for it either. Audio,
|
|
39865
|
+
* a few hundred bytes every 20 ms, never filled the buffer. That is the whole
|
|
39866
|
+
* asymmetry. See `stream-socket-buffer.ts`.
|
|
39867
|
+
*
|
|
39868
|
+
* With an 8 MiB buffer (granted — this hub's `net.core.rmem_max` is 16 MiB),
|
|
39869
|
+
* the same camera and the same slot, session
|
|
39870
|
+
* `12308100-7dbe-4ad1-b277-1f046ba54ec2` on 2026-08-07:
|
|
39871
|
+
*
|
|
39872
|
+
* selectedProfile=high transcode=false slotMeasuredKbps=5097
|
|
39873
|
+
* videoPacketsForwarded=6512 durationMs=9867 (~660/s, was ~3/s)
|
|
39874
|
+
* msToFirstKeyframe=858 deliveredFps=24 worstFractionLostPct=0.4
|
|
39875
|
+
* videoLoopRcvbufBytes=16777216 clamped=false
|
|
39876
|
+
*
|
|
39877
|
+
* Operator: loaded instantly, and visibly not the low stream. A 220x increase
|
|
39878
|
+
* in delivered packet rate from sizing one socket.
|
|
39879
|
+
*
|
|
39880
|
+
* ## Why the remote classes stay `low`
|
|
39881
|
+
*
|
|
39882
|
+
* Not caution left over from the freeze — a different, UNMEASURED question.
|
|
39883
|
+
* `watch`, `remote` and `home-hub` all send video across a link whose budget
|
|
39884
|
+
* nothing here has measured; the buffer fix says something about a loopback hop
|
|
39885
|
+
* inside one host and nothing whatsoever about a WAN. 4K pass-through at ~5 Mbps
|
|
39886
|
+
* to a phone on LTE is a decision that needs its own evidence, and `watch` has
|
|
39887
|
+
* a panel under 640 px wide that could not use the pixels anyway. Raise these
|
|
39888
|
+
* only with a measurement of the remote link, not by analogy with this one.
|
|
39889
|
+
*
|
|
39890
|
+
* `mid` remains excluded from every class, unrelated to all of the above: it is
|
|
39891
|
+
* a 10 fps stream on this fleet and it has never rendered under any combination
|
|
39892
|
+
* tried.
|
|
39893
|
+
*/
|
|
39894
|
+
function slotForConnection(connection) {
|
|
39895
|
+
switch (connection) {
|
|
39896
|
+
case "watch": return "low";
|
|
39897
|
+
case "remote": return "low";
|
|
39898
|
+
case "home-hub": return "low";
|
|
39899
|
+
case "local": return "high";
|
|
39900
|
+
}
|
|
39901
|
+
}
|
|
39186
39902
|
//#endregion
|
|
39187
39903
|
//#region src/mappers/builders/stream-bitrate.ts
|
|
39188
39904
|
/**
|
|
39905
|
+
* Send a stream that FITS the rate HomeKit negotiated. (R5)
|
|
39906
|
+
*
|
|
39907
|
+
* The controller's own Receiver Reports, read on the live hub on 2026-08-06,
|
|
39908
|
+
* closed a year of guessing: one 19.27 s session on `615/mid` forwarded 737
|
|
39909
|
+
* video packets at `mtu=1378` — roughly **421 kbps** — against a negotiated
|
|
39910
|
+
* `max_bit_rate` of **299**, and iOS reported losing **450 of those 737
|
|
39911
|
+
* packets (61 %)**, worst fraction lost 51.2 %, peak jitter 3.03 s. Under
|
|
39912
|
+
* `-c:v copy` the accessory has no lever at all: it forwards whatever the
|
|
39913
|
+
* camera's encoder produces, at whatever cadence it produces it.
|
|
39914
|
+
*
|
|
39915
|
+
* So the fix has two halves, and this module owns both:
|
|
39916
|
+
*
|
|
39917
|
+
* 1. **Choose a slot that fits.** Among the slots that can be passed through
|
|
39918
|
+
* (H.264) and whose rate is known to be within budget, the existing
|
|
39919
|
+
* resolution-closest picker decides — so the "which slot serves which
|
|
39920
|
+
* resolution" opinion stays single, exactly as D51 requires.
|
|
39921
|
+
* 2. **Transcode only when none does**, with a real cap
|
|
39922
|
+
* (`-b:v` / `-maxrate` / `-bufsize`) at the negotiated rate.
|
|
39923
|
+
*
|
|
39924
|
+
* ## Where the authoritative rate comes from, and why it is NOT the obvious one
|
|
39925
|
+
*
|
|
39926
|
+
* `webrtcSession.listStreams` reports a `bitrateKbps` per slot and it is a
|
|
39927
|
+
* **measured flow rate**, which is meaningless for a slot nobody is consuming.
|
|
39928
|
+
* Live on 2026-08-06 it reported `mid = 9 kbps` for the very slot that had just
|
|
39929
|
+
* delivered ~421 kbps, `low = 5 kbps`, and `high = 5441 kbps` (high was being
|
|
39930
|
+
* consumed, hence plausible). Selecting on that reading would admit every slot.
|
|
39931
|
+
*
|
|
39932
|
+
* The authority is therefore the camera's **configured** encoder rate, from
|
|
39933
|
+
* `streamParams.getStatus` — `main` / `sub` / `ext`, each carrying the
|
|
39934
|
+
* `bitrate` the operator (or the vendor default) set. On 615 that is
|
|
39935
|
+
* `main 8192`, `sub 2048`, `ext 2048` kbps. It is mapped onto a profile slot
|
|
39936
|
+
* through the slot's assigned cam-stream, matched on resolution and frame
|
|
39937
|
+
* rate; an ambiguous or absent match is reported as **unknown**, never as a
|
|
39938
|
+
* number.
|
|
39939
|
+
*
|
|
39940
|
+
* This inverts D51's ordering — there, `measured` outranks `published` — and
|
|
39941
|
+
* the inversion is deliberate:
|
|
39942
|
+
*
|
|
39943
|
+
* - a frame rate is a stable property of the source and a measurement of it
|
|
39944
|
+
* is the *best* evidence;
|
|
39945
|
+
* - a bitrate under VBR is an envelope. A measurement is a **lower bound**
|
|
39946
|
+
* on it, and a lower bound can prove a slot does NOT fit but can never
|
|
39947
|
+
* prove that it does.
|
|
39948
|
+
*
|
|
39949
|
+
* So `measured` is kept, and used only in the direction it is sound in.
|
|
39950
|
+
* Everything here is pure; the cap reads live in `stream-bitrate-probe.ts`.
|
|
39951
|
+
*/
|
|
39952
|
+
/**
|
|
39189
39953
|
* Fraction of the negotiated ceiling we actually aim the encoder at.
|
|
39190
39954
|
*
|
|
39191
39955
|
* `max_bit_rate` is what the controller budgeted for the stream; what crosses
|
|
@@ -39278,7 +40042,8 @@ function classifyBitrateFit(evidence, budgetKbps) {
|
|
|
39278
40042
|
function selectStreamForBudget(input) {
|
|
39279
40043
|
const budgetKbps = budgetForNegotiatedRate(input.negotiatedMaxBitrateKbps);
|
|
39280
40044
|
const notes = fitNotes(input.entries, input.bitrates, budgetKbps);
|
|
39281
|
-
const
|
|
40045
|
+
const effectivePref = input.pref === "auto" ? slotForConnection(input.connection) : input.pref;
|
|
40046
|
+
const fallback = pickPreferredRtspEntry(input.entries, effectivePref, input.deviceId, { targetResolution: input.targetResolution });
|
|
39282
40047
|
if (fallback === null) return null;
|
|
39283
40048
|
const fallbackProfile = toCamProfile$1(fallback.profileId);
|
|
39284
40049
|
if (budgetKbps === null) {
|
|
@@ -39303,7 +40068,7 @@ function selectStreamForBudget(input) {
|
|
|
39303
40068
|
return classifyBitrateFit(input.bitrates.get(entry.profile), budgetKbps) === "fits";
|
|
39304
40069
|
});
|
|
39305
40070
|
if (affordable.length > 0) {
|
|
39306
|
-
const picked = pickPreferredRtspEntry(affordable,
|
|
40071
|
+
const picked = pickPreferredRtspEntry(affordable, effectivePref, input.deviceId, { targetResolution: input.targetResolution });
|
|
39307
40072
|
if (picked !== null) return {
|
|
39308
40073
|
kind: "copy",
|
|
39309
40074
|
reason: "source-fits-budget",
|
|
@@ -39348,16 +40113,15 @@ function withSlotCodecs(entries, slots) {
|
|
|
39348
40113
|
});
|
|
39349
40114
|
}
|
|
39350
40115
|
/**
|
|
39351
|
-
* The
|
|
39352
|
-
*
|
|
39353
|
-
*
|
|
39354
|
-
|
|
39355
|
-
|
|
39356
|
-
|
|
39357
|
-
|
|
39358
|
-
|
|
39359
|
-
|
|
39360
|
-
* The ffmpeg video-output arguments.
|
|
40116
|
+
* The video half of the ffmpeg plan, in the SHARED vocabulary
|
|
40117
|
+
* (`@camstack/types` `ffmpeg/invocation.ts`). This function used to emit
|
|
40118
|
+
* arguments; it now describes them, and `buildFfmpegArgs` emits every one — the
|
|
40119
|
+
* repo keeps exactly one argv builder, and HomeKit stopped being an exception
|
|
40120
|
+
* to that (D67, `scripts/check-ffmpeg-primitive.ts` Rule 1).
|
|
40121
|
+
*
|
|
40122
|
+
* Nothing about the RESULT changed except the rescale spelling: `-s WxH` became
|
|
40123
|
+
* `-vf scale=W:H`. Equivalent for a plain rescale, and worth knowing because
|
|
40124
|
+
* the two are NOT interchangeable once another `-vf` is in play.
|
|
39361
40125
|
*
|
|
39362
40126
|
* Pass-through carries `-bsf:v dump_extra` so every IDR inlines its own
|
|
39363
40127
|
* SPS/PPS — sources that publish parameter sets only in the RTSP SDP hand iOS
|
|
@@ -39368,54 +40132,42 @@ function deliverableFps(negotiatedFps, slotFps) {
|
|
|
39368
40132
|
* **one-second** `-bufsize` bounds any one-second window at the negotiated
|
|
39369
40133
|
* rate, which is also the only lever available on the 3.03 s peak jitter — the
|
|
39370
40134
|
* VBV window is what forces x264 to size a key frame to fit rather than
|
|
39371
|
-
* emitting it as one tight burst.
|
|
39372
|
-
|
|
39373
|
-
|
|
39374
|
-
*
|
|
39375
|
-
*
|
|
39376
|
-
*
|
|
39377
|
-
*
|
|
39378
|
-
*
|
|
39379
|
-
|
|
39380
|
-
|
|
39381
|
-
function
|
|
39382
|
-
if (!input.transcode) return
|
|
39383
|
-
|
|
39384
|
-
"
|
|
39385
|
-
|
|
39386
|
-
|
|
39387
|
-
|
|
39388
|
-
|
|
39389
|
-
|
|
39390
|
-
|
|
39391
|
-
|
|
39392
|
-
|
|
39393
|
-
|
|
39394
|
-
|
|
39395
|
-
|
|
39396
|
-
|
|
39397
|
-
|
|
39398
|
-
"
|
|
39399
|
-
|
|
39400
|
-
|
|
39401
|
-
|
|
39402
|
-
|
|
39403
|
-
|
|
39404
|
-
|
|
39405
|
-
"
|
|
39406
|
-
|
|
39407
|
-
"-s",
|
|
39408
|
-
`${input.width}x${input.height}`,
|
|
39409
|
-
"-g",
|
|
39410
|
-
String(Math.max(1, Math.round(input.fps * KEYFRAME_INTERVAL_SEC))),
|
|
39411
|
-
...rate,
|
|
39412
|
-
"-profile:v",
|
|
39413
|
-
"baseline",
|
|
39414
|
-
"-level",
|
|
39415
|
-
"3.1",
|
|
39416
|
-
"-bsf:v",
|
|
39417
|
-
"dump_extra"
|
|
39418
|
-
];
|
|
40135
|
+
* emitting it as one tight burst. That window is {@link RATE_CONTROL_TIGHT},
|
|
40136
|
+
* the shared constant whose whole reason to exist is HomeKit's per-second
|
|
40137
|
+
* budget; the browser and Echo use the relaxed two-second one.
|
|
40138
|
+
*
|
|
40139
|
+
* **The encoder stays `libx264`, deliberately.** `h264_vaapi` / `h264_qsv`
|
|
40140
|
+
* carry their own rate-control model, do not accept `-profile:v baseline`, and
|
|
40141
|
+
* emit parameter sets on their own schedule rather than x264's — which puts the
|
|
40142
|
+
* two load-bearing flags below back in play, with no hardware here to prove
|
|
40143
|
+
* they still hold. Hardware DECODE is where the measured cost is.
|
|
40144
|
+
*/
|
|
40145
|
+
function buildVideoPlan(input) {
|
|
40146
|
+
if (!input.transcode) return {
|
|
40147
|
+
kind: "copy",
|
|
40148
|
+
bitstreamFilter: "dump_extra"
|
|
40149
|
+
};
|
|
40150
|
+
return {
|
|
40151
|
+
kind: "encode",
|
|
40152
|
+
encoder: "libx264",
|
|
40153
|
+
scale: {
|
|
40154
|
+
mode: "exact",
|
|
40155
|
+
width: input.width,
|
|
40156
|
+
height: input.height
|
|
40157
|
+
},
|
|
40158
|
+
preset: "ultrafast",
|
|
40159
|
+
tune: "zerolatency",
|
|
40160
|
+
profile: "baseline",
|
|
40161
|
+
level: "3.1",
|
|
40162
|
+
pixelFormat: "yuv420p",
|
|
40163
|
+
fps: input.fps,
|
|
40164
|
+
gopFrames: Math.max(1, Math.round(input.fps * 4)),
|
|
40165
|
+
...input.budgetKbps === null ? {} : {
|
|
40166
|
+
bitrateKbps: input.budgetKbps,
|
|
40167
|
+
rateControl: RATE_CONTROL_TIGHT
|
|
40168
|
+
},
|
|
40169
|
+
bitstreamFilter: "dump_extra"
|
|
40170
|
+
};
|
|
39419
40171
|
}
|
|
39420
40172
|
/** Compact `mid=2048pub/9meas:over-budget` rendering for a single log field. */
|
|
39421
40173
|
function formatFitNotes(notes) {
|
|
@@ -39495,23 +40247,129 @@ function toCamProfile$1(profileId) {
|
|
|
39495
40247
|
return CAM_PROFILES$1.find((p) => p === profileId) ?? null;
|
|
39496
40248
|
}
|
|
39497
40249
|
//#endregion
|
|
40250
|
+
//#region src/mappers/builders/deadline.ts
|
|
40251
|
+
/**
|
|
40252
|
+
* Bound a piece of optional work in time.
|
|
40253
|
+
*
|
|
40254
|
+
* HomeKit answers `Selected RTP Stream Configuration` inside a write handler
|
|
40255
|
+
* hap-nodejs expects back quickly, and the start path behind it makes six
|
|
40256
|
+
* sequential cross-process cap calls into a stream-broker that regularly
|
|
40257
|
+
* freezes for two to three seconds at a time. Measured on the live hub: the
|
|
40258
|
+
* controller negotiated at :11, gave up at 9.1 s, and the bitrate fit resolved
|
|
40259
|
+
* at :32 — twenty-one seconds — with the start then failing on `Not running`
|
|
40260
|
+
* because the session it was preparing no longer existed.
|
|
40261
|
+
*
|
|
40262
|
+
* The evidence those calls gather is genuinely optional: an absent reading
|
|
40263
|
+
* classifies as `unknown`, and the tolerated branch still picks a slot. So the
|
|
40264
|
+
* right trade under load is to answer with less evidence rather than late, and
|
|
40265
|
+
* this makes that trade explicit at each call site instead of leaving it to
|
|
40266
|
+
* whatever the broker's latency happens to be.
|
|
40267
|
+
*
|
|
40268
|
+
* A late failure from work we stopped waiting on is swallowed on purpose: the
|
|
40269
|
+
* probe keeps running after the deadline fires, and an unhandled rejection
|
|
40270
|
+
* from an abandoned probe would take the process down over a reading nobody is
|
|
40271
|
+
* using any more.
|
|
40272
|
+
*/
|
|
40273
|
+
var TIMED_OUT = Symbol("deadline:timed-out");
|
|
40274
|
+
var FAILED = Symbol("deadline:failed");
|
|
40275
|
+
async function withDeadline(work, ms, fallback, onTimeout) {
|
|
40276
|
+
let timer;
|
|
40277
|
+
const guard = new Promise((resolve) => {
|
|
40278
|
+
timer = setTimeout(() => resolve(TIMED_OUT), ms);
|
|
40279
|
+
});
|
|
40280
|
+
try {
|
|
40281
|
+
const settled = await Promise.race([work.catch(() => FAILED), guard]);
|
|
40282
|
+
if (settled === TIMED_OUT) {
|
|
40283
|
+
onTimeout();
|
|
40284
|
+
return fallback;
|
|
40285
|
+
}
|
|
40286
|
+
return settled === FAILED ? fallback : settled;
|
|
40287
|
+
} finally {
|
|
40288
|
+
if (timer !== void 0) clearTimeout(timer);
|
|
40289
|
+
work.catch(() => void 0);
|
|
40290
|
+
}
|
|
40291
|
+
}
|
|
40292
|
+
//#endregion
|
|
39498
40293
|
//#region src/mappers/builders/stream-bitrate-probe.ts
|
|
39499
40294
|
/**
|
|
39500
|
-
*
|
|
39501
|
-
*
|
|
39502
|
-
|
|
40295
|
+
* Total budget for the rate evidence, not per call — the point is to bound
|
|
40296
|
+
* what the CONTROLLER waits for, and it waits for the sum.
|
|
40297
|
+
*/
|
|
40298
|
+
var BITRATE_EVIDENCE_BUDGET_MS = 1500;
|
|
40299
|
+
var NO_EVIDENCE = {
|
|
40300
|
+
camStreams: null,
|
|
40301
|
+
streamParams: null,
|
|
40302
|
+
choices: null
|
|
40303
|
+
};
|
|
40304
|
+
/**
|
|
40305
|
+
* The last evidence that actually arrived, per device.
|
|
40306
|
+
*
|
|
40307
|
+
* Falling back to NO evidence on a slow read was not a neutral degradation: it
|
|
40308
|
+
* changed WHICH SLOT the picker chose. Measured on 615 within forty seconds,
|
|
40309
|
+
* same camera, same negotiated 1280x720:
|
|
40310
|
+
*
|
|
40311
|
+
* 10:22:11 mid 10 fps
|
|
40312
|
+
* 10:22:17 low 24 fps
|
|
40313
|
+
* 10:22:26 mid 10 fps
|
|
40314
|
+
* 10:22:50 low 24 fps
|
|
40315
|
+
*
|
|
40316
|
+
* With evidence, `low` classifies as a fit and wins; without it every slot is
|
|
40317
|
+
* `unknown` and the fallback takes `mid`. So the stream a controller received
|
|
40318
|
+
* depended on whether a cap read beat a 1500 ms timer — a coin flip, and one
|
|
40319
|
+
* that hands iOS a different profile on each retry.
|
|
40320
|
+
*
|
|
40321
|
+
* A rate is a property of the camera's encoder configuration, which changes
|
|
40322
|
+
* when an operator changes it and not otherwise. Yesterday's reading is a far
|
|
40323
|
+
* better answer than no reading, and the ONE case that must still see fresh
|
|
40324
|
+
* numbers — the operator lowering a substream — is a deliberate act followed
|
|
40325
|
+
* by a new session, by which time the background read has long landed.
|
|
40326
|
+
*/
|
|
40327
|
+
var lastGoodEvidence = /* @__PURE__ */ new Map();
|
|
40328
|
+
/**
|
|
40329
|
+
* Resolve every profile slot's rate. Never throws and never outlives its
|
|
40330
|
+
* budget: a slow read falls back to this device's last good reading, and only
|
|
40331
|
+
* a device that has never answered at all ends up `unknown`.
|
|
39503
40332
|
*/
|
|
39504
40333
|
async function probeProfileBitrates(input) {
|
|
39505
|
-
const
|
|
39506
|
-
const
|
|
39507
|
-
|
|
39508
|
-
const choices = await probe$1(() => proxy.webrtcSession?.listStreams({}), "webrtcSession.listStreams", input.log);
|
|
40334
|
+
const deviceId = input.bctx.numericDeviceId;
|
|
40335
|
+
const evidence = await gatherRateEvidence(input.bctx.proxy, input.log, lastGoodEvidence.get(deviceId));
|
|
40336
|
+
if (evidence.camStreams !== null || evidence.streamParams !== null) lastGoodEvidence.set(deviceId, evidence);
|
|
39509
40337
|
return resolveProfileBitrates({
|
|
39510
40338
|
slots: input.slots,
|
|
39511
|
-
camStreams: camStreams ?? [],
|
|
40339
|
+
camStreams: evidence.camStreams ?? [],
|
|
40340
|
+
streamParams: evidence.streamParams,
|
|
40341
|
+
choices: evidence.choices ?? []
|
|
40342
|
+
});
|
|
40343
|
+
}
|
|
40344
|
+
/**
|
|
40345
|
+
* Issue the three reads CONCURRENTLY under one budget.
|
|
40346
|
+
*
|
|
40347
|
+
* Exported so the concurrency and the budget can be asserted directly: run in
|
|
40348
|
+
* sequence these latencies add, and adding them is what cost a session.
|
|
40349
|
+
*/
|
|
40350
|
+
async function gatherRateEvidence(proxy, log, lastGood) {
|
|
40351
|
+
const startedAt = Date.now();
|
|
40352
|
+
const evidence = await withDeadline(Promise.all([
|
|
40353
|
+
probe$1(() => proxy.cameraStreams?.getCameraStreams({}), "cameraStreams.getCameraStreams", log),
|
|
40354
|
+
probe$1(() => proxy.streamParams?.getStatus({}), "streamParams.getStatus", log),
|
|
40355
|
+
probe$1(() => proxy.webrtcSession?.listStreams({}), "webrtcSession.listStreams", log)
|
|
40356
|
+
]).then(([camStreams, streamParams, choices]) => ({
|
|
40357
|
+
camStreams,
|
|
39512
40358
|
streamParams,
|
|
39513
|
-
choices
|
|
40359
|
+
choices
|
|
40360
|
+
})), BITRATE_EVIDENCE_BUDGET_MS, lastGood ?? NO_EVIDENCE, () => {
|
|
40361
|
+
log.warn("export-hap: rate evidence ABANDONED on its budget", { meta: {
|
|
40362
|
+
budgetMs: BITRATE_EVIDENCE_BUDGET_MS,
|
|
40363
|
+
fellBackTo: lastGood === void 0 ? "no-evidence" : "last-good",
|
|
40364
|
+
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"
|
|
40365
|
+
} });
|
|
39514
40366
|
});
|
|
40367
|
+
const elapsedMs = Date.now() - startedAt;
|
|
40368
|
+
if (elapsedMs > 1500 / 2) log.info("export-hap: rate evidence was slow", { meta: {
|
|
40369
|
+
elapsedMs,
|
|
40370
|
+
budgetMs: BITRATE_EVIDENCE_BUDGET_MS
|
|
40371
|
+
} });
|
|
40372
|
+
return evidence;
|
|
39515
40373
|
}
|
|
39516
40374
|
async function probe$1(call, label, log) {
|
|
39517
40375
|
try {
|
|
@@ -39529,61 +40387,138 @@ async function probe$1(call, label, log) {
|
|
|
39529
40387
|
return null;
|
|
39530
40388
|
}
|
|
39531
40389
|
}
|
|
40390
|
+
//#endregion
|
|
40391
|
+
//#region src/mappers/builders/stream-ffmpeg-args.ts
|
|
40392
|
+
/**
|
|
40393
|
+
* The ffmpeg PLAN for one HomeKit streaming session.
|
|
40394
|
+
*
|
|
40395
|
+
* This file used to assemble the argument vector by hand. It no longer emits a
|
|
40396
|
+
* single argument: it describes the session as an {@link FfmpegInvocation} and
|
|
40397
|
+
* `buildFfmpegArgs` (`@camstack/types` `ffmpeg/invocation.ts`) emits every one.
|
|
40398
|
+
* The repo keeps exactly ONE argv builder — `scripts/check-ffmpeg-primitive.ts`
|
|
40399
|
+
* Rule 1 refuses a second, and HomeKit was the last exception (D67).
|
|
40400
|
+
*
|
|
40401
|
+
* ## What moved behind the primitive, and what stayed here
|
|
40402
|
+
*
|
|
40403
|
+
* MOVED — everything that describes an ENCODE, because it is the same job every
|
|
40404
|
+
* other live egress does and the repo had five disagreeing copies of it: the
|
|
40405
|
+
* encoder, preset, tune, profile, level, pixel format, rate, GOP, the tight VBV
|
|
40406
|
+
* window ({@link RATE_CONTROL_TIGHT}), the bitstream filter, and the Opus block
|
|
40407
|
+
* ({@link HAP_AUDIO_BASE}).
|
|
40408
|
+
*
|
|
40409
|
+
* STAYED — everything that is a HAP PROTOCOL fact and belongs to no other
|
|
40410
|
+
* consumer: the payload types, the SSRCs (and their signed-int32 coercion), the
|
|
40411
|
+
* MTU baked into each `rtp://…?pkt_size=` target, the loopback ports the
|
|
40412
|
+
* JS-side SRTP encrypt reads from, and the negotiated audio sample rate and
|
|
40413
|
+
* packet time.
|
|
40414
|
+
*
|
|
40415
|
+
* ## The two flags this file exists to protect
|
|
40416
|
+
*
|
|
40417
|
+
* `-g` and `-bsf:v dump_extra` were two of the four causes of the year-long
|
|
40418
|
+
* failure, and both live in the encode plan now. They are asserted by token
|
|
40419
|
+
* AND by position in `__tests__/stream-ffmpeg-argv.spec.ts`, on both the copy
|
|
40420
|
+
* and the encode branch, so the move behind the primitive cannot quietly drop
|
|
40421
|
+
* either. Every other comment below records something learned the expensive
|
|
40422
|
+
* way; deleting one loses the reason a flag is there.
|
|
40423
|
+
*/
|
|
40424
|
+
/**
|
|
40425
|
+
* Opus encoder targets — kept low because:
|
|
40426
|
+
* - Camera audio is overwhelmingly speech / ambient noise; 24 kbps mono
|
|
40427
|
+
* is the published "fullband speech" sweet spot for libopus (well
|
|
40428
|
+
* above the 20 kbps "wideband speech" floor).
|
|
40429
|
+
* - HAP audio is one-shot live (no buffering on the controller side),
|
|
40430
|
+
* so under-shooting the bitrate is cheaper than over-shooting it and
|
|
40431
|
+
* hitting jitter.
|
|
40432
|
+
* - Mono / low-delay profile matches Apple Home's published Opus decoder
|
|
40433
|
+
* expectations for camera accessories.
|
|
40434
|
+
*
|
|
40435
|
+
* The numbers themselves live in `@camstack/types` `ffmpeg/encode-defaults.ts`
|
|
40436
|
+
* now, alongside every other live-egress constant, so the five sets that used
|
|
40437
|
+
* to disagree about Opus channel count can be diffed in one place. Re-exported
|
|
40438
|
+
* here because the session telemetry reports the bitrate it dialled.
|
|
40439
|
+
*/
|
|
40440
|
+
var OPUS_BITRATE_KBPS = 24;
|
|
40441
|
+
/**
|
|
40442
|
+
* The Opus plane, per session.
|
|
40443
|
+
*
|
|
40444
|
+
* Re-encoded regardless of source codec: the source pool is a mix of
|
|
40445
|
+
* PCM_MULAW, PCM_ALAW, G.711 and AAC depending on driver, and Apple Home
|
|
40446
|
+
* expects Opus on the wire.
|
|
40447
|
+
*
|
|
40448
|
+
* `sampleRateHz` and `frameDurationMs` are NEGOTIATED — the controller picks
|
|
40449
|
+
* them — which is why the shared {@link HAP_AUDIO_BASE} leaves both out and
|
|
40450
|
+
* they are filled in here.
|
|
40451
|
+
*
|
|
40452
|
+
* CRITICAL on the sample rate: encode at the rate iOS asked for, never a
|
|
40453
|
+
* constant. iOS's `AudioStreamingSamplerate` enum surfaces as 8 / 16 / 24 kHz;
|
|
40454
|
+
* encoding at 24 when iOS asked for 16 produces RTP timestamps stepping by 480
|
|
40455
|
+
* samples/packet against a clock expecting 320 — the SRTP frames decrypt
|
|
40456
|
+
* cleanly but the speaker stays mute, because the timestamps slide out of the
|
|
40457
|
+
* AV-sync window before the first Opus frame renders. The same request value
|
|
40458
|
+
* drives `audioIntervalScale` in the re-stamping pass, so the two MUST come
|
|
40459
|
+
* from one source.
|
|
40460
|
+
*
|
|
40461
|
+
* On the frame duration: libopus emits exactly one RTP packet per Opus frame at
|
|
40462
|
+
* that duration, and matching HAP's `packet_time` (20 ms on LAN, 30/40/60 on
|
|
40463
|
+
* LTE) is what keeps the 1:1 frame↔packet mapping the controller expects.
|
|
40464
|
+
*/
|
|
40465
|
+
function audioPlan(input) {
|
|
40466
|
+
return {
|
|
40467
|
+
...HAP_AUDIO_BASE,
|
|
40468
|
+
sampleRateHz: input.audioSampleRateKhz * 1e3,
|
|
40469
|
+
frameDurationMs: input.audioPacketTimeMs,
|
|
40470
|
+
vbvBufferKbits: 96
|
|
40471
|
+
};
|
|
40472
|
+
}
|
|
39532
40473
|
/**
|
|
39533
40474
|
* Two outputs from one input: video SRTP and audio SRTP, one process, one
|
|
39534
|
-
* lifetime, one kill signal.
|
|
39535
|
-
*
|
|
40475
|
+
* lifetime, one kill signal. The shared builder's `rtp-outputs` sink maps each
|
|
40476
|
+
* plane explicitly (`-an -map 0:v:0` / `-vn -map 0:a:0?`) so ffmpeg never
|
|
40477
|
+
* guesses which stream belongs where, and `0:a:0?` makes the audio optional so
|
|
40478
|
+
* a source with no microphone skips it instead of failing the invocation.
|
|
39536
40479
|
*/
|
|
39537
|
-
|
|
39538
|
-
|
|
39539
|
-
|
|
39540
|
-
|
|
39541
|
-
|
|
39542
|
-
|
|
39543
|
-
|
|
39544
|
-
|
|
39545
|
-
|
|
39546
|
-
|
|
39547
|
-
|
|
39548
|
-
|
|
39549
|
-
|
|
39550
|
-
|
|
39551
|
-
|
|
39552
|
-
|
|
39553
|
-
|
|
39554
|
-
|
|
39555
|
-
|
|
39556
|
-
|
|
39557
|
-
|
|
39558
|
-
|
|
39559
|
-
|
|
39560
|
-
|
|
39561
|
-
|
|
39562
|
-
|
|
39563
|
-
|
|
39564
|
-
|
|
39565
|
-
|
|
39566
|
-
|
|
39567
|
-
|
|
39568
|
-
|
|
39569
|
-
|
|
39570
|
-
|
|
39571
|
-
|
|
39572
|
-
|
|
39573
|
-
|
|
39574
|
-
|
|
39575
|
-
|
|
39576
|
-
|
|
39577
|
-
|
|
39578
|
-
|
|
39579
|
-
"-payload_type",
|
|
39580
|
-
String(input.audioPayloadType),
|
|
39581
|
-
"-ssrc",
|
|
39582
|
-
String(input.audioSsrcSigned),
|
|
39583
|
-
"-f",
|
|
39584
|
-
"rtp",
|
|
39585
|
-
input.audioTarget
|
|
39586
|
-
];
|
|
40480
|
+
/**
|
|
40481
|
+
* How long ffmpeg may inspect the broker's restream before emitting.
|
|
40482
|
+
*
|
|
40483
|
+
* Not zero. A zero-length probe makes ffmpeg trust the SDP completely, and an
|
|
40484
|
+
* RTSP source that announces a track it then never sends would leave the
|
|
40485
|
+
* mapping wrong with no way to notice. 200 ms and 64 KB is far below the
|
|
40486
|
+
* shortest key-frame interval on this fleet while still letting the demuxer
|
|
40487
|
+
* see real packets — enough to be honest, short enough that nobody watches it.
|
|
40488
|
+
*/
|
|
40489
|
+
var HAP_INPUT_PROBE = {
|
|
40490
|
+
analyzeDurationUs: 2e5,
|
|
40491
|
+
probeSizeBytes: 64 * 1024
|
|
40492
|
+
};
|
|
40493
|
+
function buildSessionInvocation(input) {
|
|
40494
|
+
return {
|
|
40495
|
+
logLevel: "warning",
|
|
40496
|
+
decodeHwAccel: input.decode.hwaccel,
|
|
40497
|
+
input: {
|
|
40498
|
+
url: input.rtspUrl,
|
|
40499
|
+
rtspTransport: "tcp",
|
|
40500
|
+
analyzeDurationUs: HAP_INPUT_PROBE.analyzeDurationUs,
|
|
40501
|
+
probeSizeBytes: HAP_INPUT_PROBE.probeSizeBytes,
|
|
40502
|
+
...input.decode.extraInputArgs.length > 0 ? { extraArgs: input.decode.extraInputArgs } : {}
|
|
40503
|
+
},
|
|
40504
|
+
video: input.video,
|
|
40505
|
+
audio: audioPlan(input),
|
|
40506
|
+
threadCount: 0,
|
|
40507
|
+
outputArgs: [],
|
|
40508
|
+
sink: {
|
|
40509
|
+
kind: "rtp-outputs",
|
|
40510
|
+
video: {
|
|
40511
|
+
url: input.videoTarget,
|
|
40512
|
+
payloadType: input.videoPayloadType,
|
|
40513
|
+
ssrc: input.videoSsrcSigned
|
|
40514
|
+
},
|
|
40515
|
+
audio: {
|
|
40516
|
+
url: input.audioTarget,
|
|
40517
|
+
payloadType: input.audioPayloadType,
|
|
40518
|
+
ssrc: input.audioSsrcSigned
|
|
40519
|
+
}
|
|
40520
|
+
}
|
|
40521
|
+
};
|
|
39587
40522
|
}
|
|
39588
40523
|
/**
|
|
39589
40524
|
* The resolutions we offer, before rates are attached. Same list the delegate
|
|
@@ -39723,6 +40658,84 @@ var CAM_PROFILES = [
|
|
|
39723
40658
|
function toCamProfile(profileId) {
|
|
39724
40659
|
return CAM_PROFILES.find((p) => p === profileId) ?? null;
|
|
39725
40660
|
}
|
|
40661
|
+
//#endregion
|
|
40662
|
+
//#region src/mappers/builders/h264-idr.ts
|
|
40663
|
+
/**
|
|
40664
|
+
* Does this RTP packet carry the start of an H.264 IDR?
|
|
40665
|
+
*
|
|
40666
|
+
* A pass-through session cannot manufacture a key frame on demand — it can
|
|
40667
|
+
* only forward the one the camera decides to emit. So the number that decides
|
|
40668
|
+
* whether a controller sees a picture or a loader is *how long it waited for
|
|
40669
|
+
* the first IDR*, and until now nothing measured it: a session could report
|
|
40670
|
+
* a thousand packets forwarded, zero loss, and a blank screen, with no field
|
|
40671
|
+
* distinguishing "the stream is broken" from "the next key frame is 20
|
|
40672
|
+
* seconds away".
|
|
40673
|
+
*
|
|
40674
|
+
* That is the whole reason this exists, so it is deliberately narrow: a
|
|
40675
|
+
* boolean per packet, no state, no allocation, and it never throws. It runs on
|
|
40676
|
+
* every forwarded video packet, and a parser that throws on a malformed packet
|
|
40677
|
+
* would take the media path down with it.
|
|
40678
|
+
*/
|
|
40679
|
+
/** NAL unit type carrying a coded slice of an IDR picture (RFC 6184 §5.2). */
|
|
40680
|
+
var NAL_TYPE_IDR = 5;
|
|
40681
|
+
/** Single-time aggregation packet — several NALs in one RTP payload. */
|
|
40682
|
+
var NAL_TYPE_STAP_A = 24;
|
|
40683
|
+
/** Fragmentation units: one NAL spread over several RTP payloads. */
|
|
40684
|
+
var NAL_TYPE_FU_A = 28;
|
|
40685
|
+
var NAL_TYPE_FU_B = 29;
|
|
40686
|
+
var RTP_MIN_HEADER_BYTES = 12;
|
|
40687
|
+
var NAL_TYPE_MASK = 31;
|
|
40688
|
+
/** FU header start bit — set only on the FIRST fragment of a fragmented NAL. */
|
|
40689
|
+
var FU_START_BIT = 128;
|
|
40690
|
+
function rtpPacketCarriesIdr(packet) {
|
|
40691
|
+
const payloadStart = rtpPayloadOffset(packet);
|
|
40692
|
+
if (payloadStart === null) return false;
|
|
40693
|
+
const firstPayloadByte = packet[payloadStart];
|
|
40694
|
+
if (firstPayloadByte === void 0) return false;
|
|
40695
|
+
const nalType = firstPayloadByte & NAL_TYPE_MASK;
|
|
40696
|
+
if (nalType === NAL_TYPE_FU_A || nalType === NAL_TYPE_FU_B) {
|
|
40697
|
+
const fuHeader = packet[payloadStart + 1];
|
|
40698
|
+
if (fuHeader === void 0) return false;
|
|
40699
|
+
if ((fuHeader & FU_START_BIT) === 0) return false;
|
|
40700
|
+
return (fuHeader & NAL_TYPE_MASK) === NAL_TYPE_IDR;
|
|
40701
|
+
}
|
|
40702
|
+
if (nalType === NAL_TYPE_STAP_A) return stapContainsIdr(packet, payloadStart + 1);
|
|
40703
|
+
return nalType === NAL_TYPE_IDR;
|
|
40704
|
+
}
|
|
40705
|
+
/**
|
|
40706
|
+
* Byte offset of the RTP payload, or `null` when the packet is too short to
|
|
40707
|
+
* hold one. The variable-length parts are what make this worth a function:
|
|
40708
|
+
* a fixed offset of 12 is right for every packet ffmpeg emits today and wrong
|
|
40709
|
+
* the moment one carries a CSRC list or a header extension.
|
|
40710
|
+
*/
|
|
40711
|
+
function rtpPayloadOffset(packet) {
|
|
40712
|
+
if (packet.length <= RTP_MIN_HEADER_BYTES) return null;
|
|
40713
|
+
const flags = packet[0];
|
|
40714
|
+
if (flags === void 0) return null;
|
|
40715
|
+
const csrcCount = flags & 15;
|
|
40716
|
+
const hasExtension = (flags & 16) !== 0;
|
|
40717
|
+
let offset = RTP_MIN_HEADER_BYTES + csrcCount * 4;
|
|
40718
|
+
if (hasExtension) {
|
|
40719
|
+
if (offset + 4 > packet.length) return null;
|
|
40720
|
+
const words = packet.readUInt16BE(offset + 2);
|
|
40721
|
+
offset += 4 + words * 4;
|
|
40722
|
+
}
|
|
40723
|
+
return offset < packet.length ? offset : null;
|
|
40724
|
+
}
|
|
40725
|
+
/** Walk a STAP-A's `[size][nal]` pairs looking for an IDR. */
|
|
40726
|
+
function stapContainsIdr(packet, start) {
|
|
40727
|
+
let offset = start;
|
|
40728
|
+
while (offset + 2 <= packet.length) {
|
|
40729
|
+
const size = packet.readUInt16BE(offset);
|
|
40730
|
+
offset += 2;
|
|
40731
|
+
if (size === 0 || offset + size > packet.length) return false;
|
|
40732
|
+
const nalHeader = packet[offset];
|
|
40733
|
+
if (nalHeader === void 0) return false;
|
|
40734
|
+
if ((nalHeader & NAL_TYPE_MASK) === NAL_TYPE_IDR) return true;
|
|
40735
|
+
offset += size;
|
|
40736
|
+
}
|
|
40737
|
+
return false;
|
|
40738
|
+
}
|
|
39726
40739
|
/**
|
|
39727
40740
|
* How long after spawn an exit still counts as "hardware init failed".
|
|
39728
40741
|
*
|
|
@@ -39787,34 +40800,65 @@ function software(reason) {
|
|
|
39787
40800
|
return {
|
|
39788
40801
|
kind: "software",
|
|
39789
40802
|
reason,
|
|
40803
|
+
hwaccel: null,
|
|
40804
|
+
extraInputArgs: [],
|
|
39790
40805
|
args: []
|
|
39791
40806
|
};
|
|
39792
40807
|
}
|
|
39793
40808
|
function hardware(backend, source, input) {
|
|
40809
|
+
if (input.recentlyFailedBackend === backend) return software("hardware-attempt-failed");
|
|
40810
|
+
const { hwaccel, extraInputArgs } = decodePlan(backend, input);
|
|
39794
40811
|
return {
|
|
39795
40812
|
kind: "hardware",
|
|
39796
40813
|
backend,
|
|
39797
40814
|
source,
|
|
39798
|
-
|
|
40815
|
+
hwaccel,
|
|
40816
|
+
extraInputArgs,
|
|
40817
|
+
args: [
|
|
40818
|
+
"-hwaccel",
|
|
40819
|
+
hwaccel,
|
|
40820
|
+
...extraInputArgs
|
|
40821
|
+
]
|
|
39799
40822
|
};
|
|
39800
40823
|
}
|
|
39801
40824
|
/**
|
|
39802
|
-
* The input-side
|
|
40825
|
+
* The input-side decode configuration, and nothing else.
|
|
39803
40826
|
*
|
|
39804
40827
|
* No `-hwaccel_output_format`: the decoded frames have to land in system
|
|
39805
40828
|
* memory for libx264 to scale and encode them. Setting it would keep them on
|
|
39806
40829
|
* the GPU, which only pays off with a GPU scale filter — and that is the
|
|
39807
40830
|
* decoder addon's job, not a two-output SRTP session's.
|
|
40831
|
+
*
|
|
40832
|
+
* The two halves are returned SEPARATELY because the shared argv builder emits
|
|
40833
|
+
* `-hwaccel` itself (it is the only function allowed to, so the flag cannot
|
|
40834
|
+
* drift past `-i`) and takes everything else as the input plan's `extraArgs`.
|
|
39808
40835
|
*/
|
|
39809
|
-
function
|
|
39810
|
-
if (backend === "videotoolbox" && input.platform === "darwin") return
|
|
39811
|
-
|
|
39812
|
-
|
|
39813
|
-
|
|
40836
|
+
function decodePlan(backend, input) {
|
|
40837
|
+
if (backend === "videotoolbox" && input.platform === "darwin") return {
|
|
40838
|
+
hwaccel: "auto",
|
|
40839
|
+
extraInputArgs: []
|
|
40840
|
+
};
|
|
40841
|
+
return {
|
|
40842
|
+
hwaccel: backend,
|
|
40843
|
+
extraInputArgs: RENDER_NODE_BACKENDS.includes(backend) ? ["-hwaccel_device", input.renderDevice ?? "/dev/dri/renderD128"] : []
|
|
40844
|
+
};
|
|
39814
40845
|
}
|
|
39815
40846
|
//#endregion
|
|
39816
40847
|
//#region src/mappers/builders/stream-hwaccel-probe.ts
|
|
39817
40848
|
/**
|
|
40849
|
+
* The real read: `decoder.getInfo`, pinned to the LOCAL node.
|
|
40850
|
+
*
|
|
40851
|
+
* Pinned explicitly rather than left to routing, because an unpinned singleton
|
|
40852
|
+
* cap answers from whichever node owns it and would report the WRONG host's
|
|
40853
|
+
* hardware.
|
|
40854
|
+
*/
|
|
40855
|
+
function decoderInfoSourceFromContext(ctx) {
|
|
40856
|
+
return {
|
|
40857
|
+
localNodeId: ctx.kernel?.localNodeId,
|
|
40858
|
+
readInfo: (nodeId) => ctx.api.decoder.getInfo.query(void 0, nodePin(nodeId))
|
|
40859
|
+
};
|
|
40860
|
+
}
|
|
40861
|
+
/**
|
|
39818
40862
|
* Read this node's decode-hwaccel state, or `null` when nothing answered.
|
|
39819
40863
|
*
|
|
39820
40864
|
* Never throws. `null` means "we do not know", which
|
|
@@ -39822,27 +40866,144 @@ function decodeArgs(backend, input) {
|
|
|
39822
40866
|
* the safe direction, because a guess here costs the whole stream.
|
|
39823
40867
|
*/
|
|
39824
40868
|
async function probeDecoderHwaccel(input) {
|
|
39825
|
-
const {
|
|
39826
|
-
const
|
|
40869
|
+
const { source, log, memo } = input;
|
|
40870
|
+
const memoised = memo.read();
|
|
40871
|
+
if (memoised !== void 0) return memoised;
|
|
40872
|
+
const nodeId = source.localNodeId;
|
|
39827
40873
|
if (nodeId === void 0 || nodeId.length === 0) {
|
|
39828
40874
|
log.warn("export-hap: hwaccel probe skipped — no local node id, decoding in SOFTWARE");
|
|
39829
40875
|
return null;
|
|
39830
40876
|
}
|
|
39831
40877
|
try {
|
|
39832
|
-
const info = await
|
|
40878
|
+
const info = await source.readInfo(nodeId);
|
|
39833
40879
|
if (info === null || info === void 0) {
|
|
39834
40880
|
log.info("export-hap: hwaccel probe returned nothing — decoding in SOFTWARE", { meta: { nodeId } });
|
|
40881
|
+
memo.write(null);
|
|
39835
40882
|
return null;
|
|
39836
40883
|
}
|
|
39837
|
-
|
|
40884
|
+
const reading = {
|
|
39838
40885
|
hwaccel: info.hwaccel ?? null,
|
|
39839
40886
|
probedBestHwaccel: info.probedBestHwaccel ?? null
|
|
39840
40887
|
};
|
|
40888
|
+
memo.write(reading);
|
|
40889
|
+
return reading;
|
|
39841
40890
|
} catch (err) {
|
|
39842
40891
|
log.warn("export-hap: hwaccel probe failed — decoding in SOFTWARE", { meta: {
|
|
39843
40892
|
nodeId,
|
|
39844
40893
|
error: err instanceof Error ? err.message : String(err)
|
|
39845
40894
|
} });
|
|
40895
|
+
memo.write(null);
|
|
40896
|
+
return null;
|
|
40897
|
+
}
|
|
40898
|
+
}
|
|
40899
|
+
/**
|
|
40900
|
+
* What we ask for on the VIDEO loopback socket.
|
|
40901
|
+
*
|
|
40902
|
+
* Generous on purpose: the cost is virtual address space the kernel only
|
|
40903
|
+
* commits as datagrams actually queue, and the failure it prevents is a black
|
|
40904
|
+
* tile. Sized well above {@link KEYFRAME_BURST_FLOOR_BYTES} so a slow drain
|
|
40905
|
+
* (the JS forwarder is on the same event loop as everything else this addon
|
|
40906
|
+
* does) still has headroom.
|
|
40907
|
+
*/
|
|
40908
|
+
var VIDEO_LOOPBACK_RCVBUF_BYTES = 8 * 1024 * 1024;
|
|
40909
|
+
/**
|
|
40910
|
+
* What we ask for on the AUDIO loopback socket.
|
|
40911
|
+
*
|
|
40912
|
+
* Audio never bursts — that is the control in this experiment, and it is why
|
|
40913
|
+
* the two legs get different numbers rather than one shared constant. If audio
|
|
40914
|
+
* ever starts dropping at the same buffer that carries video fine, the cause is
|
|
40915
|
+
* not burst size.
|
|
40916
|
+
*/
|
|
40917
|
+
var AUDIO_LOOPBACK_RCVBUF_BYTES = 1024 * 1024;
|
|
40918
|
+
function errMsg$9(err) {
|
|
40919
|
+
return err instanceof Error ? err.message : String(err);
|
|
40920
|
+
}
|
|
40921
|
+
/**
|
|
40922
|
+
* Set `SO_RCVBUF` and READ IT BACK.
|
|
40923
|
+
*
|
|
40924
|
+
* Never throws: a platform that refuses the option must cost the buffer, never
|
|
40925
|
+
* the session. The read-back is the point — a request the kernel clamped and a
|
|
40926
|
+
* request it honoured are indistinguishable at the call site.
|
|
40927
|
+
*/
|
|
40928
|
+
function applyReceiveBuffer(socket, requestedBytes) {
|
|
40929
|
+
let error = null;
|
|
40930
|
+
try {
|
|
40931
|
+
socket.setRecvBufferSize(requestedBytes);
|
|
40932
|
+
} catch (err) {
|
|
40933
|
+
error = errMsg$9(err);
|
|
40934
|
+
}
|
|
40935
|
+
let effectiveBytes = null;
|
|
40936
|
+
try {
|
|
40937
|
+
effectiveBytes = socket.getRecvBufferSize();
|
|
40938
|
+
} catch (err) {
|
|
40939
|
+
if (error === null) error = errMsg$9(err);
|
|
40940
|
+
}
|
|
40941
|
+
return {
|
|
40942
|
+
requestedBytes,
|
|
40943
|
+
effectiveBytes,
|
|
40944
|
+
clamped: effectiveBytes !== null && effectiveBytes < requestedBytes,
|
|
40945
|
+
sufficientForKeyframeBurst: effectiveBytes !== null && effectiveBytes >= 2097152,
|
|
40946
|
+
error
|
|
40947
|
+
};
|
|
40948
|
+
}
|
|
40949
|
+
/** Where the kernel publishes per-socket UDP counters, by address family. */
|
|
40950
|
+
var PROC_NET_UDP = {
|
|
40951
|
+
ipv4: "/proc/net/udp",
|
|
40952
|
+
ipv6: "/proc/net/udp6"
|
|
40953
|
+
};
|
|
40954
|
+
/**
|
|
40955
|
+
* The per-socket `drops` count for `port`, out of a `/proc/net/udp` table.
|
|
40956
|
+
*
|
|
40957
|
+
* Pure so the format assumption is pinned by a test rather than by a live
|
|
40958
|
+
* kernel. Returns `null` when the port has no row — which is NOT the same as
|
|
40959
|
+
* zero drops, and the two must never collapse: `0` is evidence the buffer held,
|
|
40960
|
+
* `null` is the absence of evidence.
|
|
40961
|
+
*/
|
|
40962
|
+
function parseUdpSocketDrops(table, port) {
|
|
40963
|
+
const lines = table.split("\n");
|
|
40964
|
+
for (const line of lines) {
|
|
40965
|
+
const fields = line.trim().split(/\s+/);
|
|
40966
|
+
if (fields.length < 13) continue;
|
|
40967
|
+
const local = fields[1];
|
|
40968
|
+
if (local === void 0) continue;
|
|
40969
|
+
const hexPort = local.split(":")[1];
|
|
40970
|
+
if (hexPort === void 0) continue;
|
|
40971
|
+
const parsedPort = Number.parseInt(hexPort, 16);
|
|
40972
|
+
if (!Number.isFinite(parsedPort) || parsedPort !== port) continue;
|
|
40973
|
+
const drops = Number(fields[fields.length - 1]);
|
|
40974
|
+
return Number.isFinite(drops) ? drops : null;
|
|
40975
|
+
}
|
|
40976
|
+
return null;
|
|
40977
|
+
}
|
|
40978
|
+
/**
|
|
40979
|
+
* Fold a fresh drop sample into the one already held.
|
|
40980
|
+
*
|
|
40981
|
+
* A socket that has been CLOSED disappears from `/proc/net/udp`, so a resample
|
|
40982
|
+
* after teardown returns `null` — "I can no longer look", which must never
|
|
40983
|
+
* erase "I looked and it was 0". The first live session that proved the buffer
|
|
40984
|
+
* fix reported `videoLoopKernelDrops=null` for exactly this reason: on a
|
|
40985
|
+
* controller `stop` the sockets are closed synchronously while the summary is
|
|
40986
|
+
* emitted later from ffmpeg's `exit` handler.
|
|
40987
|
+
*/
|
|
40988
|
+
function mergeDropSample(previous, sampled) {
|
|
40989
|
+
return sampled ?? previous;
|
|
40990
|
+
}
|
|
40991
|
+
/**
|
|
40992
|
+
* Read the kernel's drop counter for a bound local UDP port.
|
|
40993
|
+
*
|
|
40994
|
+
* Linux only — `null` on every other platform and on every read failure, which
|
|
40995
|
+
* is honest: "we could not look" and "nothing was dropped" are different
|
|
40996
|
+
* answers and this returns the first as `null`.
|
|
40997
|
+
*
|
|
40998
|
+
* Synchronous on purpose. It is called at the session heartbeat (5 s) and once
|
|
40999
|
+
* at teardown, against a memory-backed pseudo-file; making it async would mean
|
|
41000
|
+
* the SUMMARY line — the one line this experiment is read from — could not
|
|
41001
|
+
* carry a fresh count, which is the only reason it exists.
|
|
41002
|
+
*/
|
|
41003
|
+
function readUdpSocketDrops(port, ipVersion) {
|
|
41004
|
+
try {
|
|
41005
|
+
return parseUdpSocketDrops((0, node_fs.readFileSync)(PROC_NET_UDP[ipVersion], "utf8"), port);
|
|
41006
|
+
} catch {
|
|
39846
41007
|
return null;
|
|
39847
41008
|
}
|
|
39848
41009
|
}
|
|
@@ -39971,6 +41132,9 @@ function summariseSession(snapshot) {
|
|
|
39971
41132
|
encodeBudgetKbps: slot?.budgetKbps ?? null,
|
|
39972
41133
|
fitNotes: slot?.fitNotes ?? [],
|
|
39973
41134
|
videoPacketsForwarded: snapshot.videoPacketsForwarded,
|
|
41135
|
+
msToFirstKeyframe: snapshot.firstKeyframeAtMs === null || snapshot.startedAtMs === null ? null : snapshot.firstKeyframeAtMs - snapshot.startedAtMs,
|
|
41136
|
+
videoKeyframes: snapshot.videoKeyframes,
|
|
41137
|
+
maxKeyframeGapMs: snapshot.maxKeyframeGapMs,
|
|
39974
41138
|
audioPacketsForwarded: snapshot.audioPacketsForwarded,
|
|
39975
41139
|
videoRtcpSrSent: snapshot.videoRtcpSrSent,
|
|
39976
41140
|
audioRtcpSrSent: snapshot.audioRtcpSrSent,
|
|
@@ -39985,11 +41149,21 @@ function summariseSession(snapshot) {
|
|
|
39985
41149
|
videoLossVerdict: lossVerdict(snapshot.videoReceiverReports),
|
|
39986
41150
|
audioLossVerdict: lossVerdict(snapshot.audioReceiverReports),
|
|
39987
41151
|
mediaStarved: snapshot.videoPacketsForwarded === 0,
|
|
41152
|
+
videoLoopRcvbufRequestedBytes: snapshot.videoLoopback.rcvbufRequestedBytes,
|
|
41153
|
+
videoLoopRcvbufBytes: snapshot.videoLoopback.rcvbufEffectiveBytes,
|
|
41154
|
+
videoLoopRcvbufClamped: snapshot.videoLoopback.rcvbufClamped,
|
|
41155
|
+
videoLoopKernelDrops: snapshot.videoLoopback.kernelDrops,
|
|
41156
|
+
videoLoopKernelDropped: (snapshot.videoLoopback.kernelDrops ?? 0) > 0,
|
|
41157
|
+
audioLoopRcvbufBytes: snapshot.audioLoopback.rcvbufEffectiveBytes,
|
|
41158
|
+
audioLoopKernelDrops: snapshot.audioLoopback.kernelDrops,
|
|
41159
|
+
audioLoopKernelDropped: (snapshot.audioLoopback.kernelDrops ?? 0) > 0,
|
|
39988
41160
|
drops: nonZeroDrops(snapshot.drops)
|
|
39989
41161
|
};
|
|
39990
41162
|
}
|
|
39991
41163
|
//#endregion
|
|
39992
41164
|
//#region src/mappers/builders/camera-streams.ts
|
|
41165
|
+
/** A decoder that is slow to describe itself costs hardware decode, not the session. */
|
|
41166
|
+
var HWACCEL_PROBE_BUDGET_MS = 1e3;
|
|
39993
41167
|
var SRTP_KEY_LEN = 16;
|
|
39994
41168
|
var SRTP_SALT_LEN = 14;
|
|
39995
41169
|
/**
|
|
@@ -40073,6 +41247,7 @@ function buildCameraStreamingDelegate(bctx, advertised) {
|
|
|
40073
41247
|
const hadFfmpeg = session.ffmpeg !== null;
|
|
40074
41248
|
killFfmpeg(session, ctx, numericDeviceId);
|
|
40075
41249
|
stopHeartbeat(session);
|
|
41250
|
+
sampleLoopbackDrops(session);
|
|
40076
41251
|
if (!hadFfmpeg) logSessionSummary(session, log, "accessory-dispose-no-ffmpeg");
|
|
40077
41252
|
await closeIntercomTalkSession(session, bctx).catch(() => void 0);
|
|
40078
41253
|
closeSocket(session.videoUdp);
|
|
@@ -40101,8 +41276,10 @@ async function prepareStream(request, sessions, bctx) {
|
|
|
40101
41276
|
const localIp = pickLocalInterfaceIp(request.targetAddress, ipVersion);
|
|
40102
41277
|
const videoUdp = await bindUdp(ipVersion, localIp);
|
|
40103
41278
|
const audioUdp = await bindUdp(ipVersion, localIp);
|
|
40104
|
-
const
|
|
40105
|
-
const
|
|
41279
|
+
const videoLoop = await bindLoopback(ipVersion, VIDEO_LOOPBACK_RCVBUF_BYTES);
|
|
41280
|
+
const audioLoop = await bindLoopback(ipVersion, AUDIO_LOOPBACK_RCVBUF_BYTES);
|
|
41281
|
+
const videoLoopUdp = videoLoop.socket;
|
|
41282
|
+
const audioLoopUdp = audioLoop.socket;
|
|
40106
41283
|
const localVideoPort = videoUdp.address().port;
|
|
40107
41284
|
const localAudioPort = audioUdp.address().port;
|
|
40108
41285
|
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) {
|
|
@@ -40178,6 +41355,10 @@ async function prepareStream(request, sessions, bctx) {
|
|
|
40178
41355
|
drops: emptyDropCounters(),
|
|
40179
41356
|
videoPacketsForwarded: 0,
|
|
40180
41357
|
audioPacketsForwarded: 0,
|
|
41358
|
+
videoKeyframes: 0,
|
|
41359
|
+
firstKeyframeAtMs: null,
|
|
41360
|
+
lastKeyframeAtMs: null,
|
|
41361
|
+
maxKeyframeGapMs: 0,
|
|
40181
41362
|
videoRtcpSrSent: 0,
|
|
40182
41363
|
audioRtcpSrSent: 0,
|
|
40183
41364
|
videoRtcpReceived: 0,
|
|
@@ -40217,6 +41398,12 @@ async function prepareStream(request, sessions, bctx) {
|
|
|
40217
41398
|
audioInSrtcp,
|
|
40218
41399
|
audioSendGate: null,
|
|
40219
41400
|
ipVersion,
|
|
41401
|
+
videoLoopRcvbuf: videoLoop.buffer,
|
|
41402
|
+
audioLoopRcvbuf: audioLoop.buffer,
|
|
41403
|
+
videoLoopPort: videoLoopUdp.address().port,
|
|
41404
|
+
audioLoopPort: audioLoopUdp.address().port,
|
|
41405
|
+
videoLoopKernelDrops: null,
|
|
41406
|
+
audioLoopKernelDrops: null,
|
|
40220
41407
|
ffmpeg: null,
|
|
40221
41408
|
lastStartParams: null,
|
|
40222
41409
|
upstreamAudioSrtp,
|
|
@@ -40256,6 +41443,7 @@ async function prepareStream(request, sessions, bctx) {
|
|
|
40256
41443
|
});
|
|
40257
41444
|
videoLoopUdp.on("message", (rtpPacket) => {
|
|
40258
41445
|
session.videoPacketsForwarded += 1;
|
|
41446
|
+
if (rtpPacketCarriesIdr(rtpPacket)) recordKeyframe(session);
|
|
40259
41447
|
if (session.videoPacketsForwarded === 1) tagLog.info("export-hap: first video packet from ffmpeg", { meta: {
|
|
40260
41448
|
sessionId: session.sessionId,
|
|
40261
41449
|
bytes: rtpPacket.length
|
|
@@ -40271,6 +41459,8 @@ async function prepareStream(request, sessions, bctx) {
|
|
|
40271
41459
|
bctx.ctx.logger.withTags({ deviceId: bctx.numericDeviceId }).debug("export-hap: incoming-audio handler error (dropped)", { meta: { error: errMsg$8(err) } });
|
|
40272
41460
|
});
|
|
40273
41461
|
});
|
|
41462
|
+
logLoopbackBuffer(tagLog, request.sessionID, "video", videoLoop.buffer);
|
|
41463
|
+
logLoopbackBuffer(tagLog, request.sessionID, "audio", audioLoop.buffer);
|
|
40274
41464
|
tagLog.info("export-hap: stream prepared", { meta: {
|
|
40275
41465
|
sessionId: request.sessionID,
|
|
40276
41466
|
controllerAddress: request.targetAddress,
|
|
@@ -40351,12 +41541,45 @@ function sameIpv4Subnet(a, mask, b) {
|
|
|
40351
41541
|
return true;
|
|
40352
41542
|
}
|
|
40353
41543
|
/**
|
|
41544
|
+
* Report one loopback socket's receive buffer.
|
|
41545
|
+
*
|
|
41546
|
+
* `warn` when the video leg cannot hold a 4K key-frame burst: that is the state
|
|
41547
|
+
* in which this addon silently drops most of a key frame and the tile stays
|
|
41548
|
+
* black, and it was invisible for the whole life of this code path.
|
|
41549
|
+
*/
|
|
41550
|
+
function logLoopbackBuffer(log, sessionId, leg, outcome) {
|
|
41551
|
+
const meta = {
|
|
41552
|
+
sessionId,
|
|
41553
|
+
leg,
|
|
41554
|
+
requestedBytes: outcome.requestedBytes,
|
|
41555
|
+
effectiveBytes: outcome.effectiveBytes,
|
|
41556
|
+
clamped: outcome.clamped,
|
|
41557
|
+
sufficientForKeyframeBurst: outcome.sufficientForKeyframeBurst,
|
|
41558
|
+
error: outcome.error
|
|
41559
|
+
};
|
|
41560
|
+
if (leg === "video" && !outcome.sufficientForKeyframeBurst) {
|
|
41561
|
+
log.warn("export-hap: loopback receive buffer is TOO SMALL for a key-frame burst — raise net.core.rmem_max on the host", { meta });
|
|
41562
|
+
return;
|
|
41563
|
+
}
|
|
41564
|
+
log.info("export-hap: loopback receive buffer", { meta });
|
|
41565
|
+
}
|
|
41566
|
+
/**
|
|
40354
41567
|
* Resolve once-per-session: the local IP we bind iOS-facing sockets to
|
|
40355
41568
|
* AND its mate on `127.0.0.1` for the ffmpeg loopback path. Both go
|
|
40356
41569
|
* through the bounded-wait `dgram.bind` pattern.
|
|
41570
|
+
*
|
|
41571
|
+
* `SO_RCVBUF` is set AFTER the bind and read back, never assumed. Until
|
|
41572
|
+
* 2026-08-07 nothing set it at all, so these sockets ran on
|
|
41573
|
+
* `net.core.rmem_default` (212 992 B on this hub) — about a fifth of one 4K
|
|
41574
|
+
* key frame, which arrives as ~750 datagrams in one burst. See
|
|
41575
|
+
* `stream-socket-buffer.ts` for the measurement.
|
|
40357
41576
|
*/
|
|
40358
|
-
async function bindLoopback(ipVersion) {
|
|
40359
|
-
|
|
41577
|
+
async function bindLoopback(ipVersion, requestedRcvbufBytes) {
|
|
41578
|
+
const socket = await bindUdp(ipVersion, ipVersion === "ipv6" ? "::1" : "127.0.0.1");
|
|
41579
|
+
return {
|
|
41580
|
+
socket,
|
|
41581
|
+
buffer: applyReceiveBuffer(socket, requestedRcvbufBytes)
|
|
41582
|
+
};
|
|
40360
41583
|
}
|
|
40361
41584
|
/** Book a named drop. Every silent `return` on the streaming path routes here. */
|
|
40362
41585
|
function drop(session, reason) {
|
|
@@ -40497,6 +41720,30 @@ function logReceiverReports(session, leg, reports, isFirst, log) {
|
|
|
40497
41720
|
if (!shouldLogReceiverReport(session, leg)) return;
|
|
40498
41721
|
log.info("export-hap: controller receiver report", { meta });
|
|
40499
41722
|
}
|
|
41723
|
+
/**
|
|
41724
|
+
* Record a forwarded key frame. Kept separate from the packet counter because
|
|
41725
|
+
* the interesting quantity is TIMING, not a tally: the first arrival dates the
|
|
41726
|
+
* moment the controller could begin decoding, and the widest gap says how long
|
|
41727
|
+
* a mid-GOP join can be expected to stare at a loader.
|
|
41728
|
+
*/
|
|
41729
|
+
function recordKeyframe(session) {
|
|
41730
|
+
const now = Date.now();
|
|
41731
|
+
session.videoKeyframes += 1;
|
|
41732
|
+
if (session.firstKeyframeAtMs === null) session.firstKeyframeAtMs = now;
|
|
41733
|
+
else if (session.lastKeyframeAtMs !== null) session.maxKeyframeGapMs = Math.max(session.maxKeyframeGapMs, now - session.lastKeyframeAtMs);
|
|
41734
|
+
session.lastKeyframeAtMs = now;
|
|
41735
|
+
}
|
|
41736
|
+
/**
|
|
41737
|
+
* Refresh the kernel's per-socket drop counters.
|
|
41738
|
+
*
|
|
41739
|
+
* Called immediately before every line that reports them, because a stale
|
|
41740
|
+
* sample on the summary would answer the experiment's central question with
|
|
41741
|
+
* data from five seconds earlier. Cheap: `/proc/net/udp` is memory-backed.
|
|
41742
|
+
*/
|
|
41743
|
+
function sampleLoopbackDrops(session) {
|
|
41744
|
+
session.videoLoopKernelDrops = mergeDropSample(session.videoLoopKernelDrops, readUdpSocketDrops(session.videoLoopPort, session.ipVersion));
|
|
41745
|
+
session.audioLoopKernelDrops = mergeDropSample(session.audioLoopKernelDrops, readUdpSocketDrops(session.audioLoopPort, session.ipVersion));
|
|
41746
|
+
}
|
|
40500
41747
|
/** Snapshot every counter into the summary meta. */
|
|
40501
41748
|
function sessionSummaryMeta(session) {
|
|
40502
41749
|
return summariseSession({
|
|
@@ -40507,6 +41754,9 @@ function sessionSummaryMeta(session) {
|
|
|
40507
41754
|
selectedSlot: session.selectedSlot,
|
|
40508
41755
|
videoPacketsForwarded: session.videoPacketsForwarded,
|
|
40509
41756
|
audioPacketsForwarded: session.audioPacketsForwarded,
|
|
41757
|
+
videoKeyframes: session.videoKeyframes,
|
|
41758
|
+
firstKeyframeAtMs: session.firstKeyframeAtMs,
|
|
41759
|
+
maxKeyframeGapMs: session.maxKeyframeGapMs,
|
|
40510
41760
|
videoRtcpSrSent: session.videoRtcpSrSent,
|
|
40511
41761
|
audioRtcpSrSent: session.audioRtcpSrSent,
|
|
40512
41762
|
videoRtcpReceived: session.videoRtcpReceived,
|
|
@@ -40519,7 +41769,19 @@ function sessionSummaryMeta(session) {
|
|
|
40519
41769
|
audioReceiverReports: session.audioReceiverReports,
|
|
40520
41770
|
drops: session.drops,
|
|
40521
41771
|
ffmpegExit: session.ffmpegExit,
|
|
40522
|
-
stopRequestedByController: session.stopRequestedByController
|
|
41772
|
+
stopRequestedByController: session.stopRequestedByController,
|
|
41773
|
+
videoLoopback: {
|
|
41774
|
+
rcvbufRequestedBytes: session.videoLoopRcvbuf.requestedBytes,
|
|
41775
|
+
rcvbufEffectiveBytes: session.videoLoopRcvbuf.effectiveBytes,
|
|
41776
|
+
rcvbufClamped: session.videoLoopRcvbuf.clamped,
|
|
41777
|
+
kernelDrops: session.videoLoopKernelDrops
|
|
41778
|
+
},
|
|
41779
|
+
audioLoopback: {
|
|
41780
|
+
rcvbufRequestedBytes: session.audioLoopRcvbuf.requestedBytes,
|
|
41781
|
+
rcvbufEffectiveBytes: session.audioLoopRcvbuf.effectiveBytes,
|
|
41782
|
+
rcvbufClamped: session.audioLoopRcvbuf.clamped,
|
|
41783
|
+
kernelDrops: session.audioLoopKernelDrops
|
|
41784
|
+
}
|
|
40523
41785
|
});
|
|
40524
41786
|
}
|
|
40525
41787
|
/**
|
|
@@ -40533,6 +41795,7 @@ function armHeartbeat(session, log) {
|
|
|
40533
41795
|
const timer = setInterval(() => {
|
|
40534
41796
|
const forwarded = session.videoPacketsForwarded - lastVideo;
|
|
40535
41797
|
lastVideo = session.videoPacketsForwarded;
|
|
41798
|
+
sampleLoopbackDrops(session);
|
|
40536
41799
|
log.info("export-hap: stream heartbeat", { meta: {
|
|
40537
41800
|
...sessionSummaryMeta(session),
|
|
40538
41801
|
videoPacketsSinceLastBeat: forwarded,
|
|
@@ -40571,6 +41834,7 @@ function stopHeartbeat(session) {
|
|
|
40571
41834
|
*/
|
|
40572
41835
|
function logSessionSummary(session, log, trigger) {
|
|
40573
41836
|
session.endedAtMs = Date.now();
|
|
41837
|
+
sampleLoopbackDrops(session);
|
|
40574
41838
|
log.info("export-hap: stream session summary", { meta: {
|
|
40575
41839
|
...sessionSummaryMeta(session),
|
|
40576
41840
|
trigger
|
|
@@ -40755,6 +42019,7 @@ async function handleStreamRequest(request, sessions, bctx, advertised) {
|
|
|
40755
42019
|
const hadFfmpeg = session.ffmpeg !== null;
|
|
40756
42020
|
killFfmpeg(session, bctx.ctx, bctx.numericDeviceId);
|
|
40757
42021
|
stopHeartbeat(session);
|
|
42022
|
+
sampleLoopbackDrops(session);
|
|
40758
42023
|
if (!hadFfmpeg) logSessionSummary(session, log, "controller-stop-no-ffmpeg");
|
|
40759
42024
|
await closeIntercomTalkSession(session, bctx).catch(() => void 0);
|
|
40760
42025
|
closeSocket(session.videoUdp);
|
|
@@ -40864,7 +42129,7 @@ async function handleStreamRequest(request, sessions, bctx, advertised) {
|
|
|
40864
42129
|
async function startFfmpegForSession(bctx, session, sessionId, video, advertised) {
|
|
40865
42130
|
const { ctx, proxy, numericDeviceId, options } = bctx;
|
|
40866
42131
|
const startLog = ctx.logger.withTags({ deviceId: numericDeviceId });
|
|
40867
|
-
const entries = await proxy.cameraStreams?.getProfileRtspEntries({}) ?? [];
|
|
42132
|
+
const [entries, brokerStreams] = await Promise.all([(async () => await proxy.cameraStreams?.getProfileRtspEntries({}) ?? [])(), (async () => await proxy.cameraStreams?.getBrokerStreams({}) ?? [])()]);
|
|
40868
42133
|
if (entries.length === 0) {
|
|
40869
42134
|
startLog.warn("export-hap: stream start DROPPED — device publishes no profile RTSP entries", { meta: {
|
|
40870
42135
|
sessionId,
|
|
@@ -40873,16 +42138,21 @@ async function startFfmpegForSession(bctx, session, sessionId, video, advertised
|
|
|
40873
42138
|
throw new Error(`export-hap: no profile RTSP entries for device ${numericDeviceId}`);
|
|
40874
42139
|
}
|
|
40875
42140
|
const pref = options.hapDeviceSettings.streamPreference;
|
|
40876
|
-
const brokerStreams = await proxy.cameraStreams?.getBrokerStreams({}) ?? [];
|
|
40877
42141
|
const bitrates = await probeProfileBitrates({
|
|
40878
42142
|
bctx,
|
|
40879
42143
|
slots: brokerStreams,
|
|
40880
42144
|
log: startLog
|
|
40881
42145
|
});
|
|
42146
|
+
const connection = classifyConnection({
|
|
42147
|
+
negotiatedWidth: video.width,
|
|
42148
|
+
audioPacketTimeMs: session.negotiated?.audioPacketTimeMs ?? 20,
|
|
42149
|
+
viaHomeHub: false
|
|
42150
|
+
});
|
|
40882
42151
|
const fit = selectStreamForBudget({
|
|
40883
42152
|
entries: withSlotCodecs(entries, brokerStreams),
|
|
40884
42153
|
deviceId: numericDeviceId,
|
|
40885
42154
|
pref,
|
|
42155
|
+
connection,
|
|
40886
42156
|
targetResolution: {
|
|
40887
42157
|
width: video.width,
|
|
40888
42158
|
height: video.height
|
|
@@ -40908,7 +42178,7 @@ async function startFfmpegForSession(bctx, session, sessionId, video, advertised
|
|
|
40908
42178
|
const resolvedFps = pickedProfile === null ? void 0 : advertised.fpsByProfile.get(pickedProfile);
|
|
40909
42179
|
const advertisedFps = resolvedFps?.fps ?? video.fps;
|
|
40910
42180
|
const advertisedFpsSource = resolvedFps?.source ?? "assumed";
|
|
40911
|
-
const deliveredFps = needsTranscode ?
|
|
42181
|
+
const deliveredFps = needsTranscode ? video.fps : advertisedFps;
|
|
40912
42182
|
const slotEvidence = pickedProfile === null ? void 0 : bitrates.get(pickedProfile);
|
|
40913
42183
|
const fitNotes = formatFitNotes(fit.notes);
|
|
40914
42184
|
session.selectedSlot = {
|
|
@@ -40953,7 +42223,7 @@ async function startFfmpegForSession(bctx, session, sessionId, video, advertised
|
|
|
40953
42223
|
const audioLoopPort = session.audioLoopUdp.address().port;
|
|
40954
42224
|
const videoTarget = `rtp://127.0.0.1:${videoLoopPort}?pkt_size=${video.mtu}`;
|
|
40955
42225
|
const audioTarget = `rtp://127.0.0.1:${audioLoopPort}?pkt_size=${video.mtu}`;
|
|
40956
|
-
const
|
|
42226
|
+
const videoPlan = buildVideoPlan({
|
|
40957
42227
|
transcode: needsTranscode,
|
|
40958
42228
|
width: video.width,
|
|
40959
42229
|
height: video.height,
|
|
@@ -40962,19 +42232,21 @@ async function startFfmpegForSession(bctx, session, sessionId, video, advertised
|
|
|
40962
42232
|
});
|
|
40963
42233
|
const hwDecode = selectHwDecode({
|
|
40964
42234
|
transcode: needsTranscode,
|
|
40965
|
-
reading: needsTranscode ? await probeDecoderHwaccel({
|
|
40966
|
-
ctx,
|
|
40967
|
-
log: startLog
|
|
40968
|
-
|
|
40969
|
-
|
|
42235
|
+
reading: needsTranscode ? await withDeadline(probeDecoderHwaccel({
|
|
42236
|
+
source: decoderInfoSourceFromContext(ctx),
|
|
42237
|
+
log: startLog,
|
|
42238
|
+
memo: options.decodeMemos.reading
|
|
42239
|
+
}), 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,
|
|
42240
|
+
platform: process.platform,
|
|
42241
|
+
recentlyFailedBackend: options.decodeMemos.failedBackend.read() ?? null
|
|
40970
42242
|
});
|
|
40971
42243
|
logDecodePath(startLog, sessionId, hwDecode, needsTranscode);
|
|
40972
42244
|
const videoSsrcSigned = session.videoSsrc | 0;
|
|
40973
42245
|
const audioSsrcSigned = video.audio_ssrc | 0;
|
|
40974
|
-
const buildArgs = (
|
|
40975
|
-
|
|
42246
|
+
const buildArgs = (decode) => buildFfmpegArgs(buildSessionInvocation({
|
|
42247
|
+
decode,
|
|
40976
42248
|
rtspUrl,
|
|
40977
|
-
|
|
42249
|
+
video: videoPlan,
|
|
40978
42250
|
videoTarget,
|
|
40979
42251
|
audioTarget,
|
|
40980
42252
|
videoPayloadType: video.pt,
|
|
@@ -40983,13 +42255,13 @@ async function startFfmpegForSession(bctx, session, sessionId, video, advertised
|
|
|
40983
42255
|
audioSsrcSigned,
|
|
40984
42256
|
audioPacketTimeMs: video.packet_time ?? 20,
|
|
40985
42257
|
audioSampleRateKhz: video.sample_rate ?? 16
|
|
40986
|
-
});
|
|
42258
|
+
}));
|
|
40987
42259
|
const log = ctx.logger.withTags({ deviceId: numericDeviceId });
|
|
40988
42260
|
let hardwareAlreadyFailed = false;
|
|
40989
42261
|
const spawnFfmpeg = (decision) => {
|
|
40990
42262
|
const spawnedAtMs = Date.now();
|
|
40991
42263
|
const usedHardware = decision.kind === "hardware";
|
|
40992
|
-
const proc = (0, node_child_process.spawn)("ffmpeg", buildArgs(decision
|
|
42264
|
+
const proc = (0, node_child_process.spawn)("ffmpeg", buildArgs(decision), { stdio: [
|
|
40993
42265
|
"ignore",
|
|
40994
42266
|
"ignore",
|
|
40995
42267
|
"pipe"
|
|
@@ -41012,6 +42284,7 @@ async function startFfmpegForSession(bctx, session, sessionId, video, advertised
|
|
|
41012
42284
|
runtimeMs: Date.now() - spawnedAtMs
|
|
41013
42285
|
})) {
|
|
41014
42286
|
hardwareAlreadyFailed = true;
|
|
42287
|
+
if (decision.kind === "hardware") options.decodeMemos.failedBackend.write(decision.backend);
|
|
41015
42288
|
log.warn("export-hap: hardware decode FAILED at init — respawning ffmpeg in software", { meta: {
|
|
41016
42289
|
sessionId,
|
|
41017
42290
|
backend: decision.kind === "hardware" ? decision.backend : null,
|
|
@@ -41021,11 +42294,7 @@ async function startFfmpegForSession(bctx, session, sessionId, video, advertised
|
|
|
41021
42294
|
runtimeMs: Date.now() - spawnedAtMs
|
|
41022
42295
|
} });
|
|
41023
42296
|
if (session.ffmpeg === proc) session.ffmpeg = null;
|
|
41024
|
-
spawnFfmpeg(
|
|
41025
|
-
kind: "software",
|
|
41026
|
-
reason: "hardware-attempt-failed",
|
|
41027
|
-
args: []
|
|
41028
|
-
});
|
|
42297
|
+
spawnFfmpeg(software("hardware-attempt-failed"));
|
|
41029
42298
|
return;
|
|
41030
42299
|
}
|
|
41031
42300
|
onFfmpegExit(proc, code, signal);
|
|
@@ -41070,7 +42339,7 @@ async function startFfmpegForSession(bctx, session, sessionId, video, advertised
|
|
|
41070
42339
|
fitReason: fit.reason,
|
|
41071
42340
|
encodeBudgetKbps: fit.budgetKbps,
|
|
41072
42341
|
audioCodec: "opus",
|
|
41073
|
-
audioBitrateKbps:
|
|
42342
|
+
audioBitrateKbps: OPUS_BITRATE_KBPS,
|
|
41074
42343
|
videoDecode: hwDecode.kind === "hardware" ? hwDecode.backend : "software"
|
|
41075
42344
|
} });
|
|
41076
42345
|
}
|
|
@@ -41275,16 +42544,87 @@ function errMsg$8(err) {
|
|
|
41275
42544
|
return err instanceof Error ? err.message : String(err);
|
|
41276
42545
|
}
|
|
41277
42546
|
//#endregion
|
|
42547
|
+
//#region src/mappers/builders/doorbell-delivery.ts
|
|
42548
|
+
function isRecord(value) {
|
|
42549
|
+
return typeof value === "object" && value !== null;
|
|
42550
|
+
}
|
|
42551
|
+
function numberOrNull(value) {
|
|
42552
|
+
return typeof value === "number" ? value : null;
|
|
42553
|
+
}
|
|
42554
|
+
function isConnectionLike(value) {
|
|
42555
|
+
return isRecord(value) && typeof value["hasEventNotifications"] === "function";
|
|
42556
|
+
}
|
|
42557
|
+
function isIterable(value) {
|
|
42558
|
+
return isRecord(value) && typeof value[Symbol.iterator] === "function";
|
|
42559
|
+
}
|
|
42560
|
+
/** `accessory._server.httpServer.connections`, or null at any missing hop. */
|
|
42561
|
+
function readConnections(accessory) {
|
|
42562
|
+
if (!isRecord(accessory)) return null;
|
|
42563
|
+
const server = accessory["_server"];
|
|
42564
|
+
if (!isRecord(server)) return null;
|
|
42565
|
+
const httpServer = server["httpServer"];
|
|
42566
|
+
if (!isRecord(httpServer)) return null;
|
|
42567
|
+
const connections = httpServer["connections"];
|
|
42568
|
+
return isIterable(connections) ? connections : null;
|
|
42569
|
+
}
|
|
42570
|
+
/**
|
|
42571
|
+
* Probe how far a ring on `characteristic` of `accessory` can travel RIGHT NOW.
|
|
42572
|
+
* Pure with respect to HAP state — it only reads. Never throws.
|
|
42573
|
+
*/
|
|
42574
|
+
function describeDoorbellDelivery(accessory, characteristic) {
|
|
42575
|
+
const aid = isRecord(accessory) ? numberOrNull(accessory["aid"]) : null;
|
|
42576
|
+
const iid = isRecord(characteristic) ? numberOrNull(characteristic["iid"]) : null;
|
|
42577
|
+
const serverPublished = isRecord(accessory) && isRecord(accessory["_server"]);
|
|
42578
|
+
const connections = readConnections(accessory);
|
|
42579
|
+
if (connections === null) return {
|
|
42580
|
+
aid,
|
|
42581
|
+
iid,
|
|
42582
|
+
serverPublished,
|
|
42583
|
+
connectionCount: 0,
|
|
42584
|
+
subscriberCount: 0
|
|
42585
|
+
};
|
|
42586
|
+
let connectionCount = 0;
|
|
42587
|
+
let subscriberCount = 0;
|
|
42588
|
+
for (const connection of connections) {
|
|
42589
|
+
connectionCount += 1;
|
|
42590
|
+
if (aid === null || iid === null) continue;
|
|
42591
|
+
if (isConnectionLike(connection) && connection.hasEventNotifications(aid, iid)) subscriberCount += 1;
|
|
42592
|
+
}
|
|
42593
|
+
return {
|
|
42594
|
+
aid,
|
|
42595
|
+
iid,
|
|
42596
|
+
serverPublished,
|
|
42597
|
+
connectionCount,
|
|
42598
|
+
subscriberCount
|
|
42599
|
+
};
|
|
42600
|
+
}
|
|
42601
|
+
/**
|
|
42602
|
+
* True when the ring provably reached nobody: no connection is subscribed to
|
|
42603
|
+
* the characteristic, so hap-nodejs dropped every event frame silently. The
|
|
42604
|
+
* caller must say so out loud — this is a branch that discards work.
|
|
42605
|
+
*/
|
|
42606
|
+
function ringReachedNobody(report) {
|
|
42607
|
+
return report.subscriberCount === 0;
|
|
42608
|
+
}
|
|
42609
|
+
//#endregion
|
|
41278
42610
|
//#region src/mappers/builders/doorbell.ts
|
|
41279
42611
|
async function buildDoorbell(input) {
|
|
41280
42612
|
const { bctx, controller } = input;
|
|
41281
42613
|
const { ctx, numericDeviceId } = bctx;
|
|
42614
|
+
const log = ctx.logger.withTags({ deviceId: numericDeviceId });
|
|
42615
|
+
log.info("export-hap: doorbell forward armed — HomeKit will ring on doorbell.onPressed");
|
|
41282
42616
|
const unsubscribe = ctx.eventBus.subscribe({ category: EventCategory.DoorbellOnPressed }, (event) => {
|
|
41283
|
-
if (event.data
|
|
42617
|
+
if (event.data?.deviceId !== numericDeviceId) return;
|
|
41284
42618
|
try {
|
|
42619
|
+
const delivery = describeDoorbellDelivery(bctx.accessory, bctx.accessory.getService(_homebridge_hap_nodejs.Service.Doorbell)?.getCharacteristic(_homebridge_hap_nodejs.Characteristic.ProgrammableSwitchEvent) ?? null);
|
|
41285
42620
|
controller.ringDoorbell();
|
|
42621
|
+
if (ringReachedNobody(delivery)) {
|
|
42622
|
+
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 } });
|
|
42623
|
+
return;
|
|
42624
|
+
}
|
|
42625
|
+
log.info("export-hap: doorbell SINGLE_PRESS pushed to HomeKit", { meta: { ...delivery } });
|
|
41286
42626
|
} catch (err) {
|
|
41287
|
-
|
|
42627
|
+
log.warn("export-hap: ringDoorbell() failed", { meta: { error: errMsg$7(err) } });
|
|
41288
42628
|
}
|
|
41289
42629
|
});
|
|
41290
42630
|
return { async dispose() {
|
|
@@ -41360,6 +42700,78 @@ function errMsg$6(err) {
|
|
|
41360
42700
|
return err instanceof Error ? err.message : String(err);
|
|
41361
42701
|
}
|
|
41362
42702
|
//#endregion
|
|
42703
|
+
//#region src/mappers/builders/service-label.ts
|
|
42704
|
+
/**
|
|
42705
|
+
* The ONE place a secondary service on the camera accessory gets its label.
|
|
42706
|
+
*
|
|
42707
|
+
* A "secondary service" here is a Switch or Lightbulb published alongside the
|
|
42708
|
+
* camera on the same accessory — the privacy switch, each accessory child
|
|
42709
|
+
* (siren, floodlight), each PTZ action. iOS Home renders these as their own
|
|
42710
|
+
* controls, and the operator has seen them as "Interruttore 1", "Interruttore
|
|
42711
|
+
* 2" through three separate rounds of fixes.
|
|
42712
|
+
*
|
|
42713
|
+
* ## Why `Name` alone cannot rename anything
|
|
42714
|
+
*
|
|
42715
|
+
* Two facts about hap-nodejs 2.1.7, both measured against the installed copy
|
|
42716
|
+
* rather than reasoned about:
|
|
42717
|
+
*
|
|
42718
|
+
* 1. `accessory.addService(Type, displayName, subtype)` ALREADY writes
|
|
42719
|
+
* `displayName` to `Characteristic.Name` (`Service` constructor). So every
|
|
42720
|
+
* round of this bug — including the one that moved the label onto
|
|
42721
|
+
* `ConfiguredName` — shipped with `Name` correctly set. "iOS had no name
|
|
42722
|
+
* to render" was never true.
|
|
42723
|
+
* 2. The mDNS configuration number (`c#`) is a sha1 over
|
|
42724
|
+
* `internalHAPRepresentation(false)`, which OMITS characteristic VALUES.
|
|
42725
|
+
* Changing the string in `Name` therefore does not bump `c#`, a paired
|
|
42726
|
+
* controller gets no signal to re-read `/accessories`, and the name it
|
|
42727
|
+
* cached at first enumeration stands forever.
|
|
42728
|
+
*
|
|
42729
|
+
* `Name` is also declared `pr` only — paired read, no write, no notify. It is
|
|
42730
|
+
* the seed a controller seeds its database from once; it is not a channel.
|
|
42731
|
+
*
|
|
42732
|
+
* ## Why `ConfiguredName`
|
|
42733
|
+
*
|
|
42734
|
+
* `ConfiguredName` (`000000E3`) is declared `pr | pw | ev` — the only name
|
|
42735
|
+
* characteristic a controller may write and may subscribe to. It is what iOS
|
|
42736
|
+
* 16+ reads for a service the user can rename, and adding it CHANGES the
|
|
42737
|
+
* accessory structure, so `c#` does bump and the controller re-reads.
|
|
42738
|
+
*
|
|
42739
|
+
* It was removed once because hap-nodejs logged
|
|
42740
|
+
*
|
|
42741
|
+
* ```
|
|
42742
|
+
* Characteristic not in required or optional characteristic section for
|
|
42743
|
+
* service Switch. Adding anyway.
|
|
42744
|
+
* ```
|
|
42745
|
+
*
|
|
42746
|
+
* That line is a WARNING, not a rejection: `Service.getCharacteristic` calls
|
|
42747
|
+
* `addCharacteristic` unconditionally and only then emits the warning. The
|
|
42748
|
+
* characteristic was always present and always published. hap-nodejs'
|
|
42749
|
+
* per-service optional lists simply predate `ConfiguredName` being valid on
|
|
42750
|
+
* any service.
|
|
42751
|
+
*
|
|
42752
|
+
* Registering it with {@link Service.addOptionalCharacteristic} first takes
|
|
42753
|
+
* the branch above the warning, so the accessory still builds with ZERO
|
|
42754
|
+
* characteristic warnings — which is what `service-naming.spec.ts` asserts.
|
|
42755
|
+
*
|
|
42756
|
+
* ## Scope
|
|
42757
|
+
*
|
|
42758
|
+
* Switch- and Lightbulb-shaped services only. `Service.MotionSensor` on a
|
|
42759
|
+
* camera accessory is not a separately named tile in iOS Home, so giving it a
|
|
42760
|
+
* writable name would be a guess, and this module does not guess.
|
|
42761
|
+
*/
|
|
42762
|
+
/**
|
|
42763
|
+
* Publish `name` as both the immutable `Name` and the controller-visible
|
|
42764
|
+
* `ConfiguredName` of `service`.
|
|
42765
|
+
*
|
|
42766
|
+
* `name` must already be HAP-valid — build it with `service-names.ts`, which
|
|
42767
|
+
* cannot return a string hap-nodejs' `checkName` would warn about.
|
|
42768
|
+
*/
|
|
42769
|
+
function applyServiceLabel(service, name) {
|
|
42770
|
+
service.setCharacteristic(_homebridge_hap_nodejs.Characteristic.Name, name);
|
|
42771
|
+
if (!service.optionalCharacteristics.some((characteristic) => characteristic.UUID === _homebridge_hap_nodejs.Characteristic.ConfiguredName.UUID)) service.addOptionalCharacteristic(_homebridge_hap_nodejs.Characteristic.ConfiguredName);
|
|
42772
|
+
service.setCharacteristic(_homebridge_hap_nodejs.Characteristic.ConfiguredName, name);
|
|
42773
|
+
}
|
|
42774
|
+
//#endregion
|
|
41363
42775
|
//#region src/mappers/builders/privacy-switch.ts
|
|
41364
42776
|
/**
|
|
41365
42777
|
* Privacy-mask switch builder — turns the camstack `privacy-mask` cap's
|
|
@@ -41377,12 +42789,12 @@ function errMsg$6(err) {
|
|
|
41377
42789
|
* camera-enabled switch — distinct from privacy-mask).
|
|
41378
42790
|
*/
|
|
41379
42791
|
async function buildPrivacySwitch(bctx) {
|
|
41380
|
-
const { ctx, accessory, proxy, numericDeviceId
|
|
42792
|
+
const { ctx, accessory, proxy, numericDeviceId } = bctx;
|
|
41381
42793
|
const log = ctx.logger.withTags({ deviceId: numericDeviceId });
|
|
41382
42794
|
const subtype = "privacy-mask";
|
|
41383
|
-
const serviceName = privacyServiceName(
|
|
42795
|
+
const serviceName = privacyServiceName();
|
|
41384
42796
|
const service = accessory.addService(_homebridge_hap_nodejs.Service.Switch, serviceName, subtype);
|
|
41385
|
-
service
|
|
42797
|
+
applyServiceLabel(service, serviceName);
|
|
41386
42798
|
try {
|
|
41387
42799
|
const status = await proxy.privacyMask?.getStatus({});
|
|
41388
42800
|
if (status && typeof status.enabled === "boolean") service.updateCharacteristic(_homebridge_hap_nodejs.Characteristic.On, status.enabled);
|
|
@@ -41472,23 +42884,17 @@ function ptzPresetLabel(presetName) {
|
|
|
41472
42884
|
* `proxy.ptzAutotrack.setEnabled({enabled})`. Initial value is
|
|
41473
42885
|
* hydrated from `getStatus({})`.
|
|
41474
42886
|
*
|
|
41475
|
-
* Naming:
|
|
41476
|
-
* by `ptzServiceName` and
|
|
41477
|
-
*
|
|
41478
|
-
*
|
|
41479
|
-
*
|
|
41480
|
-
*
|
|
41481
|
-
*
|
|
41482
|
-
* discarded the name and showed "Interruttore N". A previous round dropped
|
|
41483
|
-
* the camera prefix along with the em-dash; only the em-dash was the fault.
|
|
41484
|
-
* - The bare label that replaced it was then written to `ConfiguredName`,
|
|
41485
|
-
* which `Service.Switch` does not list, so hap-nodejs rejected the
|
|
41486
|
-
* characteristic outright — SIX rejections per PTZ camera per build, never
|
|
41487
|
-
* reported because only the two switches on the non-PTZ camera were noticed.
|
|
42887
|
+
* Naming: the bare action — "Preset stanza", "Pan Left", "Autotrack" — built
|
|
42888
|
+
* by `ptzServiceName` and published through `applyServiceLabel`, which writes
|
|
42889
|
+
* it to BOTH `Name` and `ConfiguredName`. The camera name is deliberately not
|
|
42890
|
+
* prefixed — these eight services live on that camera's accessory and iOS
|
|
42891
|
+
* shows them there. THREE rounds of this bug have been through this file;
|
|
42892
|
+
* `service-label.ts` records what each got wrong, and why only the writable
|
|
42893
|
+
* characteristic can rename a service after pairing.
|
|
41488
42894
|
*/
|
|
41489
42895
|
var MOMENTARY_RESET_MS = 1e3;
|
|
41490
42896
|
async function buildPtz(bctx) {
|
|
41491
|
-
const { ctx, accessory, proxy, numericDeviceId,
|
|
42897
|
+
const { ctx, accessory, proxy, numericDeviceId, options } = bctx;
|
|
41492
42898
|
const log = ctx.logger.withTags({ deviceId: numericDeviceId });
|
|
41493
42899
|
const timers = /* @__PURE__ */ new Set();
|
|
41494
42900
|
const armReset = (cb, delay) => {
|
|
@@ -41500,10 +42906,10 @@ async function buildPtz(bctx) {
|
|
|
41500
42906
|
};
|
|
41501
42907
|
const presets = await readPresets(bctx);
|
|
41502
42908
|
for (const preset of presets) {
|
|
41503
|
-
const label = ptzServiceName(
|
|
42909
|
+
const label = ptzServiceName(ptzPresetLabel(preset.name));
|
|
41504
42910
|
const subtype = `ptz-preset-${preset.id}`;
|
|
41505
42911
|
const service = accessory.addService(_homebridge_hap_nodejs.Service.Switch, label, subtype);
|
|
41506
|
-
service
|
|
42912
|
+
applyServiceLabel(service, label);
|
|
41507
42913
|
service.getCharacteristic(_homebridge_hap_nodejs.Characteristic.On).onSet(async (value) => {
|
|
41508
42914
|
if (value !== true) return;
|
|
41509
42915
|
try {
|
|
@@ -41518,9 +42924,9 @@ async function buildPtz(bctx) {
|
|
|
41518
42924
|
});
|
|
41519
42925
|
}
|
|
41520
42926
|
if (proxy.ptz) for (const dir of PTZ_DIRECTIONS) {
|
|
41521
|
-
const label = ptzServiceName(
|
|
42927
|
+
const label = ptzServiceName(dir.label);
|
|
41522
42928
|
const service = accessory.addService(_homebridge_hap_nodejs.Service.Switch, label, dir.subtype);
|
|
41523
|
-
service
|
|
42929
|
+
applyServiceLabel(service, label);
|
|
41524
42930
|
service.getCharacteristic(_homebridge_hap_nodejs.Characteristic.On).onSet(async (value) => {
|
|
41525
42931
|
if (value !== true) return;
|
|
41526
42932
|
try {
|
|
@@ -41564,12 +42970,12 @@ async function readPresets(bctx) {
|
|
|
41564
42970
|
}
|
|
41565
42971
|
}
|
|
41566
42972
|
async function tryBuildAutotrack(bctx) {
|
|
41567
|
-
const { ctx, accessory, proxy, numericDeviceId
|
|
42973
|
+
const { ctx, accessory, proxy, numericDeviceId } = bctx;
|
|
41568
42974
|
if (!proxy.ptzAutotrack) return { async dispose() {} };
|
|
41569
42975
|
const log = ctx.logger.withTags({ deviceId: numericDeviceId });
|
|
41570
|
-
const label = ptzServiceName(
|
|
42976
|
+
const label = ptzServiceName(PTZ_AUTOTRACK_LABEL);
|
|
41571
42977
|
const service = accessory.addService(_homebridge_hap_nodejs.Service.Switch, label, "ptz-autotrack");
|
|
41572
|
-
service
|
|
42978
|
+
applyServiceLabel(service, label);
|
|
41573
42979
|
try {
|
|
41574
42980
|
const status = await proxy.ptzAutotrack.getStatus({});
|
|
41575
42981
|
if (status && typeof status.enabled === "boolean") service.updateCharacteristic(_homebridge_hap_nodejs.Characteristic.On, status.enabled);
|
|
@@ -41696,7 +43102,7 @@ async function buildChildSwitch(bctx, subtype, deviceType) {
|
|
|
41696
43102
|
const isLightingDevice = deviceType === DeviceType.Light || deviceType === DeviceType.Generic;
|
|
41697
43103
|
const useLightbulb = hasBrightness && isLightingDevice;
|
|
41698
43104
|
const service = useLightbulb ? accessory.addService(_homebridge_hap_nodejs.Service.Lightbulb, displayName, subtype) : accessory.addService(_homebridge_hap_nodejs.Service.Switch, displayName, subtype);
|
|
41699
|
-
service
|
|
43105
|
+
applyServiceLabel(service, displayName);
|
|
41700
43106
|
try {
|
|
41701
43107
|
const switchStatus = await proxy.switch?.getStatus({});
|
|
41702
43108
|
if (switchStatus && typeof switchStatus.on === "boolean") service.updateCharacteristic(_homebridge_hap_nodejs.Characteristic.On, switchStatus.on);
|
|
@@ -41936,6 +43342,118 @@ function pickMapperKind(_capabilities) {
|
|
|
41936
43342
|
return "camera";
|
|
41937
43343
|
}
|
|
41938
43344
|
//#endregion
|
|
43345
|
+
//#region src/mappers/builders/stream-hwaccel-memo.ts
|
|
43346
|
+
/**
|
|
43347
|
+
* The two bounded memos HomeKit's decode path owns.
|
|
43348
|
+
*
|
|
43349
|
+
* ## Why they exist
|
|
43350
|
+
*
|
|
43351
|
+
* Everything D67 was actually about — one argv builder, one set of constants,
|
|
43352
|
+
* one hwaccel authority — HomeKit already had. What it did NOT have were the
|
|
43353
|
+
* two things the broker gained alongside them:
|
|
43354
|
+
*
|
|
43355
|
+
* 1. **A memo.** `probeDecoderHwaccel` issued a cross-process
|
|
43356
|
+
* `decoder.getInfo` per SESSION. iOS starts sessions in bursts — one on
|
|
43357
|
+
* record was started three times in 16 s — and every one of those paid a
|
|
43358
|
+
* cap call on a hub whose main thread is the scarce resource.
|
|
43359
|
+
* 2. **Failure feedback.** When HomeKit's hardware child died at init and
|
|
43360
|
+
* `shouldRetryInSoftware` saved the session, HomeKit told nobody. The next
|
|
43361
|
+
* session re-picked the same corpse and paid the same two-second death.
|
|
43362
|
+
* `EgressTranscodeManager` fixed exactly this for its own children
|
|
43363
|
+
* (26a522cd5) by reporting the dead backend into the broker's 60 s memo.
|
|
43364
|
+
*
|
|
43365
|
+
* ## Both ride `HwAccelCache`, deliberately
|
|
43366
|
+
*
|
|
43367
|
+
* `createHwAccelCache` from `@camstack/types` is the primitive the broker's own
|
|
43368
|
+
* `egressHwAccelCache` is built from, and the discipline it encodes is the
|
|
43369
|
+
* point: **caller-owned, never a module global** — a module global would
|
|
43370
|
+
* outlive an addon respawn and survive an operator changing the decoder
|
|
43371
|
+
* backend. Same TTL as the broker's, so "has hardware come back yet" cannot
|
|
43372
|
+
* answer differently depending on which consumer asked.
|
|
43373
|
+
*
|
|
43374
|
+
* ## The cross-process gap, stated honestly
|
|
43375
|
+
*
|
|
43376
|
+
* These memos are scoped to the `export-hap` PROCESS. When HomeKit's vaapi
|
|
43377
|
+
* child dies, the broker's next child still pays its own two-second death, and
|
|
43378
|
+
* vice versa — because addons may never import each other and there is no
|
|
43379
|
+
* capability for "this backend is dead on this node right now". Closing that
|
|
43380
|
+
* would need a new cap surface, which Phase 0 explicitly does not take. What is
|
|
43381
|
+
* closed here is HomeKit's own repetition of the cost, across cameras and
|
|
43382
|
+
* across sessions.
|
|
43383
|
+
*/
|
|
43384
|
+
/**
|
|
43385
|
+
* The window both memos answer for.
|
|
43386
|
+
*
|
|
43387
|
+
* 60 s, the same as `stream-broker-manager`'s `egressHwAccelCache`. Long enough
|
|
43388
|
+
* that a burst of session restarts pays one read; short enough that an operator
|
|
43389
|
+
* who changes the decoder backend, or a host whose accelerator recovers, is
|
|
43390
|
+
* obeyed on the next session rather than after an addon respawn.
|
|
43391
|
+
*/
|
|
43392
|
+
var HAP_DECODE_MEMO_TTL_MS = 6e4;
|
|
43393
|
+
/**
|
|
43394
|
+
* Separator inside the encoded reading. A control character, because a backend
|
|
43395
|
+
* name is `[a-z0-9]+` and the decoder's non-backend choices are `auto` /
|
|
43396
|
+
* `none` / `''`, none of which can contain one — so the split is total.
|
|
43397
|
+
*/
|
|
43398
|
+
var READING_SEPARATOR = "";
|
|
43399
|
+
/**
|
|
43400
|
+
* Marks a `null` FIELD, distinct from an EMPTY one.
|
|
43401
|
+
*
|
|
43402
|
+
* `probedBestHwaccel: ''` means the decoder answered and has never probed
|
|
43403
|
+
* (=> `not-probed`); `null` means the field was absent altogether. Encoding
|
|
43404
|
+
* both as `''` would lose a distinction `selectHwDecode` acts on.
|
|
43405
|
+
*/
|
|
43406
|
+
var NULL_FIELD = "\0";
|
|
43407
|
+
function encodeField(value) {
|
|
43408
|
+
return value === null ? NULL_FIELD : value;
|
|
43409
|
+
}
|
|
43410
|
+
function decodeField(value) {
|
|
43411
|
+
return value === NULL_FIELD ? null : value;
|
|
43412
|
+
}
|
|
43413
|
+
/**
|
|
43414
|
+
* A reading as ONE `string | null`, which is what {@link HwAccelCache} stores.
|
|
43415
|
+
*
|
|
43416
|
+
* The cache's three states are exactly the three a memoised reading needs:
|
|
43417
|
+
* `undefined` (never asked, or expired), `null` (asked, and the decoder could
|
|
43418
|
+
* not be reached), and a value. Encoding into the one cache rather than
|
|
43419
|
+
* splitting across two is what keeps those three from skewing — two caches
|
|
43420
|
+
* written together can still be READ across an expiry boundary.
|
|
43421
|
+
*/
|
|
43422
|
+
function encodeDecoderReading(reading) {
|
|
43423
|
+
if (reading === null) return null;
|
|
43424
|
+
return `${encodeField(reading.hwaccel)}${READING_SEPARATOR}${encodeField(reading.probedBestHwaccel)}`;
|
|
43425
|
+
}
|
|
43426
|
+
function decodeDecoderReading(value) {
|
|
43427
|
+
if (value === null) return null;
|
|
43428
|
+
const [hwaccel = NULL_FIELD, probed = NULL_FIELD] = value.split(READING_SEPARATOR);
|
|
43429
|
+
return {
|
|
43430
|
+
hwaccel: decodeField(hwaccel),
|
|
43431
|
+
probedBestHwaccel: decodeField(probed)
|
|
43432
|
+
};
|
|
43433
|
+
}
|
|
43434
|
+
function createDecoderReadingMemo(options) {
|
|
43435
|
+
const cache = createHwAccelCache(options);
|
|
43436
|
+
return {
|
|
43437
|
+
read() {
|
|
43438
|
+
const cached = cache.read();
|
|
43439
|
+
return cached === void 0 ? void 0 : decodeDecoderReading(cached);
|
|
43440
|
+
},
|
|
43441
|
+
write(reading) {
|
|
43442
|
+
cache.write(encodeDecoderReading(reading));
|
|
43443
|
+
}
|
|
43444
|
+
};
|
|
43445
|
+
}
|
|
43446
|
+
function createHapDecodeMemos(now) {
|
|
43447
|
+
const options = {
|
|
43448
|
+
ttlMs: HAP_DECODE_MEMO_TTL_MS,
|
|
43449
|
+
...now ? { now } : {}
|
|
43450
|
+
};
|
|
43451
|
+
return {
|
|
43452
|
+
reading: createDecoderReadingMemo(options),
|
|
43453
|
+
failedBackend: createHwAccelCache(options)
|
|
43454
|
+
};
|
|
43455
|
+
}
|
|
43456
|
+
//#endregion
|
|
41939
43457
|
//#region src/reconcile/sync-state.ts
|
|
41940
43458
|
function syncStateFromJson(json) {
|
|
41941
43459
|
const map = /* @__PURE__ */ new Map();
|
|
@@ -42078,6 +43596,18 @@ var ExportHapAddon = class extends BaseAddon {
|
|
|
42078
43596
|
pincode = "";
|
|
42079
43597
|
/** Optional mDNS/bind interface (config.interfaceName), or undefined. */
|
|
42080
43598
|
bind;
|
|
43599
|
+
/**
|
|
43600
|
+
* What this PROCESS remembers about decode hardware, shared by every camera
|
|
43601
|
+
* mapper: the decoder addon's per-node reading (60 s), and the backend that
|
|
43602
|
+
* last died at init (60 s).
|
|
43603
|
+
*
|
|
43604
|
+
* Owned here rather than as a module global for the reason `HwAccelCache`
|
|
43605
|
+
* itself records — a module global outlives an addon respawn and survives an
|
|
43606
|
+
* operator changing the decoder backend. Owned here rather than per mapper
|
|
43607
|
+
* because the whole point is that camera B does not re-pay camera A's failed
|
|
43608
|
+
* hardware init.
|
|
43609
|
+
*/
|
|
43610
|
+
decodeMemos = createHapDecodeMemos();
|
|
42081
43611
|
constructor() {
|
|
42082
43612
|
super({ ...DEFAULT_CONFIG });
|
|
42083
43613
|
}
|
|
@@ -42219,13 +43749,14 @@ var ExportHapAddon = class extends BaseAddon {
|
|
|
42219
43749
|
const mapperKind = pickMapperKind(capabilities);
|
|
42220
43750
|
if (!mapperKind) throw new Error(`export-hap: no mapper for capabilities ${JSON.stringify(capabilities ?? [])}`);
|
|
42221
43751
|
const displayName = await this.resolveDisplayName(deviceId);
|
|
42222
|
-
const
|
|
43752
|
+
const previous = this.config.exposed.find((e) => e.deviceId === deviceId);
|
|
43753
|
+
const baseEntry = carryForward({
|
|
42223
43754
|
deviceId,
|
|
42224
43755
|
displayName,
|
|
42225
43756
|
mapperKind,
|
|
42226
|
-
addedAt: Date.now(),
|
|
43757
|
+
addedAt: previous?.addedAt ?? Date.now(),
|
|
42227
43758
|
...capabilities ? { capabilities: [...capabilities] } : {}
|
|
42228
|
-
};
|
|
43759
|
+
}, previous, ["settings", "capabilities"]);
|
|
42229
43760
|
const attached = await this.attachMapper(baseEntry);
|
|
42230
43761
|
const finalEntry = {
|
|
42231
43762
|
...baseEntry,
|
|
@@ -42244,13 +43775,13 @@ var ExportHapAddon = class extends BaseAddon {
|
|
|
42244
43775
|
childCount: attached.childAccessoryUuids.length
|
|
42245
43776
|
} });
|
|
42246
43777
|
}
|
|
42247
|
-
async unexposeDevice(deviceId) {
|
|
43778
|
+
async unexposeDevice(deviceId, options = {}) {
|
|
42248
43779
|
const numericId = Number.parseInt(deviceId, 10);
|
|
42249
43780
|
const log = this.ctx.logger.withTags({ deviceId: numericId });
|
|
42250
43781
|
await this.detachMapper(deviceId);
|
|
42251
43782
|
const next = this.config.exposed.filter((e) => e.deviceId !== deviceId);
|
|
42252
43783
|
if (next.length !== this.config.exposed.length) await this.updateGlobalSettings({ exposed: next });
|
|
42253
|
-
clearPairingFiles(_homebridge_hap_nodejs.uuid.generate(`camstack:camera:${numericId}`), this.ctx.logger);
|
|
43784
|
+
if (options.clearPairing !== false) clearPairingFiles(_homebridge_hap_nodejs.uuid.generate(`camstack:camera:${numericId}`), this.ctx.logger);
|
|
42254
43785
|
await this.forgetFingerprint(numericId);
|
|
42255
43786
|
log.info("export-hap: unexposed device");
|
|
42256
43787
|
}
|
|
@@ -42263,6 +43794,7 @@ var ExportHapAddon = class extends BaseAddon {
|
|
|
42263
43794
|
displayName: entry.displayName,
|
|
42264
43795
|
options: {
|
|
42265
43796
|
ptzPulseMs: this.config.ptzPulseMs,
|
|
43797
|
+
decodeMemos: this.decodeMemos,
|
|
42266
43798
|
hapDeviceSettings: { streamPreference: entrySettings.streamPreference ?? "auto" }
|
|
42267
43799
|
}
|
|
42268
43800
|
});
|
|
@@ -42653,9 +44185,8 @@ var ExportHapAddon = class extends BaseAddon {
|
|
|
42653
44185
|
to: streamPreference
|
|
42654
44186
|
} });
|
|
42655
44187
|
try {
|
|
42656
|
-
await this.unexposeDevice(deviceIdStr);
|
|
44188
|
+
await this.unexposeDevice(deviceIdStr, { clearPairing: false });
|
|
42657
44189
|
await this.exposeDevice(deviceIdStr);
|
|
42658
|
-
await this.updateEntrySettings(deviceIdStr, nextSettings);
|
|
42659
44190
|
} catch (err) {
|
|
42660
44191
|
log.warn("export-hap: failed to refresh accessory after streamPreference change", { meta: { error: errMsg(err) } });
|
|
42661
44192
|
}
|