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