@camstack/addon-export-hap 1.2.13 → 1.2.15
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/export-hap.addon.js +3726 -266
- package/dist/export-hap.addon.mjs +3727 -268
- package/package.json +1 -1
package/dist/export-hap.addon.js
CHANGED
|
@@ -29,12 +29,51 @@ let node_crypto = require("node:crypto");
|
|
|
29
29
|
let node_path = require("node:path");
|
|
30
30
|
__toESM(node_path, 1);
|
|
31
31
|
node_path = __toESM(node_path);
|
|
32
|
+
let node_fs = require("node:fs");
|
|
33
|
+
node_fs = __toESM(node_fs, 1);
|
|
32
34
|
let node_child_process = require("node:child_process");
|
|
33
35
|
let _homebridge_hap_nodejs = require("@homebridge/hap-nodejs");
|
|
34
36
|
let node_fs_promises = require("node:fs/promises");
|
|
35
37
|
node_fs_promises = __toESM(node_fs_promises);
|
|
36
38
|
let node_dgram = require("node:dgram");
|
|
37
39
|
let node_os = require("node:os");
|
|
40
|
+
//#region src/exposed-entry.ts
|
|
41
|
+
/**
|
|
42
|
+
* Carrying an exposed-device entry across a re-expose.
|
|
43
|
+
*
|
|
44
|
+
* `exposeDevice` rebuilds its entry from scratch — display name, mapper kind,
|
|
45
|
+
* timestamp — and then REPLACES the stored one. Anything the rebuilt object
|
|
46
|
+
* does not mention is therefore destroyed, and two things it never mentioned
|
|
47
|
+
* were the per-camera settings and the capability list.
|
|
48
|
+
*
|
|
49
|
+
* The visible cost: the operator's "Source stream (HomeKit)" selector writes
|
|
50
|
+
* `low`, the addon logs `streamPreference changed — refreshing accessory
|
|
51
|
+
* {from=auto to=low}`, and the accessory that comes back derives its
|
|
52
|
+
* advertisement from DEFAULTS — `streamPreference=auto` — because the settings
|
|
53
|
+
* were dropped between the write and the rebuild. The selector only ever took
|
|
54
|
+
* effect after a full addon restart, when the settings were loaded first. A
|
|
55
|
+
* second write after the re-expose hid this: the store ended up correct, so
|
|
56
|
+
* nothing looked wrong except the stream nobody could explain.
|
|
57
|
+
*/
|
|
58
|
+
/**
|
|
59
|
+
* Fill `base` from `existing` for the given keys, letting `base` win wherever
|
|
60
|
+
* it actually says something.
|
|
61
|
+
*
|
|
62
|
+
* That asymmetry is the point: a caller who passes `capabilities` is stating a
|
|
63
|
+
* new truth and must not be overruled by the stored copy, while a caller who
|
|
64
|
+
* says nothing about `settings` is not asking for them to be erased.
|
|
65
|
+
*/
|
|
66
|
+
function carryForward(base, existing, keys) {
|
|
67
|
+
if (existing === void 0) return base;
|
|
68
|
+
const out = { ...base };
|
|
69
|
+
for (const key of keys) {
|
|
70
|
+
if (out[key] !== void 0) continue;
|
|
71
|
+
const carried = existing[key];
|
|
72
|
+
if (carried !== void 0) out[key] = carried;
|
|
73
|
+
}
|
|
74
|
+
return out;
|
|
75
|
+
}
|
|
76
|
+
//#endregion
|
|
38
77
|
//#region ../types/dist/event-category-41fKf-q9.mjs
|
|
39
78
|
var EventCategory = /* @__PURE__ */ function(EventCategory) {
|
|
40
79
|
EventCategory["SystemBoot"] = "system.boot";
|
|
@@ -6497,7 +6536,20 @@ var BrokerStatsSchema = object({
|
|
|
6497
6536
|
sampleRate: number(),
|
|
6498
6537
|
channels: number(),
|
|
6499
6538
|
supported: boolean()
|
|
6500
|
-
}).nullable().optional()
|
|
6539
|
+
}).nullable().optional(),
|
|
6540
|
+
/**
|
|
6541
|
+
* BROKER-SIDE AUDIO MUTE (D83). `true` = this broker is deliberately
|
|
6542
|
+
* distributing none of the device's audio, on live or recording.
|
|
6543
|
+
*
|
|
6544
|
+
* Present so a silent camera can be told apart from a broken one on the
|
|
6545
|
+
* stream panel itself, without cross-referencing the switch group: a
|
|
6546
|
+
* broker holding an `audio` track descriptor while `audioMuted` is true is
|
|
6547
|
+
* working exactly as asked. `audioMutedDropped` counts the audio units
|
|
6548
|
+
* thrown away since the current dial — it is how you confirm from stats
|
|
6549
|
+
* alone that the mute is on the packet path and not merely persisted.
|
|
6550
|
+
*/
|
|
6551
|
+
audioMuted: boolean().optional(),
|
|
6552
|
+
audioMutedDropped: number().optional()
|
|
6501
6553
|
});
|
|
6502
6554
|
/**
|
|
6503
6555
|
* Exporter-facing "profile restream" entry. Returned by
|
|
@@ -6546,9 +6598,38 @@ var CAP_NODE_PIN_CONTEXT_KEY = "__camstackNodePin";
|
|
|
6546
6598
|
/**
|
|
6547
6599
|
* Build the tRPC request options that pin a single capability call to `nodeId`.
|
|
6548
6600
|
* Pass as the second argument to `.query(input, …)` / `.mutate(input, …)`.
|
|
6601
|
+
*
|
|
6602
|
+
* ## The id is normalised here, and it has to be
|
|
6603
|
+
*
|
|
6604
|
+
* A forked addon reads its own node from `ctx.kernel.localNodeId`, and inside a
|
|
6605
|
+
* worker that value is a RUNNER id — `hub/export-hap`, not `hub`. Routing
|
|
6606
|
+
* compares a pin against real node ids, so such a pin matches nothing and the
|
|
6607
|
+
* call fails with `no provider registered for cap "…"`. The local-first
|
|
6608
|
+
* resolver already guarded against this (`localNodeId.split('/')[0]`), which
|
|
6609
|
+
* made the hazard invisible: unpinned calls worked, and only an explicit pin —
|
|
6610
|
+
* the thing you reach for when you specifically need THIS node — silently
|
|
6611
|
+
* addressed a node that does not exist.
|
|
6612
|
+
*
|
|
6613
|
+
* Cost of it being missing: `addon-export-hap` pinned `decoder.getInfo` to its
|
|
6614
|
+
* own node to read the host's hardware-decode backend. It never once answered,
|
|
6615
|
+
* so every HomeKit egress transcode decoded in SOFTWARE — including 4K H.265 —
|
|
6616
|
+
* while D67's whole premise was that the decoder addon is the authority on
|
|
6617
|
+
* hardware. The warn said `decoding in SOFTWARE` and read as "this node has no
|
|
6618
|
+
* hardware", which was false.
|
|
6619
|
+
*
|
|
6620
|
+
* Normalising in the ONE constructor fixes every caller at once, which is why
|
|
6621
|
+
* it is here and not at the call sites.
|
|
6549
6622
|
*/
|
|
6550
6623
|
function nodePin(nodeId) {
|
|
6551
|
-
return { context: { [CAP_NODE_PIN_CONTEXT_KEY]: nodeId } };
|
|
6624
|
+
return { context: { [CAP_NODE_PIN_CONTEXT_KEY]: toNodeId(nodeId) } };
|
|
6625
|
+
}
|
|
6626
|
+
/**
|
|
6627
|
+
* A runner id is `<nodeId>/<addonId>`; a node id has no slash. Taking the head
|
|
6628
|
+
* is idempotent, so passing an already-clean id costs nothing.
|
|
6629
|
+
*/
|
|
6630
|
+
function toNodeId(idOrRunnerId) {
|
|
6631
|
+
const head = idOrRunnerId.split("/")[0];
|
|
6632
|
+
return head === void 0 || head.length === 0 ? idOrRunnerId : head;
|
|
6552
6633
|
}
|
|
6553
6634
|
/**
|
|
6554
6635
|
* Output schema shared by the contribution + live methods.
|
|
@@ -6999,7 +7080,300 @@ method(object({ deviceId: number() }), array(StreamSourceEntrySchema)), method(o
|
|
|
6999
7080
|
input: unknown()
|
|
7000
7081
|
}), unknown(), { kind: "mutation" }), method(object({ deviceId: number() }), _void(), { kind: "mutation" }), method(object({ deviceId: number() }), unknown().nullable()), method(object({ deviceId: number() }), RawStateResultSchema.nullable(), { auth: "protected" });
|
|
7001
7082
|
//#endregion
|
|
7002
|
-
//#region ../types/dist/
|
|
7083
|
+
//#region ../types/dist/fmp4-box-splitter-B53u9-Nu.mjs
|
|
7084
|
+
var AUDIO_ENCODER_BY_CODEC = {
|
|
7085
|
+
opus: "libopus",
|
|
7086
|
+
aac: "aac",
|
|
7087
|
+
pcmu: "pcm_mulaw",
|
|
7088
|
+
pcma: "pcm_alaw"
|
|
7089
|
+
};
|
|
7090
|
+
/**
|
|
7091
|
+
* Camera-microphone audio, per codec. Lives HERE rather than in
|
|
7092
|
+
* `encode-defaults.ts` only to avoid an import cycle (`encode-defaults` depends
|
|
7093
|
+
* on these types); it is re-exported from there, which is where to read it.
|
|
7094
|
+
*
|
|
7095
|
+
* Every source in this repo is a mono camera mic. The former broker preset
|
|
7096
|
+
* encoded Opus at `channels: 2`, spending bitrate duplicating one channel —
|
|
7097
|
+
* that is the value this consolidation changed.
|
|
7098
|
+
*/
|
|
7099
|
+
var AUDIO_PRESETS = {
|
|
7100
|
+
aac: {
|
|
7101
|
+
kind: "encode",
|
|
7102
|
+
codec: "aac",
|
|
7103
|
+
bitrateKbps: 128,
|
|
7104
|
+
sampleRateHz: 48e3,
|
|
7105
|
+
channels: 1
|
|
7106
|
+
},
|
|
7107
|
+
opus: {
|
|
7108
|
+
kind: "encode",
|
|
7109
|
+
codec: "opus",
|
|
7110
|
+
bitrateKbps: 64,
|
|
7111
|
+
sampleRateHz: 48e3,
|
|
7112
|
+
channels: 1
|
|
7113
|
+
},
|
|
7114
|
+
pcmu: {
|
|
7115
|
+
kind: "encode",
|
|
7116
|
+
codec: "pcmu",
|
|
7117
|
+
sampleRateHz: 8e3,
|
|
7118
|
+
channels: 1
|
|
7119
|
+
}
|
|
7120
|
+
};
|
|
7121
|
+
/** `-hide_banner -loglevel <level>` — every ffmpeg site opens with this. */
|
|
7122
|
+
function logBannerArgs(level) {
|
|
7123
|
+
return [
|
|
7124
|
+
"-hide_banner",
|
|
7125
|
+
"-loglevel",
|
|
7126
|
+
level
|
|
7127
|
+
];
|
|
7128
|
+
}
|
|
7129
|
+
/** `true` when the resolved value means "decode in software" (⇒ no `-hwaccel`). */
|
|
7130
|
+
function isSoftwareDecode(decodeHwAccel) {
|
|
7131
|
+
return !decodeHwAccel || decodeHwAccel === "none" || decodeHwAccel === "copy";
|
|
7132
|
+
}
|
|
7133
|
+
/**
|
|
7134
|
+
* Every INPUT option, in order, terminated by `-i <url>`. Nothing may be
|
|
7135
|
+
* appended to this list by a caller — that is the whole point of the function.
|
|
7136
|
+
*/
|
|
7137
|
+
function buildInputArgs(input, decodeHwAccel) {
|
|
7138
|
+
const args = [];
|
|
7139
|
+
if (!isSoftwareDecode(decodeHwAccel)) args.push("-hwaccel", String(decodeHwAccel));
|
|
7140
|
+
if (input.extraArgs?.length) args.push(...input.extraArgs);
|
|
7141
|
+
if (input.analyzeDurationUs !== void 0) args.push("-analyzeduration", String(input.analyzeDurationUs));
|
|
7142
|
+
if (input.probeSizeBytes !== void 0) args.push("-probesize", String(input.probeSizeBytes));
|
|
7143
|
+
if (input.fflags?.length) for (const flag of input.fflags) args.push("-fflags", flag);
|
|
7144
|
+
if (input.rtspTransport) args.push("-rtsp_transport", input.rtspTransport);
|
|
7145
|
+
args.push("-i", input.url);
|
|
7146
|
+
return args;
|
|
7147
|
+
}
|
|
7148
|
+
/** The `-vf` filter args, or `[]` when a consumer `-vf` already claims the slot. */
|
|
7149
|
+
function buildVideoFilterArgs(scale, outputArgs) {
|
|
7150
|
+
if (!scale) return [];
|
|
7151
|
+
if (outputArgs.some((a) => a === "-vf")) return [];
|
|
7152
|
+
if (scale.mode === "exact") return ["-vf", `scale=${scale.width}:${scale.height}`];
|
|
7153
|
+
return ["-vf", `scale='min(${scale.width},iw)':'min(${scale.height},ih)':force_original_aspect_ratio=decrease:force_divisible_by=2`];
|
|
7154
|
+
}
|
|
7155
|
+
/** Rate-control args for an encode plan. */
|
|
7156
|
+
function buildRateControlArgs(video) {
|
|
7157
|
+
const kbps = video.bitrateKbps;
|
|
7158
|
+
if (kbps === void 0) return [];
|
|
7159
|
+
const rc = video.rateControl ?? {
|
|
7160
|
+
kind: "cap",
|
|
7161
|
+
vbvSeconds: 2
|
|
7162
|
+
};
|
|
7163
|
+
const bufsize = Math.max(1, Math.round(kbps * rc.vbvSeconds));
|
|
7164
|
+
return [
|
|
7165
|
+
...rc.kind === "cbr" ? ["-b:v", `${kbps}k`] : [],
|
|
7166
|
+
"-maxrate",
|
|
7167
|
+
`${kbps}k`,
|
|
7168
|
+
"-bufsize",
|
|
7169
|
+
`${bufsize}k`
|
|
7170
|
+
];
|
|
7171
|
+
}
|
|
7172
|
+
/** The whole video block (`-vf` … `-c:v` … knobs), after `-i`. */
|
|
7173
|
+
function buildVideoArgs(video, outputArgs) {
|
|
7174
|
+
if (video.kind === "copy") return [
|
|
7175
|
+
"-c:v",
|
|
7176
|
+
"copy",
|
|
7177
|
+
...video.bitstreamFilter ? ["-bsf:v", video.bitstreamFilter] : []
|
|
7178
|
+
];
|
|
7179
|
+
const args = [
|
|
7180
|
+
...buildVideoFilterArgs(video.scale, outputArgs),
|
|
7181
|
+
"-c:v",
|
|
7182
|
+
video.encoder
|
|
7183
|
+
];
|
|
7184
|
+
if (video.preset !== void 0) args.push("-preset", video.preset);
|
|
7185
|
+
if (video.tune !== void 0) args.push("-tune", video.tune);
|
|
7186
|
+
if (video.profile !== void 0) args.push("-profile:v", video.profile);
|
|
7187
|
+
if (video.level !== void 0) args.push("-level", video.level);
|
|
7188
|
+
if (video.pixelFormat !== void 0) args.push("-pix_fmt", video.pixelFormat);
|
|
7189
|
+
if (video.fps !== void 0) args.push("-r", String(video.fps));
|
|
7190
|
+
if (video.gopFrames !== void 0) args.push("-g", String(video.gopFrames));
|
|
7191
|
+
if (video.forceKeyFramesSeconds !== void 0) args.push("-force_key_frames", `expr:gte(t,n_forced*${video.forceKeyFramesSeconds})`);
|
|
7192
|
+
if (video.bf !== void 0) args.push("-bf", String(video.bf));
|
|
7193
|
+
args.push(...buildRateControlArgs(video));
|
|
7194
|
+
if (video.bitstreamFilter !== void 0) args.push("-bsf:v", video.bitstreamFilter);
|
|
7195
|
+
return args;
|
|
7196
|
+
}
|
|
7197
|
+
/** The whole audio block, after `-i`. */
|
|
7198
|
+
function buildAudioArgs(audio) {
|
|
7199
|
+
if (audio.kind === "none") return ["-an"];
|
|
7200
|
+
if (audio.kind === "copy") return ["-c:a", "copy"];
|
|
7201
|
+
const args = [];
|
|
7202
|
+
if (audio.filter !== void 0) args.push("-af", audio.filter);
|
|
7203
|
+
args.push("-c:a", AUDIO_ENCODER_BY_CODEC[audio.codec]);
|
|
7204
|
+
if (audio.application !== void 0) args.push("-application", audio.application);
|
|
7205
|
+
if (audio.frameDurationMs !== void 0) args.push("-frame_duration", String(audio.frameDurationMs));
|
|
7206
|
+
if (audio.globalHeader === true) args.push("-flags", "+global_header");
|
|
7207
|
+
if (audio.sampleRateHz !== void 0) args.push("-ar", String(audio.sampleRateHz));
|
|
7208
|
+
if (audio.bitrateKbps !== void 0) args.push("-b:a", `${audio.bitrateKbps}k`);
|
|
7209
|
+
if (audio.vbvBufferKbits !== void 0) args.push("-bufsize", `${audio.vbvBufferKbits}k`);
|
|
7210
|
+
if (audio.channels !== void 0) args.push("-ac", String(audio.channels));
|
|
7211
|
+
return args;
|
|
7212
|
+
}
|
|
7213
|
+
/** RTP output-leg args (`-payload_type`, `-ssrc`, `-sdp_file`, `-f rtp <url>`). */
|
|
7214
|
+
function buildRtpOutputArgs(out) {
|
|
7215
|
+
const args = [];
|
|
7216
|
+
if (out.payloadType !== void 0) args.push("-payload_type", String(out.payloadType));
|
|
7217
|
+
if (out.ssrc !== void 0) args.push("-ssrc", String(out.ssrc));
|
|
7218
|
+
if (out.sdpFile !== void 0) args.push("-sdp_file", out.sdpFile);
|
|
7219
|
+
args.push("-f", "rtp", out.url);
|
|
7220
|
+
return args;
|
|
7221
|
+
}
|
|
7222
|
+
/** `true` when the sink is a raw elementary bytestream that cannot mux audio. */
|
|
7223
|
+
function isElementaryVideoSink(sink) {
|
|
7224
|
+
return sink.kind === "stdout" && (sink.container === "h264" || sink.container === "hevc");
|
|
7225
|
+
}
|
|
7226
|
+
/**
|
|
7227
|
+
* The fragmented-MP4 muxer flags, in the order the recorder has proven them
|
|
7228
|
+
* (`recorder/addon/ffmpeg-args.ts` passes the same `movflags` string through
|
|
7229
|
+
* `-segment_format_options`, across every vendor in the fleet):
|
|
7230
|
+
*
|
|
7231
|
+
* - `frag_keyframe` — cut a fragment at each key frame, so every fragment
|
|
7232
|
+
* opens on a sync sample. HKSV's whole requirement.
|
|
7233
|
+
* - `empty_moov` — write `ftyp`+`moov` up front with no samples in it, which
|
|
7234
|
+
* is what makes the head a standalone INITIALISATION segment.
|
|
7235
|
+
* - `default_base_moof` — fragment offsets are self-relative, so a fragment is
|
|
7236
|
+
* demuxable without the bytes that preceded it. D31's byte-range read path
|
|
7237
|
+
* depends on exactly this property of the recorder's segments.
|
|
7238
|
+
*/
|
|
7239
|
+
var FMP4_MOVFLAGS = "+frag_keyframe+empty_moov+default_base_moof";
|
|
7240
|
+
/**
|
|
7241
|
+
* The terminal sink args for every non-`rtp-outputs` sink. Exhaustive over the
|
|
7242
|
+
* union so a new member cannot fall through to `['-f', container, 'pipe:1']`,
|
|
7243
|
+
* which is what a plain `container` read would have done for `mp4` — a valid
|
|
7244
|
+
* argv that writes a NON-fragmented, unseekable-to-a-pipe MP4 and produces one
|
|
7245
|
+
* unusable byte stream.
|
|
7246
|
+
*/
|
|
7247
|
+
function buildStdoutOrRtspSinkArgs(sink) {
|
|
7248
|
+
if (sink.kind === "rtsp-listen") return [
|
|
7249
|
+
"-f",
|
|
7250
|
+
"rtsp",
|
|
7251
|
+
"-rtsp_transport",
|
|
7252
|
+
"tcp",
|
|
7253
|
+
"-rtsp_flags",
|
|
7254
|
+
"listen",
|
|
7255
|
+
sink.url
|
|
7256
|
+
];
|
|
7257
|
+
if (sink.kind === "rtp-outputs") return [];
|
|
7258
|
+
return sink.container === "mp4" ? buildFmp4SinkArgs(sink) : [
|
|
7259
|
+
"-f",
|
|
7260
|
+
sink.container,
|
|
7261
|
+
"pipe:1"
|
|
7262
|
+
];
|
|
7263
|
+
}
|
|
7264
|
+
/**
|
|
7265
|
+
* How far BELOW the negotiated fragment length `-min_frag_duration` is set.
|
|
7266
|
+
*
|
|
7267
|
+
* `-min_frag_duration` refuses to cut before that much media has accumulated,
|
|
7268
|
+
* and then waits for the next key frame. Set to exactly `fragmentMs`, the
|
|
7269
|
+
* commonest camera configuration in existence — a key-frame grid EQUAL to the
|
|
7270
|
+
* requested fragment length — lands the deadline on the same instant as the key
|
|
7271
|
+
* frame, loses the race, and skips to the following one: **every fragment comes
|
|
7272
|
+
* out at twice the requested length.**
|
|
7273
|
+
*
|
|
7274
|
+
* Measured on the live fleet 2026-08-07, camera 615, `-c:v copy` (D84):
|
|
7275
|
+
*
|
|
7276
|
+
* | slot | GOP | `-min_frag_duration` | median gap |
|
|
7277
|
+
* | --- | --- | --- | --- |
|
|
7278
|
+
* | 1280×720 | 40 f @ 10 fps = 4.0 s | 4000 ms | **7944 ms** |
|
|
7279
|
+
* | 1280×720 | 40 f @ 10 fps = 4.0 s | 3600 ms | 3973 ms |
|
|
7280
|
+
* | 3840×2160 | 100 f @ 25 fps = 4.0 s | 4000 ms | 8042 ms |
|
|
7281
|
+
* | 3840×2160 | 100 f @ 25 fps = 4.0 s | 3600 ms | 3998 ms |
|
|
7282
|
+
*
|
|
7283
|
+
* A doubled fragment is not a cosmetic overshoot: HKSV requires every fragment
|
|
7284
|
+
* to be no longer than the length the controller SELECTED, so the shipped-but-
|
|
7285
|
+
* inert phase-1 sink would have violated the contract on its first real clip.
|
|
7286
|
+
*
|
|
7287
|
+
* 10 % is chosen against the two failures either side of it. Too small and
|
|
7288
|
+
* ordinary jitter (measured spread 3953-4096 ms) re-loses the race; too large
|
|
7289
|
+
* and a source with a key frame slightly EARLY than the grid gets cut there,
|
|
7290
|
+
* yielding a short fragment for no reason.
|
|
7291
|
+
*/
|
|
7292
|
+
var FMP4_MIN_FRAG_MARGIN = .9;
|
|
7293
|
+
/** `-movflags … -min_frag_duration <us> -f mp4 pipe:1`. */
|
|
7294
|
+
function buildFmp4SinkArgs(sink) {
|
|
7295
|
+
return [
|
|
7296
|
+
"-movflags",
|
|
7297
|
+
FMP4_MOVFLAGS,
|
|
7298
|
+
"-min_frag_duration",
|
|
7299
|
+
String(Math.max(0, Math.round(sink.fragmentMs * FMP4_MIN_FRAG_MARGIN * 1e3))),
|
|
7300
|
+
"-f",
|
|
7301
|
+
"mp4",
|
|
7302
|
+
"pipe:1"
|
|
7303
|
+
];
|
|
7304
|
+
}
|
|
7305
|
+
/**
|
|
7306
|
+
* A second output mapping source audio to RTP-over-UDP. `0:a:0?` makes the
|
|
7307
|
+
* audio optional so a source with no audio skips it instead of failing the
|
|
7308
|
+
* whole invocation.
|
|
7309
|
+
*/
|
|
7310
|
+
function buildAudioSidecarArgs(sidecar) {
|
|
7311
|
+
return [
|
|
7312
|
+
"-map",
|
|
7313
|
+
"0:a:0?",
|
|
7314
|
+
...buildAudioArgs(sidecar.codec === "pcma" ? {
|
|
7315
|
+
kind: "encode",
|
|
7316
|
+
codec: "pcma",
|
|
7317
|
+
sampleRateHz: 8e3,
|
|
7318
|
+
channels: 1
|
|
7319
|
+
} : AUDIO_PRESETS[sidecar.codec]),
|
|
7320
|
+
...buildRtpOutputArgs({
|
|
7321
|
+
url: sidecar.rtpUrl,
|
|
7322
|
+
sdpFile: sidecar.sdpFile
|
|
7323
|
+
})
|
|
7324
|
+
];
|
|
7325
|
+
}
|
|
7326
|
+
/**
|
|
7327
|
+
* Assemble the full ffmpeg argument list. Layout:
|
|
7328
|
+
*
|
|
7329
|
+
* -hide_banner -loglevel <level>
|
|
7330
|
+
* [-hwaccel <backend|auto>] ─┐ INPUT options — strictly before -i.
|
|
7331
|
+
* [<input.extraArgs>] │
|
|
7332
|
+
* [-fflags <flag>…] │
|
|
7333
|
+
* [-rtsp_transport tcp] │
|
|
7334
|
+
* -i <url> ─┘
|
|
7335
|
+
* <video block> <threads> <audio block> ─┐ OUTPUT options.
|
|
7336
|
+
* <consumer outputArgs verbatim> │
|
|
7337
|
+
* <sink> ─┘ terminal
|
|
7338
|
+
*/
|
|
7339
|
+
function buildFfmpegArgs(inv) {
|
|
7340
|
+
const head = [...logBannerArgs(inv.logLevel), ...buildInputArgs(inv.input, inv.decodeHwAccel)];
|
|
7341
|
+
const threadArgs = inv.threadCount > 0 ? ["-threads", String(inv.threadCount)] : [];
|
|
7342
|
+
if (inv.sink.kind === "rtp-outputs") {
|
|
7343
|
+
const videoLeg = inv.sink.video ? [
|
|
7344
|
+
"-an",
|
|
7345
|
+
"-map",
|
|
7346
|
+
"0:v:0",
|
|
7347
|
+
...buildVideoArgs(inv.video, inv.outputArgs),
|
|
7348
|
+
...threadArgs,
|
|
7349
|
+
...inv.outputArgs,
|
|
7350
|
+
...buildRtpOutputArgs(inv.sink.video)
|
|
7351
|
+
] : [];
|
|
7352
|
+
const audioLeg = inv.sink.audio ? [
|
|
7353
|
+
"-vn",
|
|
7354
|
+
"-map",
|
|
7355
|
+
"0:a:0?",
|
|
7356
|
+
...buildAudioArgs(inv.audio),
|
|
7357
|
+
...buildRtpOutputArgs(inv.sink.audio)
|
|
7358
|
+
] : [];
|
|
7359
|
+
return [
|
|
7360
|
+
...head,
|
|
7361
|
+
...videoLeg,
|
|
7362
|
+
...audioLeg
|
|
7363
|
+
];
|
|
7364
|
+
}
|
|
7365
|
+
const audioArgs = isElementaryVideoSink(inv.sink) ? ["-an"] : buildAudioArgs(inv.audio);
|
|
7366
|
+
const sinkArgs = buildStdoutOrRtspSinkArgs(inv.sink);
|
|
7367
|
+
return [
|
|
7368
|
+
...head,
|
|
7369
|
+
...buildVideoArgs(inv.video, inv.outputArgs),
|
|
7370
|
+
...threadArgs,
|
|
7371
|
+
...audioArgs,
|
|
7372
|
+
...inv.outputArgs,
|
|
7373
|
+
...sinkArgs,
|
|
7374
|
+
...inv.audioSidecar ? buildAudioSidecarArgs(inv.audioSidecar) : []
|
|
7375
|
+
];
|
|
7376
|
+
}
|
|
7003
7377
|
/**
|
|
7004
7378
|
* Deterministic SHA-256 hash of an arbitrary serialisable value. The
|
|
7005
7379
|
* canonical form sorts object keys alphabetically at every depth so two
|
|
@@ -7031,6 +7405,190 @@ function replaceWithSortedKeys(_key, value) {
|
|
|
7031
7405
|
}
|
|
7032
7406
|
return value;
|
|
7033
7407
|
}
|
|
7408
|
+
var DEFAULT_MAX_UNIT_BYTES = 16 * 1024 * 1024;
|
|
7409
|
+
/** Header size for a normal box, and for one carrying a 64-bit `largesize`. */
|
|
7410
|
+
var BOX_HEADER_BYTES = 8;
|
|
7411
|
+
var LARGE_BOX_HEADER_BYTES = 16;
|
|
7412
|
+
var Fmp4BoxSplitter = class {
|
|
7413
|
+
maxUnitBytes;
|
|
7414
|
+
/** Bytes of the CURRENT unit plus any partial box after it. */
|
|
7415
|
+
buffer = new Uint8Array(0);
|
|
7416
|
+
/** Where the current unit starts inside {@link buffer}. */
|
|
7417
|
+
unitStart = 0;
|
|
7418
|
+
/** Where the box scanner has reached inside {@link buffer}. */
|
|
7419
|
+
cursor = 0;
|
|
7420
|
+
state = "init";
|
|
7421
|
+
nextSequence = 0;
|
|
7422
|
+
faultReason = null;
|
|
7423
|
+
interstitial = /* @__PURE__ */ new Set();
|
|
7424
|
+
constructor(options = {}) {
|
|
7425
|
+
this.maxUnitBytes = options.maxUnitBytes ?? DEFAULT_MAX_UNIT_BYTES;
|
|
7426
|
+
}
|
|
7427
|
+
/**
|
|
7428
|
+
* Non-null once the stream cannot be split. The splitter emits nothing
|
|
7429
|
+
* further, so a caller polls this to kill the child rather than watching a
|
|
7430
|
+
* silent stall — a fragmenter that quietly stops producing looks exactly like
|
|
7431
|
+
* a camera with no motion.
|
|
7432
|
+
*/
|
|
7433
|
+
get fault() {
|
|
7434
|
+
return this.faultReason;
|
|
7435
|
+
}
|
|
7436
|
+
/** Bytes currently held. The memory bound, observable rather than asserted. */
|
|
7437
|
+
get pendingBytes() {
|
|
7438
|
+
return this.buffer.length - this.unitStart;
|
|
7439
|
+
}
|
|
7440
|
+
/**
|
|
7441
|
+
* Top-level box types seen BETWEEN fragments and discarded — `mfra`, `free`,
|
|
7442
|
+
* a stray `sidx`. Reported rather than dropped in silence: they are legal and
|
|
7443
|
+
* useless to a fragment consumer, but a type nobody expected showing up here
|
|
7444
|
+
* is the first symptom of a muxer that is not writing what we think it is.
|
|
7445
|
+
*/
|
|
7446
|
+
get discardedInterstitialTypes() {
|
|
7447
|
+
return [...this.interstitial];
|
|
7448
|
+
}
|
|
7449
|
+
/**
|
|
7450
|
+
* Feed bytes; get back whatever units completed. Returns `[]` once faulted.
|
|
7451
|
+
*/
|
|
7452
|
+
push(chunk) {
|
|
7453
|
+
if (this.faultReason !== null || chunk.length === 0) return [];
|
|
7454
|
+
this.append(chunk);
|
|
7455
|
+
if (this.pendingBytes > this.maxUnitBytes) return this.fail(`a single fMP4 unit exceeded ${this.maxUnitBytes} bytes — this stream is not fragmented`);
|
|
7456
|
+
return this.drainBoxes();
|
|
7457
|
+
}
|
|
7458
|
+
append(chunk) {
|
|
7459
|
+
if (this.buffer.length === 0) {
|
|
7460
|
+
this.buffer = chunk.slice();
|
|
7461
|
+
return;
|
|
7462
|
+
}
|
|
7463
|
+
const next = new Uint8Array(this.buffer.length + chunk.length);
|
|
7464
|
+
next.set(this.buffer, 0);
|
|
7465
|
+
next.set(chunk, this.buffer.length);
|
|
7466
|
+
this.buffer = next;
|
|
7467
|
+
}
|
|
7468
|
+
/** Consume every COMPLETE top-level box now in the buffer. */
|
|
7469
|
+
drainBoxes() {
|
|
7470
|
+
const units = [];
|
|
7471
|
+
for (;;) {
|
|
7472
|
+
const header = this.readHeader();
|
|
7473
|
+
if (this.faultReason !== null) return units;
|
|
7474
|
+
if (header === null) break;
|
|
7475
|
+
if (this.cursor + header.totalBytes > this.buffer.length) break;
|
|
7476
|
+
const boxStart = this.cursor;
|
|
7477
|
+
const boxEnd = boxStart + header.totalBytes;
|
|
7478
|
+
this.cursor = boxEnd;
|
|
7479
|
+
const unit = this.consumeBox(header.type, boxStart, boxEnd);
|
|
7480
|
+
if (this.faultReason !== null) return units;
|
|
7481
|
+
if (unit !== null) units.push(unit);
|
|
7482
|
+
}
|
|
7483
|
+
this.compact();
|
|
7484
|
+
return units;
|
|
7485
|
+
}
|
|
7486
|
+
/**
|
|
7487
|
+
* Apply one box to the state machine. Returns a unit when this box CLOSED
|
|
7488
|
+
* one, `null` otherwise.
|
|
7489
|
+
*/
|
|
7490
|
+
consumeBox(type, boxStart, boxEnd) {
|
|
7491
|
+
if (this.state === "init") {
|
|
7492
|
+
if (type !== "moof") return null;
|
|
7493
|
+
if (boxStart === this.unitStart) {
|
|
7494
|
+
this.fail("a moof arrived before any initialisation box — there is no ftyp/moov to send");
|
|
7495
|
+
return null;
|
|
7496
|
+
}
|
|
7497
|
+
const init = this.emit("init", this.unitStart, boxStart);
|
|
7498
|
+
this.unitStart = boxStart;
|
|
7499
|
+
this.state = "fragment";
|
|
7500
|
+
return init;
|
|
7501
|
+
}
|
|
7502
|
+
if (this.state === "idle") {
|
|
7503
|
+
if (type !== "moof") {
|
|
7504
|
+
this.interstitial.add(type);
|
|
7505
|
+
this.unitStart = boxEnd;
|
|
7506
|
+
return null;
|
|
7507
|
+
}
|
|
7508
|
+
this.unitStart = boxStart;
|
|
7509
|
+
this.state = "fragment";
|
|
7510
|
+
return null;
|
|
7511
|
+
}
|
|
7512
|
+
if (type !== "mdat") return null;
|
|
7513
|
+
const fragment = this.emit("fragment", this.unitStart, boxEnd);
|
|
7514
|
+
this.unitStart = boxEnd;
|
|
7515
|
+
this.state = "idle";
|
|
7516
|
+
return fragment;
|
|
7517
|
+
}
|
|
7518
|
+
/**
|
|
7519
|
+
* Parse the header at {@link cursor}, or `null` when too few bytes have
|
|
7520
|
+
* arrived to know. Faults on a size the splitter cannot honour.
|
|
7521
|
+
*/
|
|
7522
|
+
readHeader() {
|
|
7523
|
+
const available = this.buffer.length - this.cursor;
|
|
7524
|
+
if (available < BOX_HEADER_BYTES) return null;
|
|
7525
|
+
const view = new DataView(this.buffer.buffer, this.buffer.byteOffset, this.buffer.byteLength);
|
|
7526
|
+
const size = view.getUint32(this.cursor);
|
|
7527
|
+
const type = String.fromCharCode(this.buffer[this.cursor + 4] ?? 0, this.buffer[this.cursor + 5] ?? 0, this.buffer[this.cursor + 6] ?? 0, this.buffer[this.cursor + 7] ?? 0);
|
|
7528
|
+
if (size === 0) {
|
|
7529
|
+
this.fail(`box "${type}" declares size 0 (to EOF) — an unbounded box cannot be fragmented`);
|
|
7530
|
+
return null;
|
|
7531
|
+
}
|
|
7532
|
+
if (size === 1) {
|
|
7533
|
+
if (available < LARGE_BOX_HEADER_BYTES) return null;
|
|
7534
|
+
const large = view.getBigUint64(this.cursor + BOX_HEADER_BYTES);
|
|
7535
|
+
if (large > BigInt(this.maxUnitBytes)) {
|
|
7536
|
+
this.fail(`box "${type}" declares ${large} bytes, over the ${this.maxUnitBytes} byte bound`);
|
|
7537
|
+
return null;
|
|
7538
|
+
}
|
|
7539
|
+
return {
|
|
7540
|
+
type,
|
|
7541
|
+
totalBytes: Number(large)
|
|
7542
|
+
};
|
|
7543
|
+
}
|
|
7544
|
+
if (size < BOX_HEADER_BYTES) {
|
|
7545
|
+
this.fail(`box "${type}" declares an impossible size of ${size} bytes`);
|
|
7546
|
+
return null;
|
|
7547
|
+
}
|
|
7548
|
+
return {
|
|
7549
|
+
type,
|
|
7550
|
+
totalBytes: size
|
|
7551
|
+
};
|
|
7552
|
+
}
|
|
7553
|
+
emit(kind, start, end) {
|
|
7554
|
+
const sequence = this.nextSequence;
|
|
7555
|
+
this.nextSequence += 1;
|
|
7556
|
+
return {
|
|
7557
|
+
kind,
|
|
7558
|
+
data: this.buffer.slice(start, end),
|
|
7559
|
+
sequence
|
|
7560
|
+
};
|
|
7561
|
+
}
|
|
7562
|
+
/**
|
|
7563
|
+
* Drop everything already emitted or discarded. Without this the buffer is
|
|
7564
|
+
* the whole stream and the process dies in hours, not minutes.
|
|
7565
|
+
*/
|
|
7566
|
+
compact() {
|
|
7567
|
+
if (this.unitStart === 0) return;
|
|
7568
|
+
this.buffer = this.buffer.slice(this.unitStart);
|
|
7569
|
+
this.cursor -= this.unitStart;
|
|
7570
|
+
this.unitStart = 0;
|
|
7571
|
+
}
|
|
7572
|
+
fail(reason) {
|
|
7573
|
+
this.faultReason = reason;
|
|
7574
|
+
this.buffer = new Uint8Array(0);
|
|
7575
|
+
this.unitStart = 0;
|
|
7576
|
+
this.cursor = 0;
|
|
7577
|
+
return [];
|
|
7578
|
+
}
|
|
7579
|
+
};
|
|
7580
|
+
//#endregion
|
|
7581
|
+
//#region ../types/dist/err-msg-IQTHeDzc.mjs
|
|
7582
|
+
/**
|
|
7583
|
+
import { errMsg } from '@camstack/types'
|
|
7584
|
+
* Extract a human-readable message from an unknown error value.
|
|
7585
|
+
* Replaces the ubiquitous `errMsg(err)` pattern.
|
|
7586
|
+
*/
|
|
7587
|
+
function errMsg$12(err) {
|
|
7588
|
+
if (err instanceof Error) return err.message;
|
|
7589
|
+
if (typeof err === "string") return err;
|
|
7590
|
+
return String(err);
|
|
7591
|
+
}
|
|
7034
7592
|
var EncodeProfileSchema = object({
|
|
7035
7593
|
video: object({
|
|
7036
7594
|
codec: _enum([
|
|
@@ -7120,6 +7678,34 @@ var BASE_LIVE_EGRESS_PROFILE = {
|
|
|
7120
7678
|
};
|
|
7121
7679
|
({ ...BASE_LIVE_EGRESS_PROFILE }), { ...BASE_LIVE_EGRESS_PROFILE.video };
|
|
7122
7680
|
({ ...BASE_LIVE_EGRESS_PROFILE });
|
|
7681
|
+
/** VBV window for a consumer whose budget is enforced per second (HomeKit). */
|
|
7682
|
+
var RATE_CONTROL_TIGHT = {
|
|
7683
|
+
kind: "cbr",
|
|
7684
|
+
vbvSeconds: 1
|
|
7685
|
+
};
|
|
7686
|
+
var HAP_AUDIO_BASE = {
|
|
7687
|
+
kind: "encode",
|
|
7688
|
+
codec: "opus",
|
|
7689
|
+
bitrateKbps: 24,
|
|
7690
|
+
channels: 1,
|
|
7691
|
+
application: "lowdelay",
|
|
7692
|
+
globalHeader: true,
|
|
7693
|
+
filter: "aresample=async=1000:first_pts=0"
|
|
7694
|
+
};
|
|
7695
|
+
function createHwAccelCache(options) {
|
|
7696
|
+
const now = options.now ?? (() => Date.now());
|
|
7697
|
+
let value = null;
|
|
7698
|
+
let writtenAt = Number.NEGATIVE_INFINITY;
|
|
7699
|
+
return {
|
|
7700
|
+
read() {
|
|
7701
|
+
return now() - writtenAt < options.ttlMs ? value : void 0;
|
|
7702
|
+
},
|
|
7703
|
+
write(next) {
|
|
7704
|
+
value = next;
|
|
7705
|
+
writtenAt = now();
|
|
7706
|
+
}
|
|
7707
|
+
};
|
|
7708
|
+
}
|
|
7123
7709
|
/**
|
|
7124
7710
|
* Deep wiring healthcheck — snapshot of active reachability probes across
|
|
7125
7711
|
* every declared capability + widget of every installed plugin, on every
|
|
@@ -7176,7 +7762,7 @@ object({
|
|
|
7176
7762
|
* ## This file adds no state
|
|
7177
7763
|
*
|
|
7178
7764
|
* Every switch here is a VIEW onto an authority that already existed
|
|
7179
|
-
* ([
|
|
7765
|
+
* ([D62](../../../../docs/decisions/adr-0062.md)). The whole point of the
|
|
7180
7766
|
* group is that there is exactly one place each function is turned off, and
|
|
7181
7767
|
* the group routes to it:
|
|
7182
7768
|
*
|
|
@@ -7187,6 +7773,53 @@ object({
|
|
|
7187
7773
|
* | `audio-analysis` | `deviceManager.setWrapperActive('audio-analysis')` | `AudioSubscriptionController.subscribeAudioStream` returns `null` before opening the stream |
|
|
7188
7774
|
* | `recording` | `recording.setDeviceConfig` → `RecordingConfig.enabled` | `band-decision.shouldRecord` returns false; the controller detaches the device |
|
|
7189
7775
|
* | `notifications` | `notificationRules.setDeviceMuted` | `NotificationCenter.evaluateAndEnqueue` returns before any rule is evaluated |
|
|
7776
|
+
* | `privacy-mask` | `privacyMask.setMask({ enabled })` → the CAMERA | the camera blanks the masked regions itself; every stream and recording carries the black boxes |
|
|
7777
|
+
* | `device-audio` | `privacyMask.setAudioEnabled` → the CAMERA | the camera stops encoding an audio track at all; every consumer sees silent video |
|
|
7778
|
+
* | `broker-audio` | `streamBroker.setDeviceAudioMute` → `DeviceOverride.audioMuted` | `StreamBroker.setAudioMuted` drops the audio plane at the source: no `type:'audio'` packet leaves `fanOutEncoded`, no RTP reaches the restreamer, and the restreamer serves the video-only SDP |
|
|
7779
|
+
*
|
|
7780
|
+
* ## `device-audio` and `broker-audio` are two functions, not two knobs
|
|
7781
|
+
*
|
|
7782
|
+
* They look adjacent and they are not the same control ([D83](../../../../docs/decisions/adr-0083.md)):
|
|
7783
|
+
* `device-audio` writes the CAMERA, so it is hardware privacy — the microphone
|
|
7784
|
+
* genuinely stops, it survives CamStack entirely, and it costs a multi-second
|
|
7785
|
+
* encoder restart on every flip. `broker-audio` writes THIS server, so it is
|
|
7786
|
+
* instant, vendor-independent and reversible without touching the camera, and
|
|
7787
|
+
* a camera that ignores or lacks the ISAPI/Reolink control is still silenced.
|
|
7788
|
+
* D62 forbids a second switch that *disagrees* with the first; these two
|
|
7789
|
+
* cannot disagree, because neither reads the other's store — the camera holds
|
|
7790
|
+
* one, the broker holds the other, and each reports its own fact.
|
|
7791
|
+
*
|
|
7792
|
+
* ## The two switches whose authority is not on this server
|
|
7793
|
+
*
|
|
7794
|
+
* `privacy-mask` and `device-audio` write the CAMERA. That is not a loophole
|
|
7795
|
+
* in "the group stores nothing" — it is the purest form of it: the camera
|
|
7796
|
+
* holds the fact, every read is a read-through, and there is no server-side
|
|
7797
|
+
* copy that could drift. Their availability therefore cannot come from
|
|
7798
|
+
* `listBindableCapsForDeviceType` (a device-NATIVE cap carries no wrappers and
|
|
7799
|
+
* is filtered out there); it comes from the cap's own camera-probed
|
|
7800
|
+
* `privacyMask.getOptions()`, which is strictly more honest — it answers for
|
|
7801
|
+
* THIS camera rather than for the device type
|
|
7802
|
+
* ([D74](../../../../docs/decisions/adr-0074.md)).
|
|
7803
|
+
*
|
|
7804
|
+
* ## `privacy-mask` is the one row whose ON is not "the function is working"
|
|
7805
|
+
*
|
|
7806
|
+
* Every other switch means *this camera's function is doing its job*, so
|
|
7807
|
+
* `enabled: false` is a thing an operator took away. `privacy-mask` means **the
|
|
7808
|
+
* MASK is active** — `enabled: true` is video deliberately obscured. The
|
|
7809
|
+
* polarity is not a choice made here: `addon-export-hap`'s privacy `Switch`
|
|
7810
|
+
* (`builders/privacy-switch.ts`) already mirrors `patch.enabled` verbatim, and
|
|
7811
|
+
* a HomeKit toggle that disagreed with the app's toggle for the same camera is
|
|
7812
|
+
* worse than either surface not having one.
|
|
7813
|
+
*
|
|
7814
|
+
* Two consequences follow and both are load-bearing:
|
|
7815
|
+
*
|
|
7816
|
+
* - **It never counts as `switchedOff`.** `countsAsSwitchedOff` is `false` for
|
|
7817
|
+
* exactly this row. With the polarity above, every camera that has NOT drawn
|
|
7818
|
+
* a privacy mask would otherwise report `switchedOff: ['privacy-mask']` — the
|
|
7819
|
+
* normal, healthy state of most cameras rendered as an operator disablement.
|
|
7820
|
+
* - **Its cost line names BOTH directions.** `costWhenOff` is rendered
|
|
7821
|
+
* unconditionally by both clients, so for this row it has to read correctly
|
|
7822
|
+
* whichever way the switch is sitting.
|
|
7190
7823
|
*
|
|
7191
7824
|
* The wrapper-binding pair is not a new idea: `legacy-migrations.ts` already
|
|
7192
7825
|
* migrated the legacy `audioEnabled` / `pipelineEnabled` /
|
|
@@ -7205,14 +7838,18 @@ object({
|
|
|
7205
7838
|
* `CameraStatus.switchedOff`.
|
|
7206
7839
|
*/
|
|
7207
7840
|
/**
|
|
7208
|
-
* The
|
|
7209
|
-
*
|
|
7841
|
+
* The functions the operator named — five on 2026-08-05, plus the camera's own
|
|
7842
|
+
* microphone on 2026-08-07. Deliberately NOT one id per pipeline step: face
|
|
7843
|
+
* recognition and plate/LPR are per-step toggles on
|
|
7210
7844
|
* `pipelineOrchestrator.setCameraStepToggle` and belong in the pipeline
|
|
7211
|
-
* editor, not in a
|
|
7845
|
+
* editor, not in a safety group.
|
|
7212
7846
|
*/
|
|
7213
7847
|
var CameraSwitchIdSchema = _enum([
|
|
7214
7848
|
"stream-broker",
|
|
7215
7849
|
"object-detection",
|
|
7850
|
+
"privacy-mask",
|
|
7851
|
+
"device-audio",
|
|
7852
|
+
"broker-audio",
|
|
7216
7853
|
"audio-analysis",
|
|
7217
7854
|
"recording",
|
|
7218
7855
|
"notifications"
|
|
@@ -7230,14 +7867,27 @@ var CameraSwitchAuthoritySchema = discriminatedUnion("kind", [
|
|
|
7230
7867
|
capName: string()
|
|
7231
7868
|
}),
|
|
7232
7869
|
object({ kind: literal("recording-config") }),
|
|
7233
|
-
object({ kind: literal("notification-mute") })
|
|
7870
|
+
object({ kind: literal("notification-mute") }),
|
|
7871
|
+
object({
|
|
7872
|
+
kind: literal("camera-audio"),
|
|
7873
|
+
capName: string()
|
|
7874
|
+
}),
|
|
7875
|
+
object({
|
|
7876
|
+
kind: literal("camera-mask"),
|
|
7877
|
+
capName: string()
|
|
7878
|
+
}),
|
|
7879
|
+
object({ kind: literal("broker-audio-mute") })
|
|
7234
7880
|
]);
|
|
7235
7881
|
/**
|
|
7236
7882
|
* Why a switch is not offered for this camera. Rendered instead of the
|
|
7237
7883
|
* control, never as a dead control — an absent function and a broken one must
|
|
7238
7884
|
* not look the same.
|
|
7239
7885
|
*/
|
|
7240
|
-
var CameraSwitchUnavailableReasonSchema = _enum([
|
|
7886
|
+
var CameraSwitchUnavailableReasonSchema = _enum([
|
|
7887
|
+
"no-provider",
|
|
7888
|
+
"source-unreachable",
|
|
7889
|
+
"not-configured"
|
|
7890
|
+
]);
|
|
7241
7891
|
/**
|
|
7242
7892
|
* One switch, resolved for one camera.
|
|
7243
7893
|
*
|
|
@@ -9259,6 +9909,26 @@ var EgressTranscodeRequestSchema = object({
|
|
|
9259
9909
|
"h264_mp4toannexb",
|
|
9260
9910
|
"hevc_mp4toannexb"
|
|
9261
9911
|
]).optional(),
|
|
9912
|
+
/**
|
|
9913
|
+
* Publish the transcode as a LOCAL push cam stream, instead of leaving the
|
|
9914
|
+
* consumer to dial the returned url. The broker picks the id and returns it
|
|
9915
|
+
* as `camStreamId` — a caller-supplied one would be circular, since the
|
|
9916
|
+
* sharing key is computed FROM this request.
|
|
9917
|
+
*
|
|
9918
|
+
* The url is still returned and still the contract for a transcode pinned to
|
|
9919
|
+
* another node. But dialling it locally costs an RTSP round trip that changes
|
|
9920
|
+
* the transport underneath the consumer: a dialled stream is an RTP source,
|
|
9921
|
+
* so `isRtpSource()` is true and the session takes the RTP-passthrough +
|
|
9922
|
+
* repacketizer branch. The push branch — the one the derived mechanism has
|
|
9923
|
+
* live hours on — is never reached. Measured on Alexa: broker registered, RTP
|
|
9924
|
+
* arriving, key frame arriving, black screen, on a chain healthy at every
|
|
9925
|
+
* other point.
|
|
9926
|
+
*
|
|
9927
|
+
* Same idea the transport already applies to CALLS, where `classifyCapRoute`
|
|
9928
|
+
* gives priority to `hub-in-process` so a local call never leaves the node.
|
|
9929
|
+
* This is that rule for media.
|
|
9930
|
+
*/
|
|
9931
|
+
publishLocally: boolean().optional(),
|
|
9262
9932
|
pixelFormat: _enum(["yuv420p", "nv12"]).optional(),
|
|
9263
9933
|
/**
|
|
9264
9934
|
* Operator/consumer override for decode hardware. ABSENT is the normal case
|
|
@@ -9303,7 +9973,13 @@ var EgressTranscodeSchema = object({
|
|
|
9303
9973
|
* Returned rather than assumed: a consumer that asked for hardware and got
|
|
9304
9974
|
* software needs to be able to see that without reading the broker's logs.
|
|
9305
9975
|
*/
|
|
9306
|
-
decodeHwAccel: string().nullable()
|
|
9976
|
+
decodeHwAccel: string().nullable(),
|
|
9977
|
+
/**
|
|
9978
|
+
* Set when `publishLocally` was honoured: attach to THIS instead of dialling
|
|
9979
|
+
* `url`, and the session takes the push/deframe transport rather than the
|
|
9980
|
+
* RTP-passthrough one. `null` means the consumer must dial.
|
|
9981
|
+
*/
|
|
9982
|
+
camStreamId: string().nullable()
|
|
9307
9983
|
});
|
|
9308
9984
|
method(object({
|
|
9309
9985
|
deviceId: number().int().nonnegative(),
|
|
@@ -9451,7 +10127,25 @@ method(object({
|
|
|
9451
10127
|
}), _void(), {
|
|
9452
10128
|
kind: "mutation",
|
|
9453
10129
|
auth: "admin"
|
|
9454
|
-
}), method(object({ brokerId: string() }), boolean()), object({
|
|
10130
|
+
}), method(object({ brokerId: string() }), boolean()), method(object({ deviceId: number().int() }), object({
|
|
10131
|
+
muted: boolean(),
|
|
10132
|
+
/**
|
|
10133
|
+
* How many live non-derived brokers currently hold the mute. Purely
|
|
10134
|
+
* diagnostic: `muted` is the policy and is authoritative on its own
|
|
10135
|
+
* (it applies to brokers that do not exist yet), while this says
|
|
10136
|
+
* whether anything is presently being silenced.
|
|
10137
|
+
*/
|
|
10138
|
+
appliedBrokers: number().int().nonnegative()
|
|
10139
|
+
})), method(object({
|
|
10140
|
+
deviceId: number().int(),
|
|
10141
|
+
muted: boolean()
|
|
10142
|
+
}), object({
|
|
10143
|
+
muted: boolean(),
|
|
10144
|
+
appliedBrokers: number().int().nonnegative()
|
|
10145
|
+
}), {
|
|
10146
|
+
kind: "mutation",
|
|
10147
|
+
auth: "admin"
|
|
10148
|
+
}), object({
|
|
9455
10149
|
deviceId: number().int().nonnegative(),
|
|
9456
10150
|
camStreamId: string(),
|
|
9457
10151
|
profile: CamProfileSchema
|
|
@@ -14774,6 +15468,30 @@ var TrackSourceSchema = _enum([
|
|
|
14774
15468
|
"audio"
|
|
14775
15469
|
]);
|
|
14776
15470
|
/**
|
|
15471
|
+
* Where a track sits in the RETRAIN lifecycle (D81).
|
|
15472
|
+
*
|
|
15473
|
+
* - `none` — never marked, or un-marked. Evictable.
|
|
15474
|
+
* - `staging` — the operator wants this track as training material and has not
|
|
15475
|
+
* finished with it. **This is the only state retention holds**: the track and
|
|
15476
|
+
* everything it owns (object events, crops, keyframes, CLIP vector) survive
|
|
15477
|
+
* the device's age window.
|
|
15478
|
+
* - `trained` — the retrain page has taken what it needed. The frames it chose
|
|
15479
|
+
* were COPIED into the retrain dataset at selection time, so the dataset no
|
|
15480
|
+
* longer depends on the track's media and the track becomes EVICTABLE again.
|
|
15481
|
+
* Terminal for the plain `markForTrain` toggle: returning it to `staging` is
|
|
15482
|
+
* a deliberate action of the retrain page, not a side effect of a checkbox.
|
|
15483
|
+
*
|
|
15484
|
+
* There is no `null`. The state is stored `TEXT NOT NULL DEFAULT 'none'` because
|
|
15485
|
+
* the store's filter language has only positive equality and `whereIn` — no
|
|
15486
|
+
* negation, no IS NULL — so a NULL would be unselectable by ANY predicate and
|
|
15487
|
+
* would make the entire pre-column history immortal in one deploy.
|
|
15488
|
+
*/
|
|
15489
|
+
var RetrainStatusSchema = _enum([
|
|
15490
|
+
"none",
|
|
15491
|
+
"staging",
|
|
15492
|
+
"trained"
|
|
15493
|
+
]);
|
|
15494
|
+
/**
|
|
14777
15495
|
* Per-track OPERATOR flags — set by hand from the admin UI or the viewer, never
|
|
14778
15496
|
* by the pipeline. Spread into `TrackSchema` and `KeyEventSchema` from one place
|
|
14779
15497
|
* so the two surfaces cannot drift.
|
|
@@ -14783,18 +15501,31 @@ var TrackSourceSchema = _enum([
|
|
|
14783
15501
|
* columns existed read as absent, and a consumer that needs a boolean should say
|
|
14784
15502
|
* `flag === true`, not `flag !== false`.
|
|
14785
15503
|
*
|
|
14786
|
-
*
|
|
14787
|
-
*
|
|
14788
|
-
*
|
|
14789
|
-
* `
|
|
15504
|
+
* `markForTrain` is the WIRE FACE of {@link RetrainStatusSchema}, not a column:
|
|
15505
|
+
* it is exactly `retrainStatus === 'staging'`, in both directions. Writing
|
|
15506
|
+
* `true` moves `none → staging`, writing `false` moves `staging → none`, and a
|
|
15507
|
+
* `trained` track reports `false` while refusing both writes. The boolean is
|
|
15508
|
+
* kept because three surfaces drive a toggle off it; anything that needs to tell
|
|
15509
|
+
* "never marked" from "already trained" must read `retrainStatus`.
|
|
15510
|
+
*
|
|
15511
|
+
* `debug` does NOT pin; it is attention, not durability.
|
|
14790
15512
|
*/
|
|
14791
15513
|
var TrackFlagFields = {
|
|
14792
|
-
/** Operator marked this track as training material.
|
|
15514
|
+
/** Operator marked this track as training material — i.e. `retrainStatus` is
|
|
15515
|
+
* `'staging'`. */
|
|
14793
15516
|
markForTrain: boolean().optional(),
|
|
14794
15517
|
/** Operator marked this track for diagnostic attention. */
|
|
14795
15518
|
debug: boolean().optional()
|
|
14796
15519
|
};
|
|
14797
15520
|
/**
|
|
15521
|
+
* The lifecycle field itself, on the READ surfaces only (`Track`, `KeyEvent`).
|
|
15522
|
+
* Deliberately NOT part of {@link TrackFlagFields}: that group also builds the
|
|
15523
|
+
* write patch, and the status is not something the toggle sets — it is what the
|
|
15524
|
+
* toggle's boolean is derived from. Absent on an in-RAM track never touched;
|
|
15525
|
+
* always present on a persisted row (the column default materialises `'none'`).
|
|
15526
|
+
*/
|
|
15527
|
+
var TrackRetrainFields = { retrainStatus: RetrainStatusSchema.optional() };
|
|
15528
|
+
/**
|
|
14798
15529
|
* The write half: a PARTIAL patch. An omitted key is left untouched, so setting
|
|
14799
15530
|
* one flag can never clear the other — the toggles are independent and are
|
|
14800
15531
|
* driven from three surfaces that do not know about each other.
|
|
@@ -14808,7 +15539,32 @@ var TrackFlagsPatchSchema = object(TrackFlagFields);
|
|
|
14808
15539
|
var TrackFlagsSchema = object({
|
|
14809
15540
|
trackId: string(),
|
|
14810
15541
|
markForTrain: boolean(),
|
|
14811
|
-
debug: boolean()
|
|
15542
|
+
debug: boolean(),
|
|
15543
|
+
/** The lifecycle state the boolean was derived from. Required here (unlike on
|
|
15544
|
+
* a track row) because this shape is only ever produced by the write body,
|
|
15545
|
+
* which always knows it — and a surface that has just written needs to render
|
|
15546
|
+
* `trained` without a re-fetch. */
|
|
15547
|
+
retrainStatus: RetrainStatusSchema
|
|
15548
|
+
});
|
|
15549
|
+
/** Per-camera slice of a training-export estimate. */
|
|
15550
|
+
var TrainingExportDeviceTotalsSchema = object({
|
|
15551
|
+
deviceId: number(),
|
|
15552
|
+
tracks: number().int(),
|
|
15553
|
+
files: number().int(),
|
|
15554
|
+
bytes: number().int()
|
|
15555
|
+
});
|
|
15556
|
+
/**
|
|
15557
|
+
* What a training export WOULD contain. Computed from media index rows only —
|
|
15558
|
+
* no blob is read to produce this.
|
|
15559
|
+
*/
|
|
15560
|
+
var TrainingExportSummarySchema = object({
|
|
15561
|
+
generatedAt: number(),
|
|
15562
|
+
trackCount: number().int(),
|
|
15563
|
+
fileCount: number().int(),
|
|
15564
|
+
byteCount: number().int(),
|
|
15565
|
+
/** More marked tracks exist than a single pass carries. */
|
|
15566
|
+
truncated: boolean(),
|
|
15567
|
+
devices: array(TrainingExportDeviceTotalsSchema).readonly()
|
|
14812
15568
|
});
|
|
14813
15569
|
var TrackSchema = object({
|
|
14814
15570
|
trackId: string(),
|
|
@@ -14853,7 +15609,8 @@ var TrackSchema = object({
|
|
|
14853
15609
|
* Populated from the persisted envelope columns on historical reads;
|
|
14854
15610
|
* absent on legacy rows, dims-less tracks and active (in-RAM) tracks. */
|
|
14855
15611
|
envelope: TrackEnvelopeSchema.optional(),
|
|
14856
|
-
...TrackFlagFields
|
|
15612
|
+
...TrackFlagFields,
|
|
15613
|
+
...TrackRetrainFields
|
|
14857
15614
|
});
|
|
14858
15615
|
var BaseEventFields = {
|
|
14859
15616
|
id: string(),
|
|
@@ -15067,7 +15824,8 @@ var KeyEventSchema = object({
|
|
|
15067
15824
|
bestEventId: string(),
|
|
15068
15825
|
/** Track lifetime in ms (lastSeen - firstSeen). */
|
|
15069
15826
|
windowMs: number().optional(),
|
|
15070
|
-
...TrackFlagFields
|
|
15827
|
+
...TrackFlagFields,
|
|
15828
|
+
...TrackRetrainFields
|
|
15071
15829
|
});
|
|
15072
15830
|
object({
|
|
15073
15831
|
trackId: string(),
|
|
@@ -15328,6 +16086,12 @@ DeviceType.Camera, method(object({ deviceId: number() }), array(TrackSchema).rea
|
|
|
15328
16086
|
}), method(OpsLogQueryInputSchema, array(OpsLogEntrySchema).readonly(), {
|
|
15329
16087
|
kind: "query",
|
|
15330
16088
|
auth: "admin"
|
|
16089
|
+
}), method(object({ deviceIds: array(number()).optional() }), TrainingExportSummarySchema, {
|
|
16090
|
+
kind: "query",
|
|
16091
|
+
auth: "admin"
|
|
16092
|
+
}), method(object({ deviceIds: array(number()).optional() }), object({ url: string() }), {
|
|
16093
|
+
kind: "query",
|
|
16094
|
+
auth: "admin"
|
|
15331
16095
|
}), method(object({
|
|
15332
16096
|
eventId: string(),
|
|
15333
16097
|
kind: MediaFileKindEnum.optional()
|
|
@@ -17145,9 +17909,15 @@ DeviceType.Camera, method(object({
|
|
|
17145
17909
|
* Bypass the cache freshness check and fetch directly from the
|
|
17146
17910
|
* native (or stream-broker fallback). Triggered by the UI's
|
|
17147
17911
|
* "refresh" button so an operator can force a fresh frame
|
|
17148
|
-
* even when the cache is well within
|
|
17149
|
-
*
|
|
17150
|
-
*
|
|
17912
|
+
* even when the cache is well within the device's
|
|
17913
|
+
* `snapshotMaxAgeS` window.
|
|
17914
|
+
*
|
|
17915
|
+
* **`force` is an OPERATOR signal, not a freshness preference.** On a
|
|
17916
|
+
* battery camera it is the one thing that walks past the wrapper's
|
|
17917
|
+
* sleep gate and wakes the camera, so a background caller — a poller,
|
|
17918
|
+
* an event handler, a thumbnail — must NEVER set it. Every such caller
|
|
17919
|
+
* gets the cached frame, which on a sleeping battery camera is the
|
|
17920
|
+
* correct answer: stale but honest beats woken.
|
|
17151
17921
|
*/
|
|
17152
17922
|
force: boolean().optional()
|
|
17153
17923
|
}), SnapshotImageSchema.nullable()), method(object({ deviceId: number() }), _void(), {
|
|
@@ -20982,6 +21752,173 @@ DeviceType.Camera, method(object({
|
|
|
20982
21752
|
status: OsdStatusSchema
|
|
20983
21753
|
});
|
|
20984
21754
|
/**
|
|
21755
|
+
* `osd-manager` — the ORCHESTRATOR over the device-scope `osd` cap.
|
|
21756
|
+
*
|
|
21757
|
+
* The `osd` cap is the firmware contract: it probes a camera's overlay
|
|
21758
|
+
* SLOTS and writes literal text into one. It has no idea WHERE that text
|
|
21759
|
+
* comes from, and it must not — a driver that grew a "show the temperature
|
|
21760
|
+
* here" feature would grow it once per vendor.
|
|
21761
|
+
*
|
|
21762
|
+
* This cap owns the other half: a per-(camera, slot) BINDING that says
|
|
21763
|
+
* which value feeds the slot, how it is formatted, and under which
|
|
21764
|
+
* conditions it is shown at all. One addon renders every binding on every
|
|
21765
|
+
* camera, so a new source costs zero driver code.
|
|
21766
|
+
*
|
|
21767
|
+
* Three deliberate choices, each with a rejected alternative:
|
|
21768
|
+
*
|
|
21769
|
+
* 1. A source is `(capName, valuePath)` over the kernel's device
|
|
21770
|
+
* runtime-state mirror — NOT a closed enum of source kinds. Every
|
|
21771
|
+
* cap-keyed slice a device publishes is bindable the day the cap
|
|
21772
|
+
* ships. The rejected alternative (one enum member per source, with
|
|
21773
|
+
* a resolver branch each) is what makes "add the humidity too" a
|
|
21774
|
+
* code change.
|
|
21775
|
+
* 2. The display gate reuses `NcConditionsSchema` verbatim — the
|
|
21776
|
+
* notification centre's condition vocabulary — rather than a parallel
|
|
21777
|
+
* model. An operator who has learned one condition editor has learned
|
|
21778
|
+
* both.
|
|
21779
|
+
* 3. Because the renderer's facts are device STATE and not a detection
|
|
21780
|
+
* record, only a SUBSET of that vocabulary can be answered here.
|
|
21781
|
+
* `setSlotBinding` REJECTS the rest at write time (see
|
|
21782
|
+
* `getConditionSupport`). It does not accept-then-fail-closed: a
|
|
21783
|
+
* condition that can never be true renders a permanently blank
|
|
21784
|
+
* overlay, and a blank overlay looks exactly like a broken camera.
|
|
21785
|
+
*/
|
|
21786
|
+
/** Where a slot's value comes from. */
|
|
21787
|
+
var OsdSourceSchema = discriminatedUnion("kind", [
|
|
21788
|
+
object({
|
|
21789
|
+
kind: literal("static"),
|
|
21790
|
+
text: string().max(64)
|
|
21791
|
+
}),
|
|
21792
|
+
object({
|
|
21793
|
+
kind: literal("clock"),
|
|
21794
|
+
/** Token pattern: `YYYY MM DD HH mm ss`. Everything else is literal. */
|
|
21795
|
+
pattern: string().min(1).max(32).default("HH:mm"),
|
|
21796
|
+
/** IANA zone. Omitted = the server's zone. */
|
|
21797
|
+
timezone: string().min(1).max(64).optional()
|
|
21798
|
+
}),
|
|
21799
|
+
object({
|
|
21800
|
+
kind: literal("device-state"),
|
|
21801
|
+
deviceId: number().int().optional(),
|
|
21802
|
+
capName: string().min(1).max(64),
|
|
21803
|
+
/** Dot path inside the slice, e.g. `detected`, `value`, `mode`. */
|
|
21804
|
+
valuePath: string().min(1).max(64)
|
|
21805
|
+
})
|
|
21806
|
+
]);
|
|
21807
|
+
var OsdSlotBindingSchema = object({
|
|
21808
|
+
/** Off = the manager stops driving this slot. It does NOT clear it. */
|
|
21809
|
+
enabled: boolean().default(true),
|
|
21810
|
+
source: OsdSourceSchema,
|
|
21811
|
+
/** `${value}` and `${unit}` are substituted; every occurrence. */
|
|
21812
|
+
template: string().max(96).default("${value}"),
|
|
21813
|
+
/** Truncate with an ellipsis past this length. Absent = no limit. */
|
|
21814
|
+
maxCharacters: number().int().min(4).max(64).optional(),
|
|
21815
|
+
/**
|
|
21816
|
+
* Decimal places for a numeric value. `0` yields an integer — the
|
|
21817
|
+
* documented workaround for firmwares that reject `.` in overlay text.
|
|
21818
|
+
*/
|
|
21819
|
+
maxDecimals: number().int().min(0).max(4).default(1),
|
|
21820
|
+
/** Appended via `${unit}`. The state mirror does not carry units. */
|
|
21821
|
+
unitLabel: string().max(8).optional(),
|
|
21822
|
+
/** Raw value → display text, e.g. `{"true":"MOTION","false":""}`. */
|
|
21823
|
+
valueMap: record(string(), string()).optional(),
|
|
21824
|
+
/** Time windows in which the slot is shown. Absent = always. */
|
|
21825
|
+
schedule: NcScheduleSchema.optional(),
|
|
21826
|
+
/**
|
|
21827
|
+
* Display gate, in the notification centre's condition vocabulary.
|
|
21828
|
+
* Only the keys reported by `getConditionSupport` are accepted.
|
|
21829
|
+
*/
|
|
21830
|
+
conditions: NcConditionsSchema.optional(),
|
|
21831
|
+
/** Rendered when the gate is closed or the value unreadable. Empty = hide. */
|
|
21832
|
+
fallbackText: string().max(64).default("")
|
|
21833
|
+
});
|
|
21834
|
+
/** One camera slot, as the operator sees it: firmware truth + our binding. */
|
|
21835
|
+
var OsdSlotViewSchema = object({
|
|
21836
|
+
slotId: string(),
|
|
21837
|
+
kind: OsdOverlayKindEnum,
|
|
21838
|
+
/** Firmware refuses text edits (a timestamp, the channel name). */
|
|
21839
|
+
readOnly: boolean(),
|
|
21840
|
+
cameraEnabled: boolean(),
|
|
21841
|
+
cameraText: string().optional(),
|
|
21842
|
+
binding: OsdSlotBindingSchema.nullable()
|
|
21843
|
+
});
|
|
21844
|
+
/**
|
|
21845
|
+
* What happened to one slot on one render pass. `unchanged` exists so the
|
|
21846
|
+
* operator can tell "we are driving this and the value is steady" from
|
|
21847
|
+
* "we never got there" — and so the loop can prove it is not rewriting
|
|
21848
|
+
* identical text to the camera every tick.
|
|
21849
|
+
*/
|
|
21850
|
+
var OsdRenderOutcomeEnum = _enum([
|
|
21851
|
+
"written",
|
|
21852
|
+
"unchanged",
|
|
21853
|
+
"gated",
|
|
21854
|
+
"unreadable",
|
|
21855
|
+
"disabled",
|
|
21856
|
+
"unbound",
|
|
21857
|
+
"failed"
|
|
21858
|
+
]);
|
|
21859
|
+
var OsdRenderResultSchema = object({
|
|
21860
|
+
slotId: string(),
|
|
21861
|
+
outcome: OsdRenderOutcomeEnum,
|
|
21862
|
+
/** The text the slot should carry. Empty = the slot is switched off. */
|
|
21863
|
+
text: string(),
|
|
21864
|
+
/** Why, whenever the outcome is not a plain write. Never silent. */
|
|
21865
|
+
reason: string().optional()
|
|
21866
|
+
});
|
|
21867
|
+
var OsdSourceValueTypeEnum = _enum([
|
|
21868
|
+
"number",
|
|
21869
|
+
"boolean",
|
|
21870
|
+
"string",
|
|
21871
|
+
"enum"
|
|
21872
|
+
]);
|
|
21873
|
+
/**
|
|
21874
|
+
* One bindable value, derived from a cap's `runtimeState` schema — never
|
|
21875
|
+
* hand-listed. The editor renders from this, so a cap that ships a new
|
|
21876
|
+
* state field becomes bindable with no UI change.
|
|
21877
|
+
*/
|
|
21878
|
+
var OsdSourceOptionSchema = object({
|
|
21879
|
+
deviceId: number().int(),
|
|
21880
|
+
deviceName: string(),
|
|
21881
|
+
capName: string(),
|
|
21882
|
+
valuePath: string(),
|
|
21883
|
+
label: string(),
|
|
21884
|
+
valueType: OsdSourceValueTypeEnum,
|
|
21885
|
+
/** Present for `enum`; the editor offers these as `valueMap` keys. */
|
|
21886
|
+
enumValues: array(string()).readonly().optional()
|
|
21887
|
+
});
|
|
21888
|
+
method(object({ deviceId: number().int() }), object({
|
|
21889
|
+
supported: boolean(),
|
|
21890
|
+
slots: array(OsdSlotViewSchema)
|
|
21891
|
+
}), { auth: "admin" }), method(object({ deviceId: number().int() }), object({ sources: array(OsdSourceOptionSchema) }), { auth: "admin" }), method(object({}), object({
|
|
21892
|
+
supported: array(string()),
|
|
21893
|
+
catalog: array(NcConditionDescriptorSchema)
|
|
21894
|
+
}), { auth: "admin" }), method(object({
|
|
21895
|
+
deviceId: number().int(),
|
|
21896
|
+
slotId: string().min(1),
|
|
21897
|
+
binding: OsdSlotBindingSchema
|
|
21898
|
+
}), object({
|
|
21899
|
+
slot: OsdSlotViewSchema,
|
|
21900
|
+
render: OsdRenderResultSchema
|
|
21901
|
+
}), {
|
|
21902
|
+
kind: "mutation",
|
|
21903
|
+
auth: "admin"
|
|
21904
|
+
}), method(object({
|
|
21905
|
+
deviceId: number().int(),
|
|
21906
|
+
slotId: string().min(1)
|
|
21907
|
+
}), object({ success: literal(true) }), {
|
|
21908
|
+
kind: "mutation",
|
|
21909
|
+
auth: "admin"
|
|
21910
|
+
}), method(object({
|
|
21911
|
+
deviceId: number().int(),
|
|
21912
|
+
slotId: string().min(1),
|
|
21913
|
+
binding: OsdSlotBindingSchema.optional()
|
|
21914
|
+
}), OsdRenderResultSchema, {
|
|
21915
|
+
kind: "mutation",
|
|
21916
|
+
auth: "admin"
|
|
21917
|
+
}), method(object({ deviceId: number().int() }), object({ results: array(OsdRenderResultSchema) }), {
|
|
21918
|
+
kind: "mutation",
|
|
21919
|
+
auth: "admin"
|
|
21920
|
+
});
|
|
21921
|
+
/**
|
|
20985
21922
|
* Feeder connectivity / power status — mirrors the HA petkit device-status
|
|
20986
21923
|
* enum: `normal` (online, mains), `offline` (not reaching PetKit cloud),
|
|
20987
21924
|
* `on_batteries` (running on battery backup). `null` until first reported.
|
|
@@ -21429,12 +22366,30 @@ object({
|
|
|
21429
22366
|
});
|
|
21430
22367
|
DeviceType.Sensor;
|
|
21431
22368
|
/**
|
|
21432
|
-
*
|
|
21433
|
-
*
|
|
21434
|
-
*
|
|
21435
|
-
*
|
|
21436
|
-
*
|
|
21437
|
-
*
|
|
22369
|
+
* PRIVACY — what the camera deliberately does not capture. Two planes:
|
|
22370
|
+
*
|
|
22371
|
+
* - **video**: up to `maxRegions` SHAPES the camera blanks out (NOT a cell
|
|
22372
|
+
* grid). Reolink `<shelterList>` zones are rectangles; Hikvision ISAPI
|
|
22373
|
+
* `<RegionCoordinatesList>` zones are free polygons (this camera: exactly
|
|
22374
|
+
* 4 vertices, not necessarily axis-aligned). The cap composes the shared
|
|
22375
|
+
* rect|polygon subset of the MaskShape vocabulary. All coords are
|
|
22376
|
+
* normalized 0..1 (top-left origin).
|
|
22377
|
+
* - **audio**: the camera's microphone. `setAudioEnabled(false)` stops the
|
|
22378
|
+
* camera encoding an audio track at all, so EVERY consumer — live view,
|
|
22379
|
+
* recording, the audio analyzer, an export — sees silent video. There is
|
|
22380
|
+
* no server-side copy of this fact; the camera is the store and every read
|
|
22381
|
+
* is a read-through, which is why a switch over it cannot drift
|
|
22382
|
+
* ([D62](../../../../docs/decisions/adr-0062.md)).
|
|
22383
|
+
*
|
|
22384
|
+
* Both belong here for one reason: they are the two things an operator turns
|
|
22385
|
+
* off when the answer to "what is this camera allowed to record" changes, and
|
|
22386
|
+
* both are applied ON the device, before anything leaves it.
|
|
22387
|
+
*
|
|
22388
|
+
* **The audio flag has exactly one writer.** `stream-params` used to carry a
|
|
22389
|
+
* per-profile `audio` in its patch schema — reachable from no UI and honoured
|
|
22390
|
+
* by one provider — and it was removed when this landed. A second writer onto
|
|
22391
|
+
* one device register is the shape of every knob this repo has shipped that
|
|
22392
|
+
* disagreed with the one the reader read.
|
|
21438
22393
|
*/
|
|
21439
22394
|
/** A privacy-mask region's geometry — rectangle or free polygon. */
|
|
21440
22395
|
var PrivacyMaskShapeSchema = discriminatedUnion("kind", [MaskRectShapeSchema, MaskPolygonShapeSchema]);
|
|
@@ -21450,16 +22405,40 @@ object({
|
|
|
21450
22405
|
enabled: boolean(),
|
|
21451
22406
|
/** Active zones (normalized 0..1). Length ≤ maxRegions. */
|
|
21452
22407
|
regions: array(PrivacyMaskRegionSchema),
|
|
22408
|
+
/**
|
|
22409
|
+
* Is the camera capturing sound right now? Read from the camera, never from
|
|
22410
|
+
* a server-side mirror.
|
|
22411
|
+
*
|
|
22412
|
+
* `null` means "no answer" — either this camera exposes no controllable
|
|
22413
|
+
* microphone (`getOptions().supportsAudioMute === false`) or the read
|
|
22414
|
+
* failed. A consumer must render `null` as UNKNOWN and never as `false`:
|
|
22415
|
+
* "the microphone is off" and "we could not ask" look identical to an
|
|
22416
|
+
* operator only until one of them is wrong.
|
|
22417
|
+
*
|
|
22418
|
+
* On a camera whose profiles carry the flag independently (Reolink writes
|
|
22419
|
+
* it per stream), `true` means AT LEAST ONE profile still carries audio —
|
|
22420
|
+
* privacy is only satisfied when every one of them is silent.
|
|
22421
|
+
*/
|
|
22422
|
+
audioEnabled: boolean().nullable(),
|
|
21453
22423
|
lastFetchedAt: number()
|
|
21454
22424
|
});
|
|
21455
|
-
/** Per-camera availability. */
|
|
22425
|
+
/** Per-camera availability. Probed, never assumed from the model name. */
|
|
21456
22426
|
var PrivacyMaskOptionsSchema = object({
|
|
21457
22427
|
/** Maximum number of supported zones. */
|
|
21458
22428
|
maxRegions: number(),
|
|
21459
22429
|
/** Shape kinds this camera accepts — Reolink: ['rect']; Hikvision: ['rect','polygon']. */
|
|
21460
22430
|
supportedShapes: array(MaskShapeKindSchema),
|
|
21461
22431
|
/** Polygon vertex bounds when 'polygon' is supported (Hikvision: {min:4,max:4}). */
|
|
21462
|
-
polygonVertices: MaskPolygonVerticesSchema.optional()
|
|
22432
|
+
polygonVertices: MaskPolygonVerticesSchema.optional(),
|
|
22433
|
+
/**
|
|
22434
|
+
* Does this camera expose a microphone switch we can actually write?
|
|
22435
|
+
*
|
|
22436
|
+
* Camera-probed: `true` only when the firmware answered with an audio flag
|
|
22437
|
+
* we know how to patch. A camera that never answered is `false` — a control
|
|
22438
|
+
* the operator can press that changes nothing is worse than no control, and
|
|
22439
|
+
* the switch group renders "not available" instead.
|
|
22440
|
+
*/
|
|
22441
|
+
supportsAudioMute: boolean()
|
|
21463
22442
|
});
|
|
21464
22443
|
/** Partial change — every field optional. */
|
|
21465
22444
|
var PrivacyMaskPatchSchema = object({
|
|
@@ -21472,6 +22451,12 @@ DeviceType.Camera, method(object({ deviceId: number() }), PrivacyMaskOptionsSche
|
|
|
21472
22451
|
}), _void(), {
|
|
21473
22452
|
kind: "mutation",
|
|
21474
22453
|
auth: "admin"
|
|
22454
|
+
}), method(object({
|
|
22455
|
+
deviceId: number(),
|
|
22456
|
+
enabled: boolean()
|
|
22457
|
+
}), _void(), {
|
|
22458
|
+
kind: "mutation",
|
|
22459
|
+
auth: "admin"
|
|
21475
22460
|
});
|
|
21476
22461
|
var PtzPresetSchema = object({
|
|
21477
22462
|
id: string(),
|
|
@@ -21681,6 +22666,21 @@ var LocateSegmentResultSchema = discriminatedUnion("kind", [object({
|
|
|
21681
22666
|
})]);
|
|
21682
22667
|
/** Raw bytes of one finalized footage segment (read off disk on the recording node). */
|
|
21683
22668
|
var ReadSegmentBytesResultSchema = object({ data: _instanceof(Uint8Array) });
|
|
22669
|
+
/**
|
|
22670
|
+
* One GOP of a finalized segment, cut by byte range through the segment's own
|
|
22671
|
+
* `mfra` (D31 on the D42 feeder path). `data` is the `ftyp`+`moov` head plus
|
|
22672
|
+
* the single `moof`+`mdat` covering the requested instant — standalone-
|
|
22673
|
+
* demuxable, never the whole file. When the segment's index cannot be parsed
|
|
22674
|
+
* the provider degrades INSIDE the mechanism to the whole segment (still one
|
|
22675
|
+
* `data`, `gopStartMs` = the segment start) — a worse read, not another path.
|
|
22676
|
+
*/
|
|
22677
|
+
var ReadGopBytesResultSchema = object({
|
|
22678
|
+
data: _instanceof(Uint8Array),
|
|
22679
|
+
/** Absolute epoch ms of the returned fragment's first sample. */
|
|
22680
|
+
gopStartMs: number(),
|
|
22681
|
+
/** Media ms the returned fragment covers. */
|
|
22682
|
+
gopDurMs: number()
|
|
22683
|
+
});
|
|
21684
22684
|
method(object({
|
|
21685
22685
|
deviceId: number(),
|
|
21686
22686
|
fromMs: number(),
|
|
@@ -21723,6 +22723,14 @@ method(object({
|
|
|
21723
22723
|
}), ReadSegmentBytesResultSchema, {
|
|
21724
22724
|
kind: "query",
|
|
21725
22725
|
auth: "admin"
|
|
22726
|
+
}), method(object({
|
|
22727
|
+
deviceId: number(),
|
|
22728
|
+
profile: string(),
|
|
22729
|
+
startMs: number(),
|
|
22730
|
+
epochMs: number()
|
|
22731
|
+
}), ReadGopBytesResultSchema, {
|
|
22732
|
+
kind: "query",
|
|
22733
|
+
auth: "admin"
|
|
21726
22734
|
}), method(object({
|
|
21727
22735
|
deviceId: number(),
|
|
21728
22736
|
config: RecordingConfigSchema
|
|
@@ -22203,6 +23211,16 @@ var StreamProfileConfigSchema = object({
|
|
|
22203
23211
|
"baseline"
|
|
22204
23212
|
]).optional(),
|
|
22205
23213
|
gop: number().optional(),
|
|
23214
|
+
/**
|
|
23215
|
+
* Whether THIS profile currently carries an audio track. READ-ONLY here.
|
|
23216
|
+
*
|
|
23217
|
+
* There is no matching field on {@link StreamProfilePatchSchema}: the
|
|
23218
|
+
* camera's microphone is owned by `privacy-mask` (`setAudioEnabled`), which
|
|
23219
|
+
* writes every profile at once so "audio off" means silent everywhere. A
|
|
23220
|
+
* per-profile writer beside it would let a camera be half-muted and would be
|
|
23221
|
+
* a second knob onto one device register — the failure D62 exists to
|
|
23222
|
+
* prevent. Absent when the firmware does not report the flag.
|
|
23223
|
+
*/
|
|
22206
23224
|
audio: boolean().optional()
|
|
22207
23225
|
});
|
|
22208
23226
|
object({
|
|
@@ -22243,7 +23261,13 @@ var StreamParamsOptionsSchema = object({
|
|
|
22243
23261
|
ext: StreamProfileOptionsSchema.optional()
|
|
22244
23262
|
});
|
|
22245
23263
|
/** A partial change to one profile — every field optional; a provider
|
|
22246
|
-
* ignores fields it doesn't support.
|
|
23264
|
+
* ignores fields it doesn't support.
|
|
23265
|
+
*
|
|
23266
|
+
* There is deliberately NO `audio` here. It existed until 2026-08-07,
|
|
23267
|
+
* reachable from no form and honoured by exactly one provider, while the
|
|
23268
|
+
* camera's microphone is a whole-device fact. It now has one writer,
|
|
23269
|
+
* `privacyMask.setAudioEnabled`, which writes every profile — see
|
|
23270
|
+
* `privacy-mask.cap.ts`. */
|
|
22247
23271
|
var StreamProfilePatchSchema = object({
|
|
22248
23272
|
width: number().optional(),
|
|
22249
23273
|
height: number().optional(),
|
|
@@ -22256,8 +23280,7 @@ var StreamProfilePatchSchema = object({
|
|
|
22256
23280
|
"main",
|
|
22257
23281
|
"baseline"
|
|
22258
23282
|
]).optional(),
|
|
22259
|
-
gop: number().optional()
|
|
22260
|
-
audio: boolean().optional()
|
|
23283
|
+
gop: number().optional()
|
|
22261
23284
|
});
|
|
22262
23285
|
DeviceType.Camera, method(object({ deviceId: number() }), StreamParamsOptionsSchema), method(object({
|
|
22263
23286
|
deviceId: number(),
|
|
@@ -25851,6 +26874,48 @@ Object.freeze({
|
|
|
25851
26874
|
addonId: null,
|
|
25852
26875
|
access: "create"
|
|
25853
26876
|
},
|
|
26877
|
+
"osdManager.clearSlotBinding": {
|
|
26878
|
+
capName: "osd-manager",
|
|
26879
|
+
capScope: "system",
|
|
26880
|
+
addonId: null,
|
|
26881
|
+
access: "delete"
|
|
26882
|
+
},
|
|
26883
|
+
"osdManager.getConditionSupport": {
|
|
26884
|
+
capName: "osd-manager",
|
|
26885
|
+
capScope: "system",
|
|
26886
|
+
addonId: null,
|
|
26887
|
+
access: "view"
|
|
26888
|
+
},
|
|
26889
|
+
"osdManager.getDeviceOsd": {
|
|
26890
|
+
capName: "osd-manager",
|
|
26891
|
+
capScope: "system",
|
|
26892
|
+
addonId: null,
|
|
26893
|
+
access: "view"
|
|
26894
|
+
},
|
|
26895
|
+
"osdManager.getSourceCatalog": {
|
|
26896
|
+
capName: "osd-manager",
|
|
26897
|
+
capScope: "system",
|
|
26898
|
+
addonId: null,
|
|
26899
|
+
access: "view"
|
|
26900
|
+
},
|
|
26901
|
+
"osdManager.previewSlot": {
|
|
26902
|
+
capName: "osd-manager",
|
|
26903
|
+
capScope: "system",
|
|
26904
|
+
addonId: null,
|
|
26905
|
+
access: "create"
|
|
26906
|
+
},
|
|
26907
|
+
"osdManager.renderDevice": {
|
|
26908
|
+
capName: "osd-manager",
|
|
26909
|
+
capScope: "system",
|
|
26910
|
+
addonId: null,
|
|
26911
|
+
access: "create"
|
|
26912
|
+
},
|
|
26913
|
+
"osdManager.setSlotBinding": {
|
|
26914
|
+
capName: "osd-manager",
|
|
26915
|
+
capScope: "system",
|
|
26916
|
+
addonId: null,
|
|
26917
|
+
access: "create"
|
|
26918
|
+
},
|
|
25854
26919
|
"petFeeder.callPet": {
|
|
25855
26920
|
capName: "pet-feeder",
|
|
25856
26921
|
capScope: "device",
|
|
@@ -26013,6 +27078,18 @@ Object.freeze({
|
|
|
26013
27078
|
addonId: null,
|
|
26014
27079
|
access: "view"
|
|
26015
27080
|
},
|
|
27081
|
+
"pipelineAnalytics.getTrainingExportSummary": {
|
|
27082
|
+
capName: "pipeline-analytics",
|
|
27083
|
+
capScope: "device",
|
|
27084
|
+
addonId: null,
|
|
27085
|
+
access: "view"
|
|
27086
|
+
},
|
|
27087
|
+
"pipelineAnalytics.getTrainingExportUrl": {
|
|
27088
|
+
capName: "pipeline-analytics",
|
|
27089
|
+
capScope: "device",
|
|
27090
|
+
addonId: null,
|
|
27091
|
+
access: "view"
|
|
27092
|
+
},
|
|
26016
27093
|
"pipelineAnalytics.listEventKinds": {
|
|
26017
27094
|
capName: "pipeline-analytics",
|
|
26018
27095
|
capScope: "device",
|
|
@@ -26769,6 +27846,12 @@ Object.freeze({
|
|
|
26769
27846
|
addonId: null,
|
|
26770
27847
|
access: "view"
|
|
26771
27848
|
},
|
|
27849
|
+
"privacyMask.setAudioEnabled": {
|
|
27850
|
+
capName: "privacy-mask",
|
|
27851
|
+
capScope: "device",
|
|
27852
|
+
addonId: null,
|
|
27853
|
+
access: "create"
|
|
27854
|
+
},
|
|
26772
27855
|
"privacyMask.setMask": {
|
|
26773
27856
|
capName: "privacy-mask",
|
|
26774
27857
|
capScope: "device",
|
|
@@ -26937,6 +28020,12 @@ Object.freeze({
|
|
|
26937
28020
|
addonId: null,
|
|
26938
28021
|
access: "create"
|
|
26939
28022
|
},
|
|
28023
|
+
"recording.readGopBytes": {
|
|
28024
|
+
capName: "recording",
|
|
28025
|
+
capScope: "system",
|
|
28026
|
+
addonId: null,
|
|
28027
|
+
access: "view"
|
|
28028
|
+
},
|
|
26940
28029
|
"recording.readSegmentBytes": {
|
|
26941
28030
|
capName: "recording",
|
|
26942
28031
|
capScope: "system",
|
|
@@ -27477,6 +28566,12 @@ Object.freeze({
|
|
|
27477
28566
|
addonId: null,
|
|
27478
28567
|
access: "view"
|
|
27479
28568
|
},
|
|
28569
|
+
"streamBroker.getDeviceAudioMute": {
|
|
28570
|
+
capName: "stream-broker",
|
|
28571
|
+
capScope: "system",
|
|
28572
|
+
addonId: null,
|
|
28573
|
+
access: "view"
|
|
28574
|
+
},
|
|
27480
28575
|
"streamBroker.getPreBufferInfo": {
|
|
27481
28576
|
capName: "stream-broker",
|
|
27482
28577
|
capScope: "system",
|
|
@@ -27597,6 +28692,12 @@ Object.freeze({
|
|
|
27597
28692
|
addonId: null,
|
|
27598
28693
|
access: "create"
|
|
27599
28694
|
},
|
|
28695
|
+
"streamBroker.setDeviceAudioMute": {
|
|
28696
|
+
capName: "stream-broker",
|
|
28697
|
+
capScope: "system",
|
|
28698
|
+
addonId: null,
|
|
28699
|
+
access: "create"
|
|
28700
|
+
},
|
|
27600
28701
|
"streamBroker.setPreBufferDuration": {
|
|
27601
28702
|
capName: "stream-broker",
|
|
27602
28703
|
capScope: "system",
|
|
@@ -28358,6 +29459,88 @@ object({
|
|
|
28358
29459
|
square: false
|
|
28359
29460
|
}).paddingRatio;
|
|
28360
29461
|
/**
|
|
29462
|
+
* WHICH delivered frames the decode worker retains a native copy of.
|
|
29463
|
+
*
|
|
29464
|
+
* - `all` — every frame the worker delivered to the runner. The shipped
|
|
29465
|
+
* behaviour, and the only correct one if something can ask for a crop of a
|
|
29466
|
+
* frame the runner never sent to inference.
|
|
29467
|
+
* - `inferred` — only the frames the runner ADMITTED to its detection queue.
|
|
29468
|
+
* A native-crop request always names a `frameId` that rode an inference
|
|
29469
|
+
* result, so that is the only set a request can name. How much it drops is
|
|
29470
|
+
* the two-plane governor's admit ratio and nothing else: measured at ~50% on
|
|
29471
|
+
* this cluster, not the ~80% the design sketch assumed, because the governor
|
|
29472
|
+
* was not throttling as hard as the sketch supposed. Read
|
|
29473
|
+
* `leaseAdmitted`/`leaseOffered` off the metrics line for the camera in front
|
|
29474
|
+
* of you rather than quoting a number from here. The newest delivered frame is
|
|
29475
|
+
* croppable regardless — it is still the worker's reserved slot, not a lease —
|
|
29476
|
+
* which covers the one-frame race between a mark and the supersede that
|
|
29477
|
+
* consumes it.
|
|
29478
|
+
*/
|
|
29479
|
+
var NativeLeaseAdmissionSchema = _enum(["all", "inferred"]);
|
|
29480
|
+
object({
|
|
29481
|
+
/**
|
|
29482
|
+
* How long a retained native frame is served before it counts as a miss.
|
|
29483
|
+
*
|
|
29484
|
+
* Must cover the FULL late-crop horizon: detection inference + the
|
|
29485
|
+
* cross-process inference-result hop to hub post-analysis + tracking + the
|
|
29486
|
+
* tRPC crop round-trip back. Below ~500 ms the busiest cameras' subject crops
|
|
29487
|
+
* outrun it and fall back to the ≤640 detection frame; above ~3 s the resident
|
|
29488
|
+
* RAM per busy camera grows linearly with no measured hit-rate gain.
|
|
29489
|
+
*/
|
|
29490
|
+
ttlMs: number().int().min(250).max(1e4),
|
|
29491
|
+
/**
|
|
29492
|
+
* Hard per-decode-worker RAM ceiling for retained native frames, in MB.
|
|
29493
|
+
*
|
|
29494
|
+
* Intended as a SAFETY ceiling with the TTL as the effective cap — but check
|
|
29495
|
+
* which one is actually binding before reasoning from that. At the shipped
|
|
29496
|
+
* 1024 MB and a 2 800 ms TTL, a 4K camera hits the CEILING first (~43 frames
|
|
29497
|
+
* at ~24 MB each) and the TTL never gets to expire anything; `leaseMb` /
|
|
29498
|
+
* `leaseFrames` on the metrics line say which. When the ceiling binds, a
|
|
29499
|
+
* change that admits fewer frames buys retention WINDOW at constant RAM
|
|
29500
|
+
* rather than giving RAM back — lower this knob if RAM is what you wanted.
|
|
29501
|
+
* `0` DISABLES the lease entirely and falls the worker back to the tiny
|
|
29502
|
+
* leak-prone GPU surface ring (~85% crop miss; that is what the lease exists
|
|
29503
|
+
* to replace).
|
|
29504
|
+
*/
|
|
29505
|
+
budgetMb: number().int().min(0).max(4096),
|
|
29506
|
+
/**
|
|
29507
|
+
* Demand window: eager per-frame native retention runs only within this many
|
|
29508
|
+
* ms of the last native-crop request (or of the dial starting).
|
|
29509
|
+
*
|
|
29510
|
+
* `0` means ALWAYS ON — it disables the gate, it does not disable retention.
|
|
29511
|
+
* That is the legacy behaviour that saturated an N100 (24 native-4K downloads
|
|
29512
|
+
* per second on a camera with zero crop demand), so leave it non-zero unless
|
|
29513
|
+
* you are reproducing that.
|
|
29514
|
+
*/
|
|
29515
|
+
activityMs: number().int().min(0).max(12e4),
|
|
29516
|
+
/**
|
|
29517
|
+
* Which delivered frames are retained at all — see
|
|
29518
|
+
* {@link NativeLeaseAdmissionSchema}. This is the only knob of the four that
|
|
29519
|
+
* changes WHAT is kept rather than for how long, so it is also the only one
|
|
29520
|
+
* that can turn a crop that used to hit into a miss. The worker counts every
|
|
29521
|
+
* crop request naming a frame it did NOT see marked
|
|
29522
|
+
* (`leaseUnmarkedCrops` on the session-decode metrics line): a non-zero value
|
|
29523
|
+
* there is the signal that some caller names frames outside the inference set
|
|
29524
|
+
* and that this must go back to `all`.
|
|
29525
|
+
*/
|
|
29526
|
+
admission: NativeLeaseAdmissionSchema
|
|
29527
|
+
});
|
|
29528
|
+
/**
|
|
29529
|
+
* The values in force when the operator has set nothing — byte-for-byte the
|
|
29530
|
+
* constants the decode worker shipped with as env-var defaults, so making these
|
|
29531
|
+
* settings changed no behaviour on the day it landed.
|
|
29532
|
+
*/
|
|
29533
|
+
var DEFAULT_NATIVE_LEASE_SETTINGS = {
|
|
29534
|
+
ttlMs: 1200,
|
|
29535
|
+
budgetMb: 1024,
|
|
29536
|
+
activityMs: 15e3,
|
|
29537
|
+
admission: "inferred"
|
|
29538
|
+
};
|
|
29539
|
+
DEFAULT_NATIVE_LEASE_SETTINGS.ttlMs;
|
|
29540
|
+
DEFAULT_NATIVE_LEASE_SETTINGS.budgetMb;
|
|
29541
|
+
DEFAULT_NATIVE_LEASE_SETTINGS.activityMs;
|
|
29542
|
+
DEFAULT_NATIVE_LEASE_SETTINGS.admission;
|
|
29543
|
+
/**
|
|
28361
29544
|
* Compute the stable 64-char lowercase-hex fingerprint of a device's
|
|
28362
29545
|
* export-relevant shape. Two structurally-equal shapes (any feature order,
|
|
28363
29546
|
* any duplicates, any deviceId) hash identically.
|
|
@@ -28383,6 +29566,506 @@ function resolveExportFingerprint(input) {
|
|
|
28383
29566
|
if (input.ready) return input.fresh;
|
|
28384
29567
|
return input.persisted ?? input.fresh;
|
|
28385
29568
|
}
|
|
29569
|
+
/**
|
|
29570
|
+
* Fmp4FragmentPlane — a SUBSCRIBABLE fragmented-MP4 plane, fed by one
|
|
29571
|
+
* {@link import('./fmp4-box-splitter.js').Fmp4BoxSplitter}.
|
|
29572
|
+
*
|
|
29573
|
+
* ## Why a plane and not a callback
|
|
29574
|
+
*
|
|
29575
|
+
* The operator's requirement for HKSV was explicit: the live fMP4 source built
|
|
29576
|
+
* for it must be **dual-use**, so a HomeKit-triggered recording also lands in
|
|
29577
|
+
* CamStack as an additional videoclip source alongside the recorder and the NC
|
|
29578
|
+
* clip ring — *one fragmenter, two consumers; do not build an HKSV-only pipe*
|
|
29579
|
+
* (`docs/roadmap.md` item 4b). A single-callback pipe makes the second consumer
|
|
29580
|
+
* a second ffmpeg child of the same camera. So this is the same shape the
|
|
29581
|
+
* broker's other multi-consumer surfaces already have
|
|
29582
|
+
* (`AudioChunkPlane`, the push packet plane): N independent subscriptions over
|
|
29583
|
+
* one producer.
|
|
29584
|
+
*
|
|
29585
|
+
* **Nothing consumes it yet.** Phase 4 brings the HKSV delegate and phase 4b the
|
|
29586
|
+
* clip source; both are named here so the seam is not re-invented, and neither
|
|
29587
|
+
* is built.
|
|
29588
|
+
*
|
|
29589
|
+
* ## The init segment is RETAINED
|
|
29590
|
+
*
|
|
29591
|
+
* A subscriber that attaches mid-stream — the clip consumer joining an already
|
|
29592
|
+
* running HKSV session, which is the whole dual-use case — receives the
|
|
29593
|
+
* retained `ftyp`+`moov` as its first packet and then live fragments. Without
|
|
29594
|
+
* retention its fragments are undecodable and the failure looks like a codec
|
|
29595
|
+
* problem.
|
|
29596
|
+
*
|
|
29597
|
+
* ## A slow subscriber is CLOSED, never silently gapped
|
|
29598
|
+
*
|
|
29599
|
+
* `AudioChunkPlane` drops its oldest chunk on overflow, which for audio costs a
|
|
29600
|
+
* click. An fMP4 stream with a hole is not a shorter clip, it is a corrupt one:
|
|
29601
|
+
* `moof` sequence numbers jump, the consumer's demuxer desynchronises, and HKSV
|
|
29602
|
+
* shows a clip that fails to play with nothing anywhere saying why. So a
|
|
29603
|
+
* subscription whose queue overflows is ENDED with a reason, loudly, and the
|
|
29604
|
+
* other subscriptions are untouched.
|
|
29605
|
+
*
|
|
29606
|
+
* ## The PREBUFFER (phase 3)
|
|
29607
|
+
*
|
|
29608
|
+
* HKSV asks for context BEFORE the trigger — `CameraRecordingOptions.prebufferLength`
|
|
29609
|
+
* is a HAP-mandated minimum of 4000 ms — and a subscriber that attaches at the
|
|
29610
|
+
* motion edge has none. So the plane optionally retains the last few fragments
|
|
29611
|
+
* and replays them to a subscriber that asks for them.
|
|
29612
|
+
*
|
|
29613
|
+
* Three things this ring gets right, each of which is a measured fact rather
|
|
29614
|
+
* than a preference (see [D84](../../../../docs/decisions/adr-0084.md)):
|
|
29615
|
+
*
|
|
29616
|
+
* - **It is bounded by TIME *and* BYTES.** On the live fleet a 720p copy
|
|
29617
|
+
* fragment is ~255 KB and a 4K one is ~6.35 MB — a 25× spread over the same
|
|
29618
|
+
* window. A time-only bound is a per-camera RAM figure nobody can predict.
|
|
29619
|
+
* - **The window is measured on ARRIVAL, not parsed from `tfdt`.** The
|
|
29620
|
+
* splitter deliberately never computes a fragment's duration (a second
|
|
29621
|
+
* opinion about a fact the muxer owns), and a prebuffer cares about how long
|
|
29622
|
+
* ago the bytes turned up, which is exactly what arrival time answers.
|
|
29623
|
+
* - **A replay is not backlog.** A subscriber taking N retained fragments gets
|
|
29624
|
+
* its queue capacity raised by N for them, because closing a subscriber as a
|
|
29625
|
+
* slow consumer for the prebuffer it explicitly asked for would be the
|
|
29626
|
+
* stupidest possible failure — and, with `DEFAULT_QUEUE_CAPACITY` of 4 and a
|
|
29627
|
+
* ring of 4, the guaranteed one.
|
|
29628
|
+
*
|
|
29629
|
+
* ## `isLast`
|
|
29630
|
+
*
|
|
29631
|
+
* hap-nodejs requires the delegate to mark exactly one `RecordingPacket` with
|
|
29632
|
+
* `isLast` — a generator that finishes without it produces the twelve-second
|
|
29633
|
+
* timeout loop [D50](../../../../../docs/decisions/adr-0050.md) deleted. The
|
|
29634
|
+
* plane therefore computes it at DELIVERY time: a packet is last when the plane
|
|
29635
|
+
* has ended and nothing remains queued behind it. A subscription that ends
|
|
29636
|
+
* having delivered NOTHING says so through {@link Fmp4Subscription.delivered};
|
|
29637
|
+
* the future delegate must not open an HDS stream it cannot feed.
|
|
29638
|
+
*/
|
|
29639
|
+
var DEFAULT_QUEUE_CAPACITY = 4;
|
|
29640
|
+
var Fmp4FragmentPlane = class {
|
|
29641
|
+
logger;
|
|
29642
|
+
prebuffer;
|
|
29643
|
+
now;
|
|
29644
|
+
subscriptions = /* @__PURE__ */ new Map();
|
|
29645
|
+
/** The last init unit seen, handed to every later subscriber. */
|
|
29646
|
+
retainedInit = null;
|
|
29647
|
+
ended = false;
|
|
29648
|
+
/** Oldest first. Empty unless {@link Fmp4PrebufferOptions} was supplied. */
|
|
29649
|
+
ring = [];
|
|
29650
|
+
ringBytes = 0;
|
|
29651
|
+
constructor(logger, prebuffer, now = Date.now) {
|
|
29652
|
+
this.logger = logger;
|
|
29653
|
+
this.prebuffer = prebuffer;
|
|
29654
|
+
this.now = now;
|
|
29655
|
+
}
|
|
29656
|
+
get subscriberCount() {
|
|
29657
|
+
return this.subscriptions.size;
|
|
29658
|
+
}
|
|
29659
|
+
/** True once {@link end} has been called — no further units are accepted. */
|
|
29660
|
+
get isEnded() {
|
|
29661
|
+
return this.ended;
|
|
29662
|
+
}
|
|
29663
|
+
/** What the prebuffer ring holds right now. All zeroes when disabled. */
|
|
29664
|
+
prebufferStats() {
|
|
29665
|
+
const oldest = this.ring[0];
|
|
29666
|
+
return {
|
|
29667
|
+
fragments: this.ring.length,
|
|
29668
|
+
bytes: this.ringBytes,
|
|
29669
|
+
spanMs: oldest === void 0 ? 0 : this.now() - oldest.arrivedAt
|
|
29670
|
+
};
|
|
29671
|
+
}
|
|
29672
|
+
subscribe(input) {
|
|
29673
|
+
const replay = input.withPrebuffer === true ? this.trimmedRing() : [];
|
|
29674
|
+
const requested = Math.max(1, input.queueCapacity ?? DEFAULT_QUEUE_CAPACITY);
|
|
29675
|
+
const sub = {
|
|
29676
|
+
id: `fmp4-${(0, node_crypto.randomUUID)()}`,
|
|
29677
|
+
tag: input.tag,
|
|
29678
|
+
subscribedAt: this.now(),
|
|
29679
|
+
capacity: requested + replay.length,
|
|
29680
|
+
queue: [],
|
|
29681
|
+
delivered: 0,
|
|
29682
|
+
closedReason: null,
|
|
29683
|
+
wake: null,
|
|
29684
|
+
iterating: false
|
|
29685
|
+
};
|
|
29686
|
+
this.subscriptions.set(sub.id, sub);
|
|
29687
|
+
if (this.retainedInit !== null) this.enqueue(sub, this.retainedInit);
|
|
29688
|
+
for (const retained of replay) this.enqueue(sub, retained.unit);
|
|
29689
|
+
if (this.ended) this.closeSubscription(sub, "ended");
|
|
29690
|
+
this.logger?.info("fmp4 plane: subscribed", { meta: {
|
|
29691
|
+
subscriptionId: sub.id,
|
|
29692
|
+
tag: sub.tag,
|
|
29693
|
+
hasRetainedInit: this.retainedInit !== null,
|
|
29694
|
+
prebufferFragments: replay.length,
|
|
29695
|
+
prebufferBytes: replay.reduce((n, r) => n + r.unit.data.length, 0)
|
|
29696
|
+
} });
|
|
29697
|
+
return this.facade(sub);
|
|
29698
|
+
}
|
|
29699
|
+
/**
|
|
29700
|
+
* Fan one splitter unit out. An `init` REPLACES the retained one — ffmpeg
|
|
29701
|
+
* emits exactly one per child, and a second means the child was respawned, in
|
|
29702
|
+
* which case the old one describes a stream that no longer exists.
|
|
29703
|
+
*/
|
|
29704
|
+
publish(unit) {
|
|
29705
|
+
if (this.ended) return;
|
|
29706
|
+
if (unit.kind === "init") {
|
|
29707
|
+
this.retainedInit = unit;
|
|
29708
|
+
this.ring.length = 0;
|
|
29709
|
+
this.ringBytes = 0;
|
|
29710
|
+
} else this.retain(unit);
|
|
29711
|
+
for (const sub of this.subscriptions.values()) {
|
|
29712
|
+
if (sub.closedReason !== null) continue;
|
|
29713
|
+
this.enqueue(sub, unit);
|
|
29714
|
+
}
|
|
29715
|
+
}
|
|
29716
|
+
/**
|
|
29717
|
+
* The producer stopped. Every subscriber drains what it holds; its final
|
|
29718
|
+
* packet carries `isLast`, and its generator then completes.
|
|
29719
|
+
*/
|
|
29720
|
+
end(reason = "producer ended") {
|
|
29721
|
+
if (this.ended) return;
|
|
29722
|
+
this.ended = true;
|
|
29723
|
+
this.logger?.info("fmp4 plane: ended", { meta: {
|
|
29724
|
+
reason,
|
|
29725
|
+
subscribers: this.subscriptions.size
|
|
29726
|
+
} });
|
|
29727
|
+
for (const sub of this.subscriptions.values()) if (sub.closedReason === null) this.closeSubscription(sub, "ended");
|
|
29728
|
+
}
|
|
29729
|
+
listSubscribers() {
|
|
29730
|
+
return [...this.subscriptions.values()].map((s) => ({
|
|
29731
|
+
tag: s.tag,
|
|
29732
|
+
subscribedAt: s.subscribedAt,
|
|
29733
|
+
delivered: s.delivered,
|
|
29734
|
+
closedReason: s.closedReason
|
|
29735
|
+
}));
|
|
29736
|
+
}
|
|
29737
|
+
/** End and forget everything. Idempotent. */
|
|
29738
|
+
dispose() {
|
|
29739
|
+
this.end("disposed");
|
|
29740
|
+
this.subscriptions.clear();
|
|
29741
|
+
this.retainedInit = null;
|
|
29742
|
+
this.ring.length = 0;
|
|
29743
|
+
this.ringBytes = 0;
|
|
29744
|
+
}
|
|
29745
|
+
/**
|
|
29746
|
+
* Add one fragment to the ring and evict from the front until BOTH bounds
|
|
29747
|
+
* hold. Eviction is oldest-first, which is the one place in this file where
|
|
29748
|
+
* dropping is correct: the ring is context, not stream — nobody is mid-decode
|
|
29749
|
+
* on it, and a subscriber only ever receives a contiguous tail of it.
|
|
29750
|
+
*/
|
|
29751
|
+
retain(unit) {
|
|
29752
|
+
const prebuffer = this.prebuffer;
|
|
29753
|
+
if (prebuffer === void 0) return;
|
|
29754
|
+
const arrivedAt = this.now();
|
|
29755
|
+
this.ring.push({
|
|
29756
|
+
unit,
|
|
29757
|
+
arrivedAt
|
|
29758
|
+
});
|
|
29759
|
+
this.ringBytes += unit.data.length;
|
|
29760
|
+
const cutoff = arrivedAt - prebuffer.windowMs;
|
|
29761
|
+
while (this.ring.length > 0) {
|
|
29762
|
+
const oldest = this.ring[0];
|
|
29763
|
+
if (oldest === void 0) break;
|
|
29764
|
+
const tooOld = oldest.arrivedAt < cutoff;
|
|
29765
|
+
const tooBig = this.ringBytes > prebuffer.maxBytes;
|
|
29766
|
+
if (!tooOld && !tooBig || this.ring.length === 1) break;
|
|
29767
|
+
this.ring.shift();
|
|
29768
|
+
this.ringBytes -= oldest.unit.data.length;
|
|
29769
|
+
}
|
|
29770
|
+
}
|
|
29771
|
+
/**
|
|
29772
|
+
* The ring as a subscriber should receive it — window applied AT SUBSCRIBE
|
|
29773
|
+
* time, not only at publish time. A camera that went quiet keeps its last
|
|
29774
|
+
* fragment in the ring indefinitely (see the never-evict-the-newest rule),
|
|
29775
|
+
* and replaying a 40-second-old fragment as "prebuffer" would put stale video
|
|
29776
|
+
* at the head of a clip iOS presents as the moment of the event.
|
|
29777
|
+
*/
|
|
29778
|
+
trimmedRing() {
|
|
29779
|
+
const prebuffer = this.prebuffer;
|
|
29780
|
+
if (prebuffer === void 0) return [];
|
|
29781
|
+
const cutoff = this.now() - prebuffer.windowMs;
|
|
29782
|
+
return this.ring.filter((r) => r.arrivedAt >= cutoff);
|
|
29783
|
+
}
|
|
29784
|
+
enqueue(sub, unit) {
|
|
29785
|
+
if (sub.queue.length >= sub.capacity) {
|
|
29786
|
+
this.logger?.warn("fmp4 plane: subscriber fell behind — CLOSING it rather than gapping it", { meta: {
|
|
29787
|
+
subscriptionId: sub.id,
|
|
29788
|
+
tag: sub.tag,
|
|
29789
|
+
capacity: sub.capacity,
|
|
29790
|
+
delivered: sub.delivered
|
|
29791
|
+
} });
|
|
29792
|
+
this.closeSubscription(sub, "slow-consumer");
|
|
29793
|
+
return;
|
|
29794
|
+
}
|
|
29795
|
+
sub.queue.push({
|
|
29796
|
+
kind: unit.kind,
|
|
29797
|
+
data: unit.data,
|
|
29798
|
+
sequence: unit.sequence,
|
|
29799
|
+
isLast: false
|
|
29800
|
+
});
|
|
29801
|
+
this.wake(sub);
|
|
29802
|
+
}
|
|
29803
|
+
closeSubscription(sub, reason) {
|
|
29804
|
+
if (sub.closedReason !== null) return;
|
|
29805
|
+
sub.closedReason = reason;
|
|
29806
|
+
if (reason === "slow-consumer") sub.queue.length = 0;
|
|
29807
|
+
this.wake(sub);
|
|
29808
|
+
}
|
|
29809
|
+
wake(sub) {
|
|
29810
|
+
const resume = sub.wake;
|
|
29811
|
+
sub.wake = null;
|
|
29812
|
+
resume?.();
|
|
29813
|
+
}
|
|
29814
|
+
facade(sub) {
|
|
29815
|
+
const plane = this;
|
|
29816
|
+
return {
|
|
29817
|
+
id: sub.id,
|
|
29818
|
+
tag: sub.tag,
|
|
29819
|
+
get delivered() {
|
|
29820
|
+
return sub.delivered;
|
|
29821
|
+
},
|
|
29822
|
+
get closedReason() {
|
|
29823
|
+
return sub.closedReason;
|
|
29824
|
+
},
|
|
29825
|
+
packets: () => plane.iterate(sub),
|
|
29826
|
+
release: () => {
|
|
29827
|
+
plane.closeSubscription(sub, "released");
|
|
29828
|
+
plane.subscriptions.delete(sub.id);
|
|
29829
|
+
}
|
|
29830
|
+
};
|
|
29831
|
+
}
|
|
29832
|
+
async *iterate(sub) {
|
|
29833
|
+
if (sub.iterating) throw new Error(`fmp4 plane: subscription ${sub.tag} is already being consumed — take a second subscription`);
|
|
29834
|
+
sub.iterating = true;
|
|
29835
|
+
for (;;) {
|
|
29836
|
+
const next = sub.queue.shift();
|
|
29837
|
+
if (next === void 0) {
|
|
29838
|
+
if (sub.closedReason !== null) return;
|
|
29839
|
+
await new Promise((resolve) => {
|
|
29840
|
+
sub.wake = resolve;
|
|
29841
|
+
});
|
|
29842
|
+
continue;
|
|
29843
|
+
}
|
|
29844
|
+
const isLast = sub.closedReason === "ended" && sub.queue.length === 0;
|
|
29845
|
+
sub.delivered += 1;
|
|
29846
|
+
yield {
|
|
29847
|
+
...next,
|
|
29848
|
+
isLast
|
|
29849
|
+
};
|
|
29850
|
+
if (isLast) return;
|
|
29851
|
+
}
|
|
29852
|
+
}
|
|
29853
|
+
};
|
|
29854
|
+
var DEFAULT_FIRST_UNIT_TIMEOUT_MS = 12e3;
|
|
29855
|
+
/** Heartbeat cadence — ~2 minutes of 4 s fragments. */
|
|
29856
|
+
var FRAGMENT_LOG_EVERY = 30;
|
|
29857
|
+
var Fmp4FragmentChild = class {
|
|
29858
|
+
deps;
|
|
29859
|
+
args;
|
|
29860
|
+
child = null;
|
|
29861
|
+
splitter = new Fmp4BoxSplitter();
|
|
29862
|
+
stopped = false;
|
|
29863
|
+
unitsOut = 0;
|
|
29864
|
+
activeHwAccel = null;
|
|
29865
|
+
constructor(deps, args) {
|
|
29866
|
+
this.deps = deps;
|
|
29867
|
+
this.args = args;
|
|
29868
|
+
}
|
|
29869
|
+
/** Spawn, and resolve once the INIT segment has been cut out of stdout. */
|
|
29870
|
+
async start() {
|
|
29871
|
+
const requested = this.args.invocation.decodeHwAccel;
|
|
29872
|
+
this.activeHwAccel = requested;
|
|
29873
|
+
try {
|
|
29874
|
+
await this.spawnAttempt(requested);
|
|
29875
|
+
return;
|
|
29876
|
+
} catch (err) {
|
|
29877
|
+
if (this.stopped) throw err;
|
|
29878
|
+
if (requested === null || isSoftwareDecode(requested)) throw err;
|
|
29879
|
+
this.deps.logger.warn("fmp4 fragment child: hardware decode produced NO fragment — retrying in SOFTWARE", {
|
|
29880
|
+
tags: { deviceId: this.args.deviceId },
|
|
29881
|
+
meta: {
|
|
29882
|
+
sourceId: this.args.sourceId,
|
|
29883
|
+
decodeHwAccel: requested,
|
|
29884
|
+
error: errMsg$12(err)
|
|
29885
|
+
}
|
|
29886
|
+
});
|
|
29887
|
+
this.killChild();
|
|
29888
|
+
this.splitter = new Fmp4BoxSplitter();
|
|
29889
|
+
this.activeHwAccel = null;
|
|
29890
|
+
await this.spawnAttempt(null);
|
|
29891
|
+
}
|
|
29892
|
+
}
|
|
29893
|
+
/** The backend the child ACTUALLY ran with — `null` for software. */
|
|
29894
|
+
activeDecodeHwAccel() {
|
|
29895
|
+
const value = this.activeHwAccel;
|
|
29896
|
+
return value === null || value === "none" || value === "copy" ? null : value;
|
|
29897
|
+
}
|
|
29898
|
+
/** Kill ffmpeg and end the plane. Idempotent. */
|
|
29899
|
+
async stop() {
|
|
29900
|
+
if (this.stopped) return;
|
|
29901
|
+
this.stopped = true;
|
|
29902
|
+
this.killChild();
|
|
29903
|
+
this.args.plane.end("the fragment child stopped");
|
|
29904
|
+
}
|
|
29905
|
+
spawnAttempt(decodeHwAccel) {
|
|
29906
|
+
const args = buildFfmpegArgs({
|
|
29907
|
+
...this.args.invocation,
|
|
29908
|
+
decodeHwAccel,
|
|
29909
|
+
sink: {
|
|
29910
|
+
kind: "stdout",
|
|
29911
|
+
container: "mp4",
|
|
29912
|
+
fragmentMs: this.args.fragmentMs
|
|
29913
|
+
}
|
|
29914
|
+
});
|
|
29915
|
+
this.deps.logger.info("fmp4 fragment child: spawning ffmpeg", {
|
|
29916
|
+
tags: { deviceId: this.args.deviceId },
|
|
29917
|
+
meta: {
|
|
29918
|
+
sourceId: this.args.sourceId,
|
|
29919
|
+
fragmentMs: this.args.fragmentMs,
|
|
29920
|
+
decodeHwAccel: decodeHwAccel ?? "software",
|
|
29921
|
+
argv: args.join(" ")
|
|
29922
|
+
}
|
|
29923
|
+
});
|
|
29924
|
+
return new Promise((resolve, reject) => {
|
|
29925
|
+
const child = this.deps.spawnFn(this.deps.ffmpegBinaryPath, args, { stdio: [
|
|
29926
|
+
"ignore",
|
|
29927
|
+
"pipe",
|
|
29928
|
+
"pipe"
|
|
29929
|
+
] });
|
|
29930
|
+
this.child = child;
|
|
29931
|
+
let settled = false;
|
|
29932
|
+
/**
|
|
29933
|
+
* This attempt FAILED. Set before the kill, because SIGTERM makes the
|
|
29934
|
+
* child exit and that exit must not be reported as a death: the retry —
|
|
29935
|
+
* or the caller's rejection — already owns what happens next. Without it
|
|
29936
|
+
* the timeout path ends the plane the software retry is about to fill,
|
|
29937
|
+
* and the consumer sees a stream that stopped for no reason. A "which
|
|
29938
|
+
* spawn is current" counter does NOT cover this: the retry has not been
|
|
29939
|
+
* spawned when the kill's exit arrives.
|
|
29940
|
+
*/
|
|
29941
|
+
let failed = false;
|
|
29942
|
+
/**
|
|
29943
|
+
* This attempt is still the live producer: it has not failed (a failure
|
|
29944
|
+
* hands ownership to the retry, or to the caller's rejection) and nothing
|
|
29945
|
+
* has stopped the child. Those two cover every way an attempt stops being
|
|
29946
|
+
* current — `start` only respawns after a rejection.
|
|
29947
|
+
*/
|
|
29948
|
+
const isCurrent = () => !this.stopped && !failed;
|
|
29949
|
+
const timeoutMs = this.deps.firstUnitTimeoutMs ?? DEFAULT_FIRST_UNIT_TIMEOUT_MS;
|
|
29950
|
+
const settle = (fail) => {
|
|
29951
|
+
if (settled) return;
|
|
29952
|
+
settled = true;
|
|
29953
|
+
clearTimeout(timer);
|
|
29954
|
+
if (fail) {
|
|
29955
|
+
failed = true;
|
|
29956
|
+
reject(fail);
|
|
29957
|
+
} else resolve();
|
|
29958
|
+
};
|
|
29959
|
+
const timer = setTimeout(() => {
|
|
29960
|
+
settle(/* @__PURE__ */ new Error(`fmp4 fragment child: no fragment within ${timeoutMs}ms`));
|
|
29961
|
+
this.killChild();
|
|
29962
|
+
}, timeoutMs);
|
|
29963
|
+
timer.unref?.();
|
|
29964
|
+
child.stdout?.on("data", (chunk) => {
|
|
29965
|
+
for (const unit of this.splitter.push(chunk)) {
|
|
29966
|
+
this.unitsOut += 1;
|
|
29967
|
+
this.args.plane.publish(unit);
|
|
29968
|
+
if (unit.kind === "init") {
|
|
29969
|
+
this.deps.logger.info("fmp4 fragment child: INIT segment cut", {
|
|
29970
|
+
tags: { deviceId: this.args.deviceId },
|
|
29971
|
+
meta: {
|
|
29972
|
+
sourceId: this.args.sourceId,
|
|
29973
|
+
bytes: unit.data.length
|
|
29974
|
+
}
|
|
29975
|
+
});
|
|
29976
|
+
settle();
|
|
29977
|
+
} else if (this.unitsOut % FRAGMENT_LOG_EVERY === 0) this.deps.logger.info("fmp4 fragment child: fragments still flowing", {
|
|
29978
|
+
tags: { deviceId: this.args.deviceId },
|
|
29979
|
+
meta: {
|
|
29980
|
+
sourceId: this.args.sourceId,
|
|
29981
|
+
unitsOut: this.unitsOut,
|
|
29982
|
+
bytes: unit.data.length,
|
|
29983
|
+
subscribers: this.args.plane.subscriberCount
|
|
29984
|
+
}
|
|
29985
|
+
});
|
|
29986
|
+
}
|
|
29987
|
+
const fault = this.splitter.fault;
|
|
29988
|
+
if (fault !== null) this.onFault(fault, settled, isCurrent(), settle);
|
|
29989
|
+
});
|
|
29990
|
+
child.stderr?.setEncoding("utf8");
|
|
29991
|
+
child.stderr?.on("data", (line) => {
|
|
29992
|
+
this.deps.logger.debug("fmp4 fragment child ffmpeg", {
|
|
29993
|
+
tags: { deviceId: this.args.deviceId },
|
|
29994
|
+
meta: {
|
|
29995
|
+
sourceId: this.args.sourceId,
|
|
29996
|
+
line: line.trim()
|
|
29997
|
+
}
|
|
29998
|
+
});
|
|
29999
|
+
});
|
|
30000
|
+
child.once("error", (err) => {
|
|
30001
|
+
if (!settled) {
|
|
30002
|
+
settle(err);
|
|
30003
|
+
return;
|
|
30004
|
+
}
|
|
30005
|
+
if (!isCurrent()) return;
|
|
30006
|
+
this.args.plane.end("the fragment child errored");
|
|
30007
|
+
this.deps.onChildExit?.(err);
|
|
30008
|
+
});
|
|
30009
|
+
child.once("exit", (code, signal) => {
|
|
30010
|
+
if (!settled) {
|
|
30011
|
+
settle(/* @__PURE__ */ new Error(`fmp4 fragment child: ffmpeg exited before any fragment (code=${code} signal=${signal})`));
|
|
30012
|
+
return;
|
|
30013
|
+
}
|
|
30014
|
+
if (!isCurrent()) return;
|
|
30015
|
+
const error = /* @__PURE__ */ new Error(`fmp4 fragment child: ffmpeg exited while live (code=${code} signal=${signal})`);
|
|
30016
|
+
this.deps.logger.warn("fmp4 fragment child: ffmpeg exited while live", {
|
|
30017
|
+
tags: { deviceId: this.args.deviceId },
|
|
30018
|
+
meta: {
|
|
30019
|
+
sourceId: this.args.sourceId,
|
|
30020
|
+
code,
|
|
30021
|
+
signal,
|
|
30022
|
+
unitsOut: this.unitsOut
|
|
30023
|
+
}
|
|
30024
|
+
});
|
|
30025
|
+
this.args.plane.end("the fragment child exited");
|
|
30026
|
+
this.deps.onChildExit?.(error);
|
|
30027
|
+
});
|
|
30028
|
+
});
|
|
30029
|
+
}
|
|
30030
|
+
/**
|
|
30031
|
+
* The byte stream stopped being splittable. Not recoverable — the splitter
|
|
30032
|
+
* cannot resynchronise mid-box — so the child is a corpse and every consumer
|
|
30033
|
+
* has to be told, loudly, with the reason.
|
|
30034
|
+
*/
|
|
30035
|
+
onFault(reason, wasLive, current, settle) {
|
|
30036
|
+
const error = /* @__PURE__ */ new Error(`fmp4 fragment child: ${reason}`);
|
|
30037
|
+
this.deps.logger.error("fmp4 fragment child: the ffmpeg output stopped parsing as fMP4", {
|
|
30038
|
+
tags: { deviceId: this.args.deviceId },
|
|
30039
|
+
meta: {
|
|
30040
|
+
sourceId: this.args.sourceId,
|
|
30041
|
+
unitsOut: this.unitsOut,
|
|
30042
|
+
interstitial: this.splitter.discardedInterstitialTypes,
|
|
30043
|
+
reason
|
|
30044
|
+
}
|
|
30045
|
+
});
|
|
30046
|
+
this.killChild();
|
|
30047
|
+
settle(error);
|
|
30048
|
+
if (wasLive && current) {
|
|
30049
|
+
this.args.plane.end("the fragment child produced unsplittable output");
|
|
30050
|
+
this.deps.onChildExit?.(error);
|
|
30051
|
+
}
|
|
30052
|
+
}
|
|
30053
|
+
killChild() {
|
|
30054
|
+
const child = this.child;
|
|
30055
|
+
this.child = null;
|
|
30056
|
+
if (child && !child.killed) try {
|
|
30057
|
+
child.kill("SIGTERM");
|
|
30058
|
+
} catch (err) {
|
|
30059
|
+
this.deps.logger.warn("fmp4 fragment child: kill error", {
|
|
30060
|
+
tags: { deviceId: this.args.deviceId },
|
|
30061
|
+
meta: {
|
|
30062
|
+
sourceId: this.args.sourceId,
|
|
30063
|
+
error: errMsg$12(err)
|
|
30064
|
+
}
|
|
30065
|
+
});
|
|
30066
|
+
}
|
|
30067
|
+
}
|
|
30068
|
+
};
|
|
28386
30069
|
//#endregion
|
|
28387
30070
|
//#region src/accessory-publisher.ts
|
|
28388
30071
|
/**
|
|
@@ -28496,7 +30179,7 @@ function clearPairingFiles(accessoryUuid, logger) {
|
|
|
28496
30179
|
}
|
|
28497
30180
|
//#endregion
|
|
28498
30181
|
//#region src/hap-setup-uri.ts
|
|
28499
|
-
function errMsg$
|
|
30182
|
+
function errMsg$11(e) {
|
|
28500
30183
|
return e instanceof Error ? e.message : String(e);
|
|
28501
30184
|
}
|
|
28502
30185
|
/**
|
|
@@ -28523,7 +30206,7 @@ function firstExposedAccessorySetupUri(exposed, logger) {
|
|
|
28523
30206
|
try {
|
|
28524
30207
|
return first.setupURI();
|
|
28525
30208
|
} catch (err) {
|
|
28526
|
-
logger.debug("export-hap: setupURI failed on first exposed accessory", { meta: { error: errMsg$
|
|
30209
|
+
logger.debug("export-hap: setupURI failed on first exposed accessory", { meta: { error: errMsg$11(err) } });
|
|
28527
30210
|
return;
|
|
28528
30211
|
}
|
|
28529
30212
|
}
|
|
@@ -28561,13 +30244,19 @@ function hapServiceName(parts, fallback) {
|
|
|
28561
30244
|
/**
|
|
28562
30245
|
* The privacy-mask switch.
|
|
28563
30246
|
*
|
|
28564
|
-
* The camera
|
|
28565
|
-
* camera's
|
|
28566
|
-
*
|
|
28567
|
-
*
|
|
30247
|
+
* Just "Privacy". The camera name is NOT prefixed: this service lives inside
|
|
30248
|
+
* the camera's own accessory, iOS already renders it under the camera, and a
|
|
30249
|
+
* round that prefixed it gave the operator "Videocamera ingresso Privacy"
|
|
30250
|
+
* sitting inside a tile titled "Videocamera ingresso".
|
|
30251
|
+
*
|
|
30252
|
+
* The prefix was added for a real reason — two cameras publishing a switch
|
|
30253
|
+
* called "Privacy" — but that was a symptom of the label being the ONLY thing
|
|
30254
|
+
* shown, which stopped being true once `ConfiguredName` made the service
|
|
30255
|
+
* render in its accessory's context. Uniqueness is required WITHIN one
|
|
30256
|
+
* accessory, not across the bridge, and one camera has one privacy switch.
|
|
28568
30257
|
*/
|
|
28569
|
-
function privacyServiceName(
|
|
28570
|
-
return hapServiceName([
|
|
30258
|
+
function privacyServiceName() {
|
|
30259
|
+
return hapServiceName([PRIVACY_SUFFIX], PRIVACY_SUFFIX);
|
|
28571
30260
|
}
|
|
28572
30261
|
/**
|
|
28573
30262
|
* Deliberately not localised, and deliberately not a translation table.
|
|
@@ -28584,35 +30273,60 @@ var PRIVACY_SUFFIX = "Privacy";
|
|
|
28584
30273
|
* the parent camera.
|
|
28585
30274
|
*
|
|
28586
30275
|
* The child's OWN stored name wins. It is the string the operator typed, in
|
|
28587
|
-
* the operator's language, and
|
|
30276
|
+
* the operator's language, and an early rule threw it away: `role` was
|
|
28588
30277
|
* consulted first and title-cased, so every siren on the fleet published as
|
|
28589
30278
|
* the English word "Siren" no matter what the operator had called it.
|
|
28590
30279
|
*
|
|
28591
|
-
*
|
|
28592
|
-
*
|
|
28593
|
-
*
|
|
30280
|
+
* Providers name children both ways — "Sirena" and "Videocamera cucina
|
|
30281
|
+
* Sirena". The parent half is now REMOVED rather than added, because the
|
|
30282
|
+
* service is published inside the parent camera's own accessory and iOS
|
|
30283
|
+
* already shows it there. The result must be one form, not two.
|
|
28594
30284
|
*/
|
|
28595
30285
|
function childServiceName(parentName, child) {
|
|
28596
|
-
const own = child.name.trim();
|
|
28597
|
-
if (own.length > 0) return
|
|
28598
|
-
return hapServiceName([
|
|
30286
|
+
const own = withoutParent(child.name.trim(), parentName);
|
|
30287
|
+
if (own.length > 0) return hapServiceName([own], own);
|
|
30288
|
+
return hapServiceName([typeof child.role === "string" ? titleCase(child.role) : ""], CHILD_FALLBACK);
|
|
28599
30289
|
}
|
|
28600
30290
|
/**
|
|
28601
|
-
*
|
|
30291
|
+
* Last resort for a child that carries neither a name nor a role. Better than
|
|
30292
|
+
* the parent's name, which would publish a service indistinguishable from the
|
|
30293
|
+
* accessory holding it — the exact defect this module keeps being asked to fix.
|
|
28602
30294
|
*
|
|
28603
|
-
*
|
|
28604
|
-
*
|
|
28605
|
-
*
|
|
28606
|
-
* ingresso".
|
|
30295
|
+
* English, like the role slugs it stands in for ("Floodlight", "Siren"): the
|
|
30296
|
+
* only strings this module invents are English, and inventing one Italian word
|
|
30297
|
+
* would be a localisation layer that localises nothing.
|
|
28607
30298
|
*/
|
|
28608
|
-
|
|
28609
|
-
|
|
30299
|
+
var CHILD_FALLBACK = "Accessory";
|
|
30300
|
+
/**
|
|
30301
|
+
* A PTZ action switch: the bare action, "Preset ingresso" / "Pan Left" /
|
|
30302
|
+
* "Autotrack".
|
|
30303
|
+
*
|
|
30304
|
+
* The labels themselves stay in `ptz-labels.ts` — they name a HomeKit control,
|
|
30305
|
+
* not a device. This function exists only to put the operator-typed half of a
|
|
30306
|
+
* preset name through the same sanitisation everything else gets; it no longer
|
|
30307
|
+
* qualifies the label with the camera, because all eight PTZ services live on
|
|
30308
|
+
* that camera's accessory and are unique among themselves.
|
|
30309
|
+
*/
|
|
30310
|
+
function ptzServiceName(actionLabel) {
|
|
30311
|
+
return hapServiceName([actionLabel], actionLabel);
|
|
28610
30312
|
}
|
|
28611
|
-
/**
|
|
28612
|
-
|
|
28613
|
-
|
|
28614
|
-
|
|
28615
|
-
|
|
30313
|
+
/**
|
|
30314
|
+
* Drop `parentName` from the front of `name`.
|
|
30315
|
+
*
|
|
30316
|
+
* A PREFIX only. "Videocamera cucina Sirena" → "Sirena"; "Sirena" is already
|
|
30317
|
+
* bare and untouched. A parent name appearing anywhere else in the child's
|
|
30318
|
+
* name is left alone — cutting from the middle of a string the operator typed
|
|
30319
|
+
* would mangle it, and this function must never make a label WORSE.
|
|
30320
|
+
*
|
|
30321
|
+
* Returns `name` unchanged when stripping would leave nothing: a child the
|
|
30322
|
+
* operator called exactly what the camera is called still needs a label.
|
|
30323
|
+
*/
|
|
30324
|
+
function withoutParent(name, parentName) {
|
|
30325
|
+
const needle = parentName.trim();
|
|
30326
|
+
if (needle.length === 0) return name;
|
|
30327
|
+
if (!name.toLowerCase().startsWith(needle.toLowerCase())) return name;
|
|
30328
|
+
const rest = name.slice(needle.length).trim();
|
|
30329
|
+
return rest.length > 0 ? rest : name;
|
|
28616
30330
|
}
|
|
28617
30331
|
/**
|
|
28618
30332
|
* Truncate to the HAP ceiling and shave any leading/trailing character the
|
|
@@ -28661,7 +30375,7 @@ async function buildBattery(bctx) {
|
|
|
28661
30375
|
const status = await proxy.battery?.getStatus({});
|
|
28662
30376
|
if (status) applyToService(service, status);
|
|
28663
30377
|
} catch (err) {
|
|
28664
|
-
log.debug("export-hap: battery getStatus hydrate failed (non-fatal)", { meta: { error: errMsg$
|
|
30378
|
+
log.debug("export-hap: battery getStatus hydrate failed (non-fatal)", { meta: { error: errMsg$10(err) } });
|
|
28665
30379
|
}
|
|
28666
30380
|
const unsubscribes = [];
|
|
28667
30381
|
if (proxy.state.battery) {
|
|
@@ -28687,7 +30401,7 @@ function applyToService(service, status) {
|
|
|
28687
30401
|
const lowBattery = pct <= LOW_BATTERY_THRESHOLD_PCT ? _homebridge_hap_nodejs.Characteristic.StatusLowBattery.BATTERY_LEVEL_LOW : _homebridge_hap_nodejs.Characteristic.StatusLowBattery.BATTERY_LEVEL_NORMAL;
|
|
28688
30402
|
service.updateCharacteristic(_homebridge_hap_nodejs.Characteristic.StatusLowBattery, lowBattery);
|
|
28689
30403
|
}
|
|
28690
|
-
function errMsg$
|
|
30404
|
+
function errMsg$10(err) {
|
|
28691
30405
|
return err instanceof Error ? err.message : String(err);
|
|
28692
30406
|
}
|
|
28693
30407
|
//#endregion
|
|
@@ -39183,9 +40897,121 @@ function ingestDecryptedRtcp(plaintext, tally) {
|
|
|
39183
40897
|
failure: null
|
|
39184
40898
|
};
|
|
39185
40899
|
}
|
|
40900
|
+
function classifyConnection(input) {
|
|
40901
|
+
if (input.negotiatedWidth < 640) return "watch";
|
|
40902
|
+
if (input.audioPacketTimeMs >= 60) return "remote";
|
|
40903
|
+
return input.viaHomeHub ? "home-hub" : "local";
|
|
40904
|
+
}
|
|
40905
|
+
/**
|
|
40906
|
+
* The slot each class asks for.
|
|
40907
|
+
*
|
|
40908
|
+
* ## `local` takes the camera's best stream — settled by measurement
|
|
40909
|
+
*
|
|
40910
|
+
* This function was pinned to `low` for EVERY class by one number. On 615/high,
|
|
40911
|
+
* 3840x2160 pass-through:
|
|
40912
|
+
*
|
|
40913
|
+
* durationMs=30820 videoPacketsForwarded=93 videoKeyframes=1
|
|
40914
|
+
* audioPacketsForwarded=1497 lost=0
|
|
40915
|
+
*
|
|
40916
|
+
* Three video datagrams a second, one key frame in half a minute, while the
|
|
40917
|
+
* AUDIO leg of the *same* ffmpeg ran perfectly. It read as "our path cannot
|
|
40918
|
+
* carry a high-bitrate stream".
|
|
40919
|
+
*
|
|
40920
|
+
* **It was not HomeKit, not 4K and not SRTP. The loopback UDP socket ffmpeg
|
|
40921
|
+
* writes its RTP into had no `SO_RCVBUF` at all** (2026-08-07). It ran on
|
|
40922
|
+
* `net.core.rmem_default`, 212 992 B — about a fifth of one 4K IDR, which
|
|
40923
|
+
* arrives as ~750 datagrams at `pkt_size=1378` in a single burst. The kernel
|
|
40924
|
+
* discarded the overflow, and a datagram dropped there never reaches a
|
|
40925
|
+
* `message` handler, so it lowered the forwarded count exactly like a packet
|
|
40926
|
+
* ffmpeg never wrote and the controller reported no loss for it either. Audio,
|
|
40927
|
+
* a few hundred bytes every 20 ms, never filled the buffer. That is the whole
|
|
40928
|
+
* asymmetry. See `stream-socket-buffer.ts`.
|
|
40929
|
+
*
|
|
40930
|
+
* With an 8 MiB buffer (granted — this hub's `net.core.rmem_max` is 16 MiB),
|
|
40931
|
+
* the same camera and the same slot, session
|
|
40932
|
+
* `12308100-7dbe-4ad1-b277-1f046ba54ec2` on 2026-08-07:
|
|
40933
|
+
*
|
|
40934
|
+
* selectedProfile=high transcode=false slotMeasuredKbps=5097
|
|
40935
|
+
* videoPacketsForwarded=6512 durationMs=9867 (~660/s, was ~3/s)
|
|
40936
|
+
* msToFirstKeyframe=858 deliveredFps=24 worstFractionLostPct=0.4
|
|
40937
|
+
* videoLoopRcvbufBytes=16777216 clamped=false
|
|
40938
|
+
*
|
|
40939
|
+
* Operator: loaded instantly, and visibly not the low stream. A 220x increase
|
|
40940
|
+
* in delivered packet rate from sizing one socket.
|
|
40941
|
+
*
|
|
40942
|
+
* ## Why the remote classes stay `low`
|
|
40943
|
+
*
|
|
40944
|
+
* Not caution left over from the freeze — a different, UNMEASURED question.
|
|
40945
|
+
* `watch`, `remote` and `home-hub` all send video across a link whose budget
|
|
40946
|
+
* nothing here has measured; the buffer fix says something about a loopback hop
|
|
40947
|
+
* inside one host and nothing whatsoever about a WAN. 4K pass-through at ~5 Mbps
|
|
40948
|
+
* to a phone on LTE is a decision that needs its own evidence, and `watch` has
|
|
40949
|
+
* a panel under 640 px wide that could not use the pixels anyway. Raise these
|
|
40950
|
+
* only with a measurement of the remote link, not by analogy with this one.
|
|
40951
|
+
*
|
|
40952
|
+
* `mid` remains excluded from every class, unrelated to all of the above: it is
|
|
40953
|
+
* a 10 fps stream on this fleet and it has never rendered under any combination
|
|
40954
|
+
* tried.
|
|
40955
|
+
*/
|
|
40956
|
+
function slotForConnection(connection) {
|
|
40957
|
+
switch (connection) {
|
|
40958
|
+
case "watch": return "low";
|
|
40959
|
+
case "remote": return "low";
|
|
40960
|
+
case "home-hub": return "low";
|
|
40961
|
+
case "local": return "high";
|
|
40962
|
+
}
|
|
40963
|
+
}
|
|
39186
40964
|
//#endregion
|
|
39187
40965
|
//#region src/mappers/builders/stream-bitrate.ts
|
|
39188
40966
|
/**
|
|
40967
|
+
* Send a stream that FITS the rate HomeKit negotiated. (R5)
|
|
40968
|
+
*
|
|
40969
|
+
* The controller's own Receiver Reports, read on the live hub on 2026-08-06,
|
|
40970
|
+
* closed a year of guessing: one 19.27 s session on `615/mid` forwarded 737
|
|
40971
|
+
* video packets at `mtu=1378` — roughly **421 kbps** — against a negotiated
|
|
40972
|
+
* `max_bit_rate` of **299**, and iOS reported losing **450 of those 737
|
|
40973
|
+
* packets (61 %)**, worst fraction lost 51.2 %, peak jitter 3.03 s. Under
|
|
40974
|
+
* `-c:v copy` the accessory has no lever at all: it forwards whatever the
|
|
40975
|
+
* camera's encoder produces, at whatever cadence it produces it.
|
|
40976
|
+
*
|
|
40977
|
+
* So the fix has two halves, and this module owns both:
|
|
40978
|
+
*
|
|
40979
|
+
* 1. **Choose a slot that fits.** Among the slots that can be passed through
|
|
40980
|
+
* (H.264) and whose rate is known to be within budget, the existing
|
|
40981
|
+
* resolution-closest picker decides — so the "which slot serves which
|
|
40982
|
+
* resolution" opinion stays single, exactly as D51 requires.
|
|
40983
|
+
* 2. **Transcode only when none does**, with a real cap
|
|
40984
|
+
* (`-b:v` / `-maxrate` / `-bufsize`) at the negotiated rate.
|
|
40985
|
+
*
|
|
40986
|
+
* ## Where the authoritative rate comes from, and why it is NOT the obvious one
|
|
40987
|
+
*
|
|
40988
|
+
* `webrtcSession.listStreams` reports a `bitrateKbps` per slot and it is a
|
|
40989
|
+
* **measured flow rate**, which is meaningless for a slot nobody is consuming.
|
|
40990
|
+
* Live on 2026-08-06 it reported `mid = 9 kbps` for the very slot that had just
|
|
40991
|
+
* delivered ~421 kbps, `low = 5 kbps`, and `high = 5441 kbps` (high was being
|
|
40992
|
+
* consumed, hence plausible). Selecting on that reading would admit every slot.
|
|
40993
|
+
*
|
|
40994
|
+
* The authority is therefore the camera's **configured** encoder rate, from
|
|
40995
|
+
* `streamParams.getStatus` — `main` / `sub` / `ext`, each carrying the
|
|
40996
|
+
* `bitrate` the operator (or the vendor default) set. On 615 that is
|
|
40997
|
+
* `main 8192`, `sub 2048`, `ext 2048` kbps. It is mapped onto a profile slot
|
|
40998
|
+
* through the slot's assigned cam-stream, matched on resolution and frame
|
|
40999
|
+
* rate; an ambiguous or absent match is reported as **unknown**, never as a
|
|
41000
|
+
* number.
|
|
41001
|
+
*
|
|
41002
|
+
* This inverts D51's ordering — there, `measured` outranks `published` — and
|
|
41003
|
+
* the inversion is deliberate:
|
|
41004
|
+
*
|
|
41005
|
+
* - a frame rate is a stable property of the source and a measurement of it
|
|
41006
|
+
* is the *best* evidence;
|
|
41007
|
+
* - a bitrate under VBR is an envelope. A measurement is a **lower bound**
|
|
41008
|
+
* on it, and a lower bound can prove a slot does NOT fit but can never
|
|
41009
|
+
* prove that it does.
|
|
41010
|
+
*
|
|
41011
|
+
* So `measured` is kept, and used only in the direction it is sound in.
|
|
41012
|
+
* Everything here is pure; the cap reads live in `stream-bitrate-probe.ts`.
|
|
41013
|
+
*/
|
|
41014
|
+
/**
|
|
39189
41015
|
* Fraction of the negotiated ceiling we actually aim the encoder at.
|
|
39190
41016
|
*
|
|
39191
41017
|
* `max_bit_rate` is what the controller budgeted for the stream; what crosses
|
|
@@ -39278,7 +41104,8 @@ function classifyBitrateFit(evidence, budgetKbps) {
|
|
|
39278
41104
|
function selectStreamForBudget(input) {
|
|
39279
41105
|
const budgetKbps = budgetForNegotiatedRate(input.negotiatedMaxBitrateKbps);
|
|
39280
41106
|
const notes = fitNotes(input.entries, input.bitrates, budgetKbps);
|
|
39281
|
-
const
|
|
41107
|
+
const effectivePref = input.pref === "auto" ? slotForConnection(input.connection) : input.pref;
|
|
41108
|
+
const fallback = pickPreferredRtspEntry(input.entries, effectivePref, input.deviceId, { targetResolution: input.targetResolution });
|
|
39282
41109
|
if (fallback === null) return null;
|
|
39283
41110
|
const fallbackProfile = toCamProfile$1(fallback.profileId);
|
|
39284
41111
|
if (budgetKbps === null) {
|
|
@@ -39303,7 +41130,7 @@ function selectStreamForBudget(input) {
|
|
|
39303
41130
|
return classifyBitrateFit(input.bitrates.get(entry.profile), budgetKbps) === "fits";
|
|
39304
41131
|
});
|
|
39305
41132
|
if (affordable.length > 0) {
|
|
39306
|
-
const picked = pickPreferredRtspEntry(affordable,
|
|
41133
|
+
const picked = pickPreferredRtspEntry(affordable, effectivePref, input.deviceId, { targetResolution: input.targetResolution });
|
|
39307
41134
|
if (picked !== null) return {
|
|
39308
41135
|
kind: "copy",
|
|
39309
41136
|
reason: "source-fits-budget",
|
|
@@ -39348,16 +41175,15 @@ function withSlotCodecs(entries, slots) {
|
|
|
39348
41175
|
});
|
|
39349
41176
|
}
|
|
39350
41177
|
/**
|
|
39351
|
-
* The
|
|
39352
|
-
*
|
|
39353
|
-
*
|
|
39354
|
-
|
|
39355
|
-
|
|
39356
|
-
|
|
39357
|
-
|
|
39358
|
-
|
|
39359
|
-
|
|
39360
|
-
* The ffmpeg video-output arguments.
|
|
41178
|
+
* The video half of the ffmpeg plan, in the SHARED vocabulary
|
|
41179
|
+
* (`@camstack/types` `ffmpeg/invocation.ts`). This function used to emit
|
|
41180
|
+
* arguments; it now describes them, and `buildFfmpegArgs` emits every one — the
|
|
41181
|
+
* repo keeps exactly one argv builder, and HomeKit stopped being an exception
|
|
41182
|
+
* to that (D67, `scripts/check-ffmpeg-primitive.ts` Rule 1).
|
|
41183
|
+
*
|
|
41184
|
+
* Nothing about the RESULT changed except the rescale spelling: `-s WxH` became
|
|
41185
|
+
* `-vf scale=W:H`. Equivalent for a plain rescale, and worth knowing because
|
|
41186
|
+
* the two are NOT interchangeable once another `-vf` is in play.
|
|
39361
41187
|
*
|
|
39362
41188
|
* Pass-through carries `-bsf:v dump_extra` so every IDR inlines its own
|
|
39363
41189
|
* SPS/PPS — sources that publish parameter sets only in the RTSP SDP hand iOS
|
|
@@ -39368,54 +41194,42 @@ function deliverableFps(negotiatedFps, slotFps) {
|
|
|
39368
41194
|
* **one-second** `-bufsize` bounds any one-second window at the negotiated
|
|
39369
41195
|
* rate, which is also the only lever available on the 3.03 s peak jitter — the
|
|
39370
41196
|
* VBV window is what forces x264 to size a key frame to fit rather than
|
|
39371
|
-
* emitting it as one tight burst.
|
|
39372
|
-
|
|
39373
|
-
|
|
39374
|
-
*
|
|
39375
|
-
*
|
|
39376
|
-
*
|
|
39377
|
-
*
|
|
39378
|
-
*
|
|
39379
|
-
|
|
39380
|
-
|
|
39381
|
-
function
|
|
39382
|
-
if (!input.transcode) return
|
|
39383
|
-
|
|
39384
|
-
"
|
|
39385
|
-
|
|
39386
|
-
|
|
39387
|
-
|
|
39388
|
-
|
|
39389
|
-
|
|
39390
|
-
|
|
39391
|
-
|
|
39392
|
-
|
|
39393
|
-
|
|
39394
|
-
|
|
39395
|
-
|
|
39396
|
-
|
|
39397
|
-
|
|
39398
|
-
"
|
|
39399
|
-
|
|
39400
|
-
|
|
39401
|
-
|
|
39402
|
-
|
|
39403
|
-
|
|
39404
|
-
|
|
39405
|
-
"
|
|
39406
|
-
|
|
39407
|
-
"-s",
|
|
39408
|
-
`${input.width}x${input.height}`,
|
|
39409
|
-
"-g",
|
|
39410
|
-
String(Math.max(1, Math.round(input.fps * KEYFRAME_INTERVAL_SEC))),
|
|
39411
|
-
...rate,
|
|
39412
|
-
"-profile:v",
|
|
39413
|
-
"baseline",
|
|
39414
|
-
"-level",
|
|
39415
|
-
"3.1",
|
|
39416
|
-
"-bsf:v",
|
|
39417
|
-
"dump_extra"
|
|
39418
|
-
];
|
|
41197
|
+
* emitting it as one tight burst. That window is {@link RATE_CONTROL_TIGHT},
|
|
41198
|
+
* the shared constant whose whole reason to exist is HomeKit's per-second
|
|
41199
|
+
* budget; the browser and Echo use the relaxed two-second one.
|
|
41200
|
+
*
|
|
41201
|
+
* **The encoder stays `libx264`, deliberately.** `h264_vaapi` / `h264_qsv`
|
|
41202
|
+
* carry their own rate-control model, do not accept `-profile:v baseline`, and
|
|
41203
|
+
* emit parameter sets on their own schedule rather than x264's — which puts the
|
|
41204
|
+
* two load-bearing flags below back in play, with no hardware here to prove
|
|
41205
|
+
* they still hold. Hardware DECODE is where the measured cost is.
|
|
41206
|
+
*/
|
|
41207
|
+
function buildVideoPlan(input) {
|
|
41208
|
+
if (!input.transcode) return {
|
|
41209
|
+
kind: "copy",
|
|
41210
|
+
bitstreamFilter: "dump_extra"
|
|
41211
|
+
};
|
|
41212
|
+
return {
|
|
41213
|
+
kind: "encode",
|
|
41214
|
+
encoder: "libx264",
|
|
41215
|
+
scale: {
|
|
41216
|
+
mode: "exact",
|
|
41217
|
+
width: input.width,
|
|
41218
|
+
height: input.height
|
|
41219
|
+
},
|
|
41220
|
+
preset: "ultrafast",
|
|
41221
|
+
tune: "zerolatency",
|
|
41222
|
+
profile: "baseline",
|
|
41223
|
+
level: "3.1",
|
|
41224
|
+
pixelFormat: "yuv420p",
|
|
41225
|
+
fps: input.fps,
|
|
41226
|
+
gopFrames: Math.max(1, Math.round(input.fps * 4)),
|
|
41227
|
+
...input.budgetKbps === null ? {} : {
|
|
41228
|
+
bitrateKbps: input.budgetKbps,
|
|
41229
|
+
rateControl: RATE_CONTROL_TIGHT
|
|
41230
|
+
},
|
|
41231
|
+
bitstreamFilter: "dump_extra"
|
|
41232
|
+
};
|
|
39419
41233
|
}
|
|
39420
41234
|
/** Compact `mid=2048pub/9meas:over-budget` rendering for a single log field. */
|
|
39421
41235
|
function formatFitNotes(notes) {
|
|
@@ -39495,23 +41309,129 @@ function toCamProfile$1(profileId) {
|
|
|
39495
41309
|
return CAM_PROFILES$1.find((p) => p === profileId) ?? null;
|
|
39496
41310
|
}
|
|
39497
41311
|
//#endregion
|
|
41312
|
+
//#region src/mappers/builders/deadline.ts
|
|
41313
|
+
/**
|
|
41314
|
+
* Bound a piece of optional work in time.
|
|
41315
|
+
*
|
|
41316
|
+
* HomeKit answers `Selected RTP Stream Configuration` inside a write handler
|
|
41317
|
+
* hap-nodejs expects back quickly, and the start path behind it makes six
|
|
41318
|
+
* sequential cross-process cap calls into a stream-broker that regularly
|
|
41319
|
+
* freezes for two to three seconds at a time. Measured on the live hub: the
|
|
41320
|
+
* controller negotiated at :11, gave up at 9.1 s, and the bitrate fit resolved
|
|
41321
|
+
* at :32 — twenty-one seconds — with the start then failing on `Not running`
|
|
41322
|
+
* because the session it was preparing no longer existed.
|
|
41323
|
+
*
|
|
41324
|
+
* The evidence those calls gather is genuinely optional: an absent reading
|
|
41325
|
+
* classifies as `unknown`, and the tolerated branch still picks a slot. So the
|
|
41326
|
+
* right trade under load is to answer with less evidence rather than late, and
|
|
41327
|
+
* this makes that trade explicit at each call site instead of leaving it to
|
|
41328
|
+
* whatever the broker's latency happens to be.
|
|
41329
|
+
*
|
|
41330
|
+
* A late failure from work we stopped waiting on is swallowed on purpose: the
|
|
41331
|
+
* probe keeps running after the deadline fires, and an unhandled rejection
|
|
41332
|
+
* from an abandoned probe would take the process down over a reading nobody is
|
|
41333
|
+
* using any more.
|
|
41334
|
+
*/
|
|
41335
|
+
var TIMED_OUT = Symbol("deadline:timed-out");
|
|
41336
|
+
var FAILED = Symbol("deadline:failed");
|
|
41337
|
+
async function withDeadline(work, ms, fallback, onTimeout) {
|
|
41338
|
+
let timer;
|
|
41339
|
+
const guard = new Promise((resolve) => {
|
|
41340
|
+
timer = setTimeout(() => resolve(TIMED_OUT), ms);
|
|
41341
|
+
});
|
|
41342
|
+
try {
|
|
41343
|
+
const settled = await Promise.race([work.catch(() => FAILED), guard]);
|
|
41344
|
+
if (settled === TIMED_OUT) {
|
|
41345
|
+
onTimeout();
|
|
41346
|
+
return fallback;
|
|
41347
|
+
}
|
|
41348
|
+
return settled === FAILED ? fallback : settled;
|
|
41349
|
+
} finally {
|
|
41350
|
+
if (timer !== void 0) clearTimeout(timer);
|
|
41351
|
+
work.catch(() => void 0);
|
|
41352
|
+
}
|
|
41353
|
+
}
|
|
41354
|
+
//#endregion
|
|
39498
41355
|
//#region src/mappers/builders/stream-bitrate-probe.ts
|
|
39499
41356
|
/**
|
|
39500
|
-
*
|
|
39501
|
-
*
|
|
39502
|
-
|
|
41357
|
+
* Total budget for the rate evidence, not per call — the point is to bound
|
|
41358
|
+
* what the CONTROLLER waits for, and it waits for the sum.
|
|
41359
|
+
*/
|
|
41360
|
+
var BITRATE_EVIDENCE_BUDGET_MS = 1500;
|
|
41361
|
+
var NO_EVIDENCE = {
|
|
41362
|
+
camStreams: null,
|
|
41363
|
+
streamParams: null,
|
|
41364
|
+
choices: null
|
|
41365
|
+
};
|
|
41366
|
+
/**
|
|
41367
|
+
* The last evidence that actually arrived, per device.
|
|
41368
|
+
*
|
|
41369
|
+
* Falling back to NO evidence on a slow read was not a neutral degradation: it
|
|
41370
|
+
* changed WHICH SLOT the picker chose. Measured on 615 within forty seconds,
|
|
41371
|
+
* same camera, same negotiated 1280x720:
|
|
41372
|
+
*
|
|
41373
|
+
* 10:22:11 mid 10 fps
|
|
41374
|
+
* 10:22:17 low 24 fps
|
|
41375
|
+
* 10:22:26 mid 10 fps
|
|
41376
|
+
* 10:22:50 low 24 fps
|
|
41377
|
+
*
|
|
41378
|
+
* With evidence, `low` classifies as a fit and wins; without it every slot is
|
|
41379
|
+
* `unknown` and the fallback takes `mid`. So the stream a controller received
|
|
41380
|
+
* depended on whether a cap read beat a 1500 ms timer — a coin flip, and one
|
|
41381
|
+
* that hands iOS a different profile on each retry.
|
|
41382
|
+
*
|
|
41383
|
+
* A rate is a property of the camera's encoder configuration, which changes
|
|
41384
|
+
* when an operator changes it and not otherwise. Yesterday's reading is a far
|
|
41385
|
+
* better answer than no reading, and the ONE case that must still see fresh
|
|
41386
|
+
* numbers — the operator lowering a substream — is a deliberate act followed
|
|
41387
|
+
* by a new session, by which time the background read has long landed.
|
|
41388
|
+
*/
|
|
41389
|
+
var lastGoodEvidence = /* @__PURE__ */ new Map();
|
|
41390
|
+
/**
|
|
41391
|
+
* Resolve every profile slot's rate. Never throws and never outlives its
|
|
41392
|
+
* budget: a slow read falls back to this device's last good reading, and only
|
|
41393
|
+
* a device that has never answered at all ends up `unknown`.
|
|
39503
41394
|
*/
|
|
39504
41395
|
async function probeProfileBitrates(input) {
|
|
39505
|
-
const
|
|
39506
|
-
const
|
|
39507
|
-
|
|
39508
|
-
const choices = await probe$1(() => proxy.webrtcSession?.listStreams({}), "webrtcSession.listStreams", input.log);
|
|
41396
|
+
const deviceId = input.bctx.numericDeviceId;
|
|
41397
|
+
const evidence = await gatherRateEvidence(input.bctx.proxy, input.log, lastGoodEvidence.get(deviceId));
|
|
41398
|
+
if (evidence.camStreams !== null || evidence.streamParams !== null) lastGoodEvidence.set(deviceId, evidence);
|
|
39509
41399
|
return resolveProfileBitrates({
|
|
39510
41400
|
slots: input.slots,
|
|
39511
|
-
camStreams: camStreams ?? [],
|
|
41401
|
+
camStreams: evidence.camStreams ?? [],
|
|
41402
|
+
streamParams: evidence.streamParams,
|
|
41403
|
+
choices: evidence.choices ?? []
|
|
41404
|
+
});
|
|
41405
|
+
}
|
|
41406
|
+
/**
|
|
41407
|
+
* Issue the three reads CONCURRENTLY under one budget.
|
|
41408
|
+
*
|
|
41409
|
+
* Exported so the concurrency and the budget can be asserted directly: run in
|
|
41410
|
+
* sequence these latencies add, and adding them is what cost a session.
|
|
41411
|
+
*/
|
|
41412
|
+
async function gatherRateEvidence(proxy, log, lastGood) {
|
|
41413
|
+
const startedAt = Date.now();
|
|
41414
|
+
const evidence = await withDeadline(Promise.all([
|
|
41415
|
+
probe$1(() => proxy.cameraStreams?.getCameraStreams({}), "cameraStreams.getCameraStreams", log),
|
|
41416
|
+
probe$1(() => proxy.streamParams?.getStatus({}), "streamParams.getStatus", log),
|
|
41417
|
+
probe$1(() => proxy.webrtcSession?.listStreams({}), "webrtcSession.listStreams", log)
|
|
41418
|
+
]).then(([camStreams, streamParams, choices]) => ({
|
|
41419
|
+
camStreams,
|
|
39512
41420
|
streamParams,
|
|
39513
|
-
choices
|
|
41421
|
+
choices
|
|
41422
|
+
})), BITRATE_EVIDENCE_BUDGET_MS, lastGood ?? NO_EVIDENCE, () => {
|
|
41423
|
+
log.warn("export-hap: rate evidence ABANDONED on its budget", { meta: {
|
|
41424
|
+
budgetMs: BITRATE_EVIDENCE_BUDGET_MS,
|
|
41425
|
+
fellBackTo: lastGood === void 0 ? "no-evidence" : "last-good",
|
|
41426
|
+
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"
|
|
41427
|
+
} });
|
|
39514
41428
|
});
|
|
41429
|
+
const elapsedMs = Date.now() - startedAt;
|
|
41430
|
+
if (elapsedMs > 1500 / 2) log.info("export-hap: rate evidence was slow", { meta: {
|
|
41431
|
+
elapsedMs,
|
|
41432
|
+
budgetMs: BITRATE_EVIDENCE_BUDGET_MS
|
|
41433
|
+
} });
|
|
41434
|
+
return evidence;
|
|
39515
41435
|
}
|
|
39516
41436
|
async function probe$1(call, label, log) {
|
|
39517
41437
|
try {
|
|
@@ -39529,61 +41449,138 @@ async function probe$1(call, label, log) {
|
|
|
39529
41449
|
return null;
|
|
39530
41450
|
}
|
|
39531
41451
|
}
|
|
41452
|
+
//#endregion
|
|
41453
|
+
//#region src/mappers/builders/stream-ffmpeg-args.ts
|
|
41454
|
+
/**
|
|
41455
|
+
* The ffmpeg PLAN for one HomeKit streaming session.
|
|
41456
|
+
*
|
|
41457
|
+
* This file used to assemble the argument vector by hand. It no longer emits a
|
|
41458
|
+
* single argument: it describes the session as an {@link FfmpegInvocation} and
|
|
41459
|
+
* `buildFfmpegArgs` (`@camstack/types` `ffmpeg/invocation.ts`) emits every one.
|
|
41460
|
+
* The repo keeps exactly ONE argv builder — `scripts/check-ffmpeg-primitive.ts`
|
|
41461
|
+
* Rule 1 refuses a second, and HomeKit was the last exception (D67).
|
|
41462
|
+
*
|
|
41463
|
+
* ## What moved behind the primitive, and what stayed here
|
|
41464
|
+
*
|
|
41465
|
+
* MOVED — everything that describes an ENCODE, because it is the same job every
|
|
41466
|
+
* other live egress does and the repo had five disagreeing copies of it: the
|
|
41467
|
+
* encoder, preset, tune, profile, level, pixel format, rate, GOP, the tight VBV
|
|
41468
|
+
* window ({@link RATE_CONTROL_TIGHT}), the bitstream filter, and the Opus block
|
|
41469
|
+
* ({@link HAP_AUDIO_BASE}).
|
|
41470
|
+
*
|
|
41471
|
+
* STAYED — everything that is a HAP PROTOCOL fact and belongs to no other
|
|
41472
|
+
* consumer: the payload types, the SSRCs (and their signed-int32 coercion), the
|
|
41473
|
+
* MTU baked into each `rtp://…?pkt_size=` target, the loopback ports the
|
|
41474
|
+
* JS-side SRTP encrypt reads from, and the negotiated audio sample rate and
|
|
41475
|
+
* packet time.
|
|
41476
|
+
*
|
|
41477
|
+
* ## The two flags this file exists to protect
|
|
41478
|
+
*
|
|
41479
|
+
* `-g` and `-bsf:v dump_extra` were two of the four causes of the year-long
|
|
41480
|
+
* failure, and both live in the encode plan now. They are asserted by token
|
|
41481
|
+
* AND by position in `__tests__/stream-ffmpeg-argv.spec.ts`, on both the copy
|
|
41482
|
+
* and the encode branch, so the move behind the primitive cannot quietly drop
|
|
41483
|
+
* either. Every other comment below records something learned the expensive
|
|
41484
|
+
* way; deleting one loses the reason a flag is there.
|
|
41485
|
+
*/
|
|
41486
|
+
/**
|
|
41487
|
+
* Opus encoder targets — kept low because:
|
|
41488
|
+
* - Camera audio is overwhelmingly speech / ambient noise; 24 kbps mono
|
|
41489
|
+
* is the published "fullband speech" sweet spot for libopus (well
|
|
41490
|
+
* above the 20 kbps "wideband speech" floor).
|
|
41491
|
+
* - HAP audio is one-shot live (no buffering on the controller side),
|
|
41492
|
+
* so under-shooting the bitrate is cheaper than over-shooting it and
|
|
41493
|
+
* hitting jitter.
|
|
41494
|
+
* - Mono / low-delay profile matches Apple Home's published Opus decoder
|
|
41495
|
+
* expectations for camera accessories.
|
|
41496
|
+
*
|
|
41497
|
+
* The numbers themselves live in `@camstack/types` `ffmpeg/encode-defaults.ts`
|
|
41498
|
+
* now, alongside every other live-egress constant, so the five sets that used
|
|
41499
|
+
* to disagree about Opus channel count can be diffed in one place. Re-exported
|
|
41500
|
+
* here because the session telemetry reports the bitrate it dialled.
|
|
41501
|
+
*/
|
|
41502
|
+
var OPUS_BITRATE_KBPS = 24;
|
|
41503
|
+
/**
|
|
41504
|
+
* The Opus plane, per session.
|
|
41505
|
+
*
|
|
41506
|
+
* Re-encoded regardless of source codec: the source pool is a mix of
|
|
41507
|
+
* PCM_MULAW, PCM_ALAW, G.711 and AAC depending on driver, and Apple Home
|
|
41508
|
+
* expects Opus on the wire.
|
|
41509
|
+
*
|
|
41510
|
+
* `sampleRateHz` and `frameDurationMs` are NEGOTIATED — the controller picks
|
|
41511
|
+
* them — which is why the shared {@link HAP_AUDIO_BASE} leaves both out and
|
|
41512
|
+
* they are filled in here.
|
|
41513
|
+
*
|
|
41514
|
+
* CRITICAL on the sample rate: encode at the rate iOS asked for, never a
|
|
41515
|
+
* constant. iOS's `AudioStreamingSamplerate` enum surfaces as 8 / 16 / 24 kHz;
|
|
41516
|
+
* encoding at 24 when iOS asked for 16 produces RTP timestamps stepping by 480
|
|
41517
|
+
* samples/packet against a clock expecting 320 — the SRTP frames decrypt
|
|
41518
|
+
* cleanly but the speaker stays mute, because the timestamps slide out of the
|
|
41519
|
+
* AV-sync window before the first Opus frame renders. The same request value
|
|
41520
|
+
* drives `audioIntervalScale` in the re-stamping pass, so the two MUST come
|
|
41521
|
+
* from one source.
|
|
41522
|
+
*
|
|
41523
|
+
* On the frame duration: libopus emits exactly one RTP packet per Opus frame at
|
|
41524
|
+
* that duration, and matching HAP's `packet_time` (20 ms on LAN, 30/40/60 on
|
|
41525
|
+
* LTE) is what keeps the 1:1 frame↔packet mapping the controller expects.
|
|
41526
|
+
*/
|
|
41527
|
+
function audioPlan(input) {
|
|
41528
|
+
return {
|
|
41529
|
+
...HAP_AUDIO_BASE,
|
|
41530
|
+
sampleRateHz: input.audioSampleRateKhz * 1e3,
|
|
41531
|
+
frameDurationMs: input.audioPacketTimeMs,
|
|
41532
|
+
vbvBufferKbits: 96
|
|
41533
|
+
};
|
|
41534
|
+
}
|
|
39532
41535
|
/**
|
|
39533
41536
|
* Two outputs from one input: video SRTP and audio SRTP, one process, one
|
|
39534
|
-
* lifetime, one kill signal.
|
|
39535
|
-
*
|
|
41537
|
+
* lifetime, one kill signal. The shared builder's `rtp-outputs` sink maps each
|
|
41538
|
+
* plane explicitly (`-an -map 0:v:0` / `-vn -map 0:a:0?`) so ffmpeg never
|
|
41539
|
+
* guesses which stream belongs where, and `0:a:0?` makes the audio optional so
|
|
41540
|
+
* a source with no microphone skips it instead of failing the invocation.
|
|
39536
41541
|
*/
|
|
39537
|
-
|
|
39538
|
-
|
|
39539
|
-
|
|
39540
|
-
|
|
39541
|
-
|
|
39542
|
-
|
|
39543
|
-
|
|
39544
|
-
|
|
39545
|
-
|
|
39546
|
-
|
|
39547
|
-
|
|
39548
|
-
|
|
39549
|
-
|
|
39550
|
-
|
|
39551
|
-
|
|
39552
|
-
|
|
39553
|
-
|
|
39554
|
-
|
|
39555
|
-
|
|
39556
|
-
|
|
39557
|
-
|
|
39558
|
-
|
|
39559
|
-
|
|
39560
|
-
|
|
39561
|
-
|
|
39562
|
-
|
|
39563
|
-
|
|
39564
|
-
|
|
39565
|
-
|
|
39566
|
-
|
|
39567
|
-
|
|
39568
|
-
|
|
39569
|
-
|
|
39570
|
-
|
|
39571
|
-
|
|
39572
|
-
|
|
39573
|
-
|
|
39574
|
-
|
|
39575
|
-
|
|
39576
|
-
|
|
39577
|
-
|
|
39578
|
-
|
|
39579
|
-
"-payload_type",
|
|
39580
|
-
String(input.audioPayloadType),
|
|
39581
|
-
"-ssrc",
|
|
39582
|
-
String(input.audioSsrcSigned),
|
|
39583
|
-
"-f",
|
|
39584
|
-
"rtp",
|
|
39585
|
-
input.audioTarget
|
|
39586
|
-
];
|
|
41542
|
+
/**
|
|
41543
|
+
* How long ffmpeg may inspect the broker's restream before emitting.
|
|
41544
|
+
*
|
|
41545
|
+
* Not zero. A zero-length probe makes ffmpeg trust the SDP completely, and an
|
|
41546
|
+
* RTSP source that announces a track it then never sends would leave the
|
|
41547
|
+
* mapping wrong with no way to notice. 200 ms and 64 KB is far below the
|
|
41548
|
+
* shortest key-frame interval on this fleet while still letting the demuxer
|
|
41549
|
+
* see real packets — enough to be honest, short enough that nobody watches it.
|
|
41550
|
+
*/
|
|
41551
|
+
var HAP_INPUT_PROBE = {
|
|
41552
|
+
analyzeDurationUs: 2e5,
|
|
41553
|
+
probeSizeBytes: 64 * 1024
|
|
41554
|
+
};
|
|
41555
|
+
function buildSessionInvocation(input) {
|
|
41556
|
+
return {
|
|
41557
|
+
logLevel: "warning",
|
|
41558
|
+
decodeHwAccel: input.decode.hwaccel,
|
|
41559
|
+
input: {
|
|
41560
|
+
url: input.rtspUrl,
|
|
41561
|
+
rtspTransport: "tcp",
|
|
41562
|
+
analyzeDurationUs: HAP_INPUT_PROBE.analyzeDurationUs,
|
|
41563
|
+
probeSizeBytes: HAP_INPUT_PROBE.probeSizeBytes,
|
|
41564
|
+
...input.decode.extraInputArgs.length > 0 ? { extraArgs: input.decode.extraInputArgs } : {}
|
|
41565
|
+
},
|
|
41566
|
+
video: input.video,
|
|
41567
|
+
audio: audioPlan(input),
|
|
41568
|
+
threadCount: 0,
|
|
41569
|
+
outputArgs: [],
|
|
41570
|
+
sink: {
|
|
41571
|
+
kind: "rtp-outputs",
|
|
41572
|
+
video: {
|
|
41573
|
+
url: input.videoTarget,
|
|
41574
|
+
payloadType: input.videoPayloadType,
|
|
41575
|
+
ssrc: input.videoSsrcSigned
|
|
41576
|
+
},
|
|
41577
|
+
audio: {
|
|
41578
|
+
url: input.audioTarget,
|
|
41579
|
+
payloadType: input.audioPayloadType,
|
|
41580
|
+
ssrc: input.audioSsrcSigned
|
|
41581
|
+
}
|
|
41582
|
+
}
|
|
41583
|
+
};
|
|
39587
41584
|
}
|
|
39588
41585
|
/**
|
|
39589
41586
|
* The resolutions we offer, before rates are attached. Same list the delegate
|
|
@@ -39723,6 +41720,84 @@ var CAM_PROFILES = [
|
|
|
39723
41720
|
function toCamProfile(profileId) {
|
|
39724
41721
|
return CAM_PROFILES.find((p) => p === profileId) ?? null;
|
|
39725
41722
|
}
|
|
41723
|
+
//#endregion
|
|
41724
|
+
//#region src/mappers/builders/h264-idr.ts
|
|
41725
|
+
/**
|
|
41726
|
+
* Does this RTP packet carry the start of an H.264 IDR?
|
|
41727
|
+
*
|
|
41728
|
+
* A pass-through session cannot manufacture a key frame on demand — it can
|
|
41729
|
+
* only forward the one the camera decides to emit. So the number that decides
|
|
41730
|
+
* whether a controller sees a picture or a loader is *how long it waited for
|
|
41731
|
+
* the first IDR*, and until now nothing measured it: a session could report
|
|
41732
|
+
* a thousand packets forwarded, zero loss, and a blank screen, with no field
|
|
41733
|
+
* distinguishing "the stream is broken" from "the next key frame is 20
|
|
41734
|
+
* seconds away".
|
|
41735
|
+
*
|
|
41736
|
+
* That is the whole reason this exists, so it is deliberately narrow: a
|
|
41737
|
+
* boolean per packet, no state, no allocation, and it never throws. It runs on
|
|
41738
|
+
* every forwarded video packet, and a parser that throws on a malformed packet
|
|
41739
|
+
* would take the media path down with it.
|
|
41740
|
+
*/
|
|
41741
|
+
/** NAL unit type carrying a coded slice of an IDR picture (RFC 6184 §5.2). */
|
|
41742
|
+
var NAL_TYPE_IDR = 5;
|
|
41743
|
+
/** Single-time aggregation packet — several NALs in one RTP payload. */
|
|
41744
|
+
var NAL_TYPE_STAP_A = 24;
|
|
41745
|
+
/** Fragmentation units: one NAL spread over several RTP payloads. */
|
|
41746
|
+
var NAL_TYPE_FU_A = 28;
|
|
41747
|
+
var NAL_TYPE_FU_B = 29;
|
|
41748
|
+
var RTP_MIN_HEADER_BYTES = 12;
|
|
41749
|
+
var NAL_TYPE_MASK = 31;
|
|
41750
|
+
/** FU header start bit — set only on the FIRST fragment of a fragmented NAL. */
|
|
41751
|
+
var FU_START_BIT = 128;
|
|
41752
|
+
function rtpPacketCarriesIdr(packet) {
|
|
41753
|
+
const payloadStart = rtpPayloadOffset(packet);
|
|
41754
|
+
if (payloadStart === null) return false;
|
|
41755
|
+
const firstPayloadByte = packet[payloadStart];
|
|
41756
|
+
if (firstPayloadByte === void 0) return false;
|
|
41757
|
+
const nalType = firstPayloadByte & NAL_TYPE_MASK;
|
|
41758
|
+
if (nalType === NAL_TYPE_FU_A || nalType === NAL_TYPE_FU_B) {
|
|
41759
|
+
const fuHeader = packet[payloadStart + 1];
|
|
41760
|
+
if (fuHeader === void 0) return false;
|
|
41761
|
+
if ((fuHeader & FU_START_BIT) === 0) return false;
|
|
41762
|
+
return (fuHeader & NAL_TYPE_MASK) === NAL_TYPE_IDR;
|
|
41763
|
+
}
|
|
41764
|
+
if (nalType === NAL_TYPE_STAP_A) return stapContainsIdr(packet, payloadStart + 1);
|
|
41765
|
+
return nalType === NAL_TYPE_IDR;
|
|
41766
|
+
}
|
|
41767
|
+
/**
|
|
41768
|
+
* Byte offset of the RTP payload, or `null` when the packet is too short to
|
|
41769
|
+
* hold one. The variable-length parts are what make this worth a function:
|
|
41770
|
+
* a fixed offset of 12 is right for every packet ffmpeg emits today and wrong
|
|
41771
|
+
* the moment one carries a CSRC list or a header extension.
|
|
41772
|
+
*/
|
|
41773
|
+
function rtpPayloadOffset(packet) {
|
|
41774
|
+
if (packet.length <= RTP_MIN_HEADER_BYTES) return null;
|
|
41775
|
+
const flags = packet[0];
|
|
41776
|
+
if (flags === void 0) return null;
|
|
41777
|
+
const csrcCount = flags & 15;
|
|
41778
|
+
const hasExtension = (flags & 16) !== 0;
|
|
41779
|
+
let offset = RTP_MIN_HEADER_BYTES + csrcCount * 4;
|
|
41780
|
+
if (hasExtension) {
|
|
41781
|
+
if (offset + 4 > packet.length) return null;
|
|
41782
|
+
const words = packet.readUInt16BE(offset + 2);
|
|
41783
|
+
offset += 4 + words * 4;
|
|
41784
|
+
}
|
|
41785
|
+
return offset < packet.length ? offset : null;
|
|
41786
|
+
}
|
|
41787
|
+
/** Walk a STAP-A's `[size][nal]` pairs looking for an IDR. */
|
|
41788
|
+
function stapContainsIdr(packet, start) {
|
|
41789
|
+
let offset = start;
|
|
41790
|
+
while (offset + 2 <= packet.length) {
|
|
41791
|
+
const size = packet.readUInt16BE(offset);
|
|
41792
|
+
offset += 2;
|
|
41793
|
+
if (size === 0 || offset + size > packet.length) return false;
|
|
41794
|
+
const nalHeader = packet[offset];
|
|
41795
|
+
if (nalHeader === void 0) return false;
|
|
41796
|
+
if ((nalHeader & NAL_TYPE_MASK) === NAL_TYPE_IDR) return true;
|
|
41797
|
+
offset += size;
|
|
41798
|
+
}
|
|
41799
|
+
return false;
|
|
41800
|
+
}
|
|
39726
41801
|
/**
|
|
39727
41802
|
* How long after spawn an exit still counts as "hardware init failed".
|
|
39728
41803
|
*
|
|
@@ -39787,34 +41862,65 @@ function software(reason) {
|
|
|
39787
41862
|
return {
|
|
39788
41863
|
kind: "software",
|
|
39789
41864
|
reason,
|
|
41865
|
+
hwaccel: null,
|
|
41866
|
+
extraInputArgs: [],
|
|
39790
41867
|
args: []
|
|
39791
41868
|
};
|
|
39792
41869
|
}
|
|
39793
41870
|
function hardware(backend, source, input) {
|
|
41871
|
+
if (input.recentlyFailedBackend === backend) return software("hardware-attempt-failed");
|
|
41872
|
+
const { hwaccel, extraInputArgs } = decodePlan(backend, input);
|
|
39794
41873
|
return {
|
|
39795
41874
|
kind: "hardware",
|
|
39796
41875
|
backend,
|
|
39797
41876
|
source,
|
|
39798
|
-
|
|
41877
|
+
hwaccel,
|
|
41878
|
+
extraInputArgs,
|
|
41879
|
+
args: [
|
|
41880
|
+
"-hwaccel",
|
|
41881
|
+
hwaccel,
|
|
41882
|
+
...extraInputArgs
|
|
41883
|
+
]
|
|
39799
41884
|
};
|
|
39800
41885
|
}
|
|
39801
41886
|
/**
|
|
39802
|
-
* The input-side
|
|
41887
|
+
* The input-side decode configuration, and nothing else.
|
|
39803
41888
|
*
|
|
39804
41889
|
* No `-hwaccel_output_format`: the decoded frames have to land in system
|
|
39805
41890
|
* memory for libx264 to scale and encode them. Setting it would keep them on
|
|
39806
41891
|
* the GPU, which only pays off with a GPU scale filter — and that is the
|
|
39807
41892
|
* decoder addon's job, not a two-output SRTP session's.
|
|
41893
|
+
*
|
|
41894
|
+
* The two halves are returned SEPARATELY because the shared argv builder emits
|
|
41895
|
+
* `-hwaccel` itself (it is the only function allowed to, so the flag cannot
|
|
41896
|
+
* drift past `-i`) and takes everything else as the input plan's `extraArgs`.
|
|
39808
41897
|
*/
|
|
39809
|
-
function
|
|
39810
|
-
if (backend === "videotoolbox" && input.platform === "darwin") return
|
|
39811
|
-
|
|
39812
|
-
|
|
39813
|
-
|
|
41898
|
+
function decodePlan(backend, input) {
|
|
41899
|
+
if (backend === "videotoolbox" && input.platform === "darwin") return {
|
|
41900
|
+
hwaccel: "auto",
|
|
41901
|
+
extraInputArgs: []
|
|
41902
|
+
};
|
|
41903
|
+
return {
|
|
41904
|
+
hwaccel: backend,
|
|
41905
|
+
extraInputArgs: RENDER_NODE_BACKENDS.includes(backend) ? ["-hwaccel_device", input.renderDevice ?? "/dev/dri/renderD128"] : []
|
|
41906
|
+
};
|
|
39814
41907
|
}
|
|
39815
41908
|
//#endregion
|
|
39816
41909
|
//#region src/mappers/builders/stream-hwaccel-probe.ts
|
|
39817
41910
|
/**
|
|
41911
|
+
* The real read: `decoder.getInfo`, pinned to the LOCAL node.
|
|
41912
|
+
*
|
|
41913
|
+
* Pinned explicitly rather than left to routing, because an unpinned singleton
|
|
41914
|
+
* cap answers from whichever node owns it and would report the WRONG host's
|
|
41915
|
+
* hardware.
|
|
41916
|
+
*/
|
|
41917
|
+
function decoderInfoSourceFromContext(ctx) {
|
|
41918
|
+
return {
|
|
41919
|
+
localNodeId: ctx.kernel?.localNodeId,
|
|
41920
|
+
readInfo: (nodeId) => ctx.api.decoder.getInfo.query(void 0, nodePin(nodeId))
|
|
41921
|
+
};
|
|
41922
|
+
}
|
|
41923
|
+
/**
|
|
39818
41924
|
* Read this node's decode-hwaccel state, or `null` when nothing answered.
|
|
39819
41925
|
*
|
|
39820
41926
|
* Never throws. `null` means "we do not know", which
|
|
@@ -39822,27 +41928,144 @@ function decodeArgs(backend, input) {
|
|
|
39822
41928
|
* the safe direction, because a guess here costs the whole stream.
|
|
39823
41929
|
*/
|
|
39824
41930
|
async function probeDecoderHwaccel(input) {
|
|
39825
|
-
const {
|
|
39826
|
-
const
|
|
41931
|
+
const { source, log, memo } = input;
|
|
41932
|
+
const memoised = memo.read();
|
|
41933
|
+
if (memoised !== void 0) return memoised;
|
|
41934
|
+
const nodeId = source.localNodeId;
|
|
39827
41935
|
if (nodeId === void 0 || nodeId.length === 0) {
|
|
39828
41936
|
log.warn("export-hap: hwaccel probe skipped — no local node id, decoding in SOFTWARE");
|
|
39829
41937
|
return null;
|
|
39830
41938
|
}
|
|
39831
41939
|
try {
|
|
39832
|
-
const info = await
|
|
41940
|
+
const info = await source.readInfo(nodeId);
|
|
39833
41941
|
if (info === null || info === void 0) {
|
|
39834
41942
|
log.info("export-hap: hwaccel probe returned nothing — decoding in SOFTWARE", { meta: { nodeId } });
|
|
41943
|
+
memo.write(null);
|
|
39835
41944
|
return null;
|
|
39836
41945
|
}
|
|
39837
|
-
|
|
41946
|
+
const reading = {
|
|
39838
41947
|
hwaccel: info.hwaccel ?? null,
|
|
39839
41948
|
probedBestHwaccel: info.probedBestHwaccel ?? null
|
|
39840
41949
|
};
|
|
41950
|
+
memo.write(reading);
|
|
41951
|
+
return reading;
|
|
39841
41952
|
} catch (err) {
|
|
39842
41953
|
log.warn("export-hap: hwaccel probe failed — decoding in SOFTWARE", { meta: {
|
|
39843
41954
|
nodeId,
|
|
39844
41955
|
error: err instanceof Error ? err.message : String(err)
|
|
39845
41956
|
} });
|
|
41957
|
+
memo.write(null);
|
|
41958
|
+
return null;
|
|
41959
|
+
}
|
|
41960
|
+
}
|
|
41961
|
+
/**
|
|
41962
|
+
* What we ask for on the VIDEO loopback socket.
|
|
41963
|
+
*
|
|
41964
|
+
* Generous on purpose: the cost is virtual address space the kernel only
|
|
41965
|
+
* commits as datagrams actually queue, and the failure it prevents is a black
|
|
41966
|
+
* tile. Sized well above {@link KEYFRAME_BURST_FLOOR_BYTES} so a slow drain
|
|
41967
|
+
* (the JS forwarder is on the same event loop as everything else this addon
|
|
41968
|
+
* does) still has headroom.
|
|
41969
|
+
*/
|
|
41970
|
+
var VIDEO_LOOPBACK_RCVBUF_BYTES = 8 * 1024 * 1024;
|
|
41971
|
+
/**
|
|
41972
|
+
* What we ask for on the AUDIO loopback socket.
|
|
41973
|
+
*
|
|
41974
|
+
* Audio never bursts — that is the control in this experiment, and it is why
|
|
41975
|
+
* the two legs get different numbers rather than one shared constant. If audio
|
|
41976
|
+
* ever starts dropping at the same buffer that carries video fine, the cause is
|
|
41977
|
+
* not burst size.
|
|
41978
|
+
*/
|
|
41979
|
+
var AUDIO_LOOPBACK_RCVBUF_BYTES = 1024 * 1024;
|
|
41980
|
+
function errMsg$9(err) {
|
|
41981
|
+
return err instanceof Error ? err.message : String(err);
|
|
41982
|
+
}
|
|
41983
|
+
/**
|
|
41984
|
+
* Set `SO_RCVBUF` and READ IT BACK.
|
|
41985
|
+
*
|
|
41986
|
+
* Never throws: a platform that refuses the option must cost the buffer, never
|
|
41987
|
+
* the session. The read-back is the point — a request the kernel clamped and a
|
|
41988
|
+
* request it honoured are indistinguishable at the call site.
|
|
41989
|
+
*/
|
|
41990
|
+
function applyReceiveBuffer(socket, requestedBytes) {
|
|
41991
|
+
let error = null;
|
|
41992
|
+
try {
|
|
41993
|
+
socket.setRecvBufferSize(requestedBytes);
|
|
41994
|
+
} catch (err) {
|
|
41995
|
+
error = errMsg$9(err);
|
|
41996
|
+
}
|
|
41997
|
+
let effectiveBytes = null;
|
|
41998
|
+
try {
|
|
41999
|
+
effectiveBytes = socket.getRecvBufferSize();
|
|
42000
|
+
} catch (err) {
|
|
42001
|
+
if (error === null) error = errMsg$9(err);
|
|
42002
|
+
}
|
|
42003
|
+
return {
|
|
42004
|
+
requestedBytes,
|
|
42005
|
+
effectiveBytes,
|
|
42006
|
+
clamped: effectiveBytes !== null && effectiveBytes < requestedBytes,
|
|
42007
|
+
sufficientForKeyframeBurst: effectiveBytes !== null && effectiveBytes >= 2097152,
|
|
42008
|
+
error
|
|
42009
|
+
};
|
|
42010
|
+
}
|
|
42011
|
+
/** Where the kernel publishes per-socket UDP counters, by address family. */
|
|
42012
|
+
var PROC_NET_UDP = {
|
|
42013
|
+
ipv4: "/proc/net/udp",
|
|
42014
|
+
ipv6: "/proc/net/udp6"
|
|
42015
|
+
};
|
|
42016
|
+
/**
|
|
42017
|
+
* The per-socket `drops` count for `port`, out of a `/proc/net/udp` table.
|
|
42018
|
+
*
|
|
42019
|
+
* Pure so the format assumption is pinned by a test rather than by a live
|
|
42020
|
+
* kernel. Returns `null` when the port has no row — which is NOT the same as
|
|
42021
|
+
* zero drops, and the two must never collapse: `0` is evidence the buffer held,
|
|
42022
|
+
* `null` is the absence of evidence.
|
|
42023
|
+
*/
|
|
42024
|
+
function parseUdpSocketDrops(table, port) {
|
|
42025
|
+
const lines = table.split("\n");
|
|
42026
|
+
for (const line of lines) {
|
|
42027
|
+
const fields = line.trim().split(/\s+/);
|
|
42028
|
+
if (fields.length < 13) continue;
|
|
42029
|
+
const local = fields[1];
|
|
42030
|
+
if (local === void 0) continue;
|
|
42031
|
+
const hexPort = local.split(":")[1];
|
|
42032
|
+
if (hexPort === void 0) continue;
|
|
42033
|
+
const parsedPort = Number.parseInt(hexPort, 16);
|
|
42034
|
+
if (!Number.isFinite(parsedPort) || parsedPort !== port) continue;
|
|
42035
|
+
const drops = Number(fields[fields.length - 1]);
|
|
42036
|
+
return Number.isFinite(drops) ? drops : null;
|
|
42037
|
+
}
|
|
42038
|
+
return null;
|
|
42039
|
+
}
|
|
42040
|
+
/**
|
|
42041
|
+
* Fold a fresh drop sample into the one already held.
|
|
42042
|
+
*
|
|
42043
|
+
* A socket that has been CLOSED disappears from `/proc/net/udp`, so a resample
|
|
42044
|
+
* after teardown returns `null` — "I can no longer look", which must never
|
|
42045
|
+
* erase "I looked and it was 0". The first live session that proved the buffer
|
|
42046
|
+
* fix reported `videoLoopKernelDrops=null` for exactly this reason: on a
|
|
42047
|
+
* controller `stop` the sockets are closed synchronously while the summary is
|
|
42048
|
+
* emitted later from ffmpeg's `exit` handler.
|
|
42049
|
+
*/
|
|
42050
|
+
function mergeDropSample(previous, sampled) {
|
|
42051
|
+
return sampled ?? previous;
|
|
42052
|
+
}
|
|
42053
|
+
/**
|
|
42054
|
+
* Read the kernel's drop counter for a bound local UDP port.
|
|
42055
|
+
*
|
|
42056
|
+
* Linux only — `null` on every other platform and on every read failure, which
|
|
42057
|
+
* is honest: "we could not look" and "nothing was dropped" are different
|
|
42058
|
+
* answers and this returns the first as `null`.
|
|
42059
|
+
*
|
|
42060
|
+
* Synchronous on purpose. It is called at the session heartbeat (5 s) and once
|
|
42061
|
+
* at teardown, against a memory-backed pseudo-file; making it async would mean
|
|
42062
|
+
* the SUMMARY line — the one line this experiment is read from — could not
|
|
42063
|
+
* carry a fresh count, which is the only reason it exists.
|
|
42064
|
+
*/
|
|
42065
|
+
function readUdpSocketDrops(port, ipVersion) {
|
|
42066
|
+
try {
|
|
42067
|
+
return parseUdpSocketDrops((0, node_fs.readFileSync)(PROC_NET_UDP[ipVersion], "utf8"), port);
|
|
42068
|
+
} catch {
|
|
39846
42069
|
return null;
|
|
39847
42070
|
}
|
|
39848
42071
|
}
|
|
@@ -39971,6 +42194,9 @@ function summariseSession(snapshot) {
|
|
|
39971
42194
|
encodeBudgetKbps: slot?.budgetKbps ?? null,
|
|
39972
42195
|
fitNotes: slot?.fitNotes ?? [],
|
|
39973
42196
|
videoPacketsForwarded: snapshot.videoPacketsForwarded,
|
|
42197
|
+
msToFirstKeyframe: snapshot.firstKeyframeAtMs === null || snapshot.startedAtMs === null ? null : snapshot.firstKeyframeAtMs - snapshot.startedAtMs,
|
|
42198
|
+
videoKeyframes: snapshot.videoKeyframes,
|
|
42199
|
+
maxKeyframeGapMs: snapshot.maxKeyframeGapMs,
|
|
39974
42200
|
audioPacketsForwarded: snapshot.audioPacketsForwarded,
|
|
39975
42201
|
videoRtcpSrSent: snapshot.videoRtcpSrSent,
|
|
39976
42202
|
audioRtcpSrSent: snapshot.audioRtcpSrSent,
|
|
@@ -39985,11 +42211,21 @@ function summariseSession(snapshot) {
|
|
|
39985
42211
|
videoLossVerdict: lossVerdict(snapshot.videoReceiverReports),
|
|
39986
42212
|
audioLossVerdict: lossVerdict(snapshot.audioReceiverReports),
|
|
39987
42213
|
mediaStarved: snapshot.videoPacketsForwarded === 0,
|
|
42214
|
+
videoLoopRcvbufRequestedBytes: snapshot.videoLoopback.rcvbufRequestedBytes,
|
|
42215
|
+
videoLoopRcvbufBytes: snapshot.videoLoopback.rcvbufEffectiveBytes,
|
|
42216
|
+
videoLoopRcvbufClamped: snapshot.videoLoopback.rcvbufClamped,
|
|
42217
|
+
videoLoopKernelDrops: snapshot.videoLoopback.kernelDrops,
|
|
42218
|
+
videoLoopKernelDropped: (snapshot.videoLoopback.kernelDrops ?? 0) > 0,
|
|
42219
|
+
audioLoopRcvbufBytes: snapshot.audioLoopback.rcvbufEffectiveBytes,
|
|
42220
|
+
audioLoopKernelDrops: snapshot.audioLoopback.kernelDrops,
|
|
42221
|
+
audioLoopKernelDropped: (snapshot.audioLoopback.kernelDrops ?? 0) > 0,
|
|
39988
42222
|
drops: nonZeroDrops(snapshot.drops)
|
|
39989
42223
|
};
|
|
39990
42224
|
}
|
|
39991
42225
|
//#endregion
|
|
39992
42226
|
//#region src/mappers/builders/camera-streams.ts
|
|
42227
|
+
/** A decoder that is slow to describe itself costs hardware decode, not the session. */
|
|
42228
|
+
var HWACCEL_PROBE_BUDGET_MS = 1e3;
|
|
39993
42229
|
var SRTP_KEY_LEN = 16;
|
|
39994
42230
|
var SRTP_SALT_LEN = 14;
|
|
39995
42231
|
/**
|
|
@@ -40073,6 +42309,7 @@ function buildCameraStreamingDelegate(bctx, advertised) {
|
|
|
40073
42309
|
const hadFfmpeg = session.ffmpeg !== null;
|
|
40074
42310
|
killFfmpeg(session, ctx, numericDeviceId);
|
|
40075
42311
|
stopHeartbeat(session);
|
|
42312
|
+
sampleLoopbackDrops(session);
|
|
40076
42313
|
if (!hadFfmpeg) logSessionSummary(session, log, "accessory-dispose-no-ffmpeg");
|
|
40077
42314
|
await closeIntercomTalkSession(session, bctx).catch(() => void 0);
|
|
40078
42315
|
closeSocket(session.videoUdp);
|
|
@@ -40101,8 +42338,10 @@ async function prepareStream(request, sessions, bctx) {
|
|
|
40101
42338
|
const localIp = pickLocalInterfaceIp(request.targetAddress, ipVersion);
|
|
40102
42339
|
const videoUdp = await bindUdp(ipVersion, localIp);
|
|
40103
42340
|
const audioUdp = await bindUdp(ipVersion, localIp);
|
|
40104
|
-
const
|
|
40105
|
-
const
|
|
42341
|
+
const videoLoop = await bindLoopback(ipVersion, VIDEO_LOOPBACK_RCVBUF_BYTES);
|
|
42342
|
+
const audioLoop = await bindLoopback(ipVersion, AUDIO_LOOPBACK_RCVBUF_BYTES);
|
|
42343
|
+
const videoLoopUdp = videoLoop.socket;
|
|
42344
|
+
const audioLoopUdp = audioLoop.socket;
|
|
40106
42345
|
const localVideoPort = videoUdp.address().port;
|
|
40107
42346
|
const localAudioPort = audioUdp.address().port;
|
|
40108
42347
|
if (request.video.srtp_key.length !== SRTP_KEY_LEN || request.video.srtp_salt.length !== SRTP_SALT_LEN || request.audio.srtp_key.length !== SRTP_KEY_LEN || request.audio.srtp_salt.length !== SRTP_SALT_LEN) {
|
|
@@ -40178,6 +42417,10 @@ async function prepareStream(request, sessions, bctx) {
|
|
|
40178
42417
|
drops: emptyDropCounters(),
|
|
40179
42418
|
videoPacketsForwarded: 0,
|
|
40180
42419
|
audioPacketsForwarded: 0,
|
|
42420
|
+
videoKeyframes: 0,
|
|
42421
|
+
firstKeyframeAtMs: null,
|
|
42422
|
+
lastKeyframeAtMs: null,
|
|
42423
|
+
maxKeyframeGapMs: 0,
|
|
40181
42424
|
videoRtcpSrSent: 0,
|
|
40182
42425
|
audioRtcpSrSent: 0,
|
|
40183
42426
|
videoRtcpReceived: 0,
|
|
@@ -40217,6 +42460,12 @@ async function prepareStream(request, sessions, bctx) {
|
|
|
40217
42460
|
audioInSrtcp,
|
|
40218
42461
|
audioSendGate: null,
|
|
40219
42462
|
ipVersion,
|
|
42463
|
+
videoLoopRcvbuf: videoLoop.buffer,
|
|
42464
|
+
audioLoopRcvbuf: audioLoop.buffer,
|
|
42465
|
+
videoLoopPort: videoLoopUdp.address().port,
|
|
42466
|
+
audioLoopPort: audioLoopUdp.address().port,
|
|
42467
|
+
videoLoopKernelDrops: null,
|
|
42468
|
+
audioLoopKernelDrops: null,
|
|
40220
42469
|
ffmpeg: null,
|
|
40221
42470
|
lastStartParams: null,
|
|
40222
42471
|
upstreamAudioSrtp,
|
|
@@ -40256,6 +42505,7 @@ async function prepareStream(request, sessions, bctx) {
|
|
|
40256
42505
|
});
|
|
40257
42506
|
videoLoopUdp.on("message", (rtpPacket) => {
|
|
40258
42507
|
session.videoPacketsForwarded += 1;
|
|
42508
|
+
if (rtpPacketCarriesIdr(rtpPacket)) recordKeyframe(session);
|
|
40259
42509
|
if (session.videoPacketsForwarded === 1) tagLog.info("export-hap: first video packet from ffmpeg", { meta: {
|
|
40260
42510
|
sessionId: session.sessionId,
|
|
40261
42511
|
bytes: rtpPacket.length
|
|
@@ -40271,6 +42521,8 @@ async function prepareStream(request, sessions, bctx) {
|
|
|
40271
42521
|
bctx.ctx.logger.withTags({ deviceId: bctx.numericDeviceId }).debug("export-hap: incoming-audio handler error (dropped)", { meta: { error: errMsg$8(err) } });
|
|
40272
42522
|
});
|
|
40273
42523
|
});
|
|
42524
|
+
logLoopbackBuffer(tagLog, request.sessionID, "video", videoLoop.buffer);
|
|
42525
|
+
logLoopbackBuffer(tagLog, request.sessionID, "audio", audioLoop.buffer);
|
|
40274
42526
|
tagLog.info("export-hap: stream prepared", { meta: {
|
|
40275
42527
|
sessionId: request.sessionID,
|
|
40276
42528
|
controllerAddress: request.targetAddress,
|
|
@@ -40351,12 +42603,45 @@ function sameIpv4Subnet(a, mask, b) {
|
|
|
40351
42603
|
return true;
|
|
40352
42604
|
}
|
|
40353
42605
|
/**
|
|
42606
|
+
* Report one loopback socket's receive buffer.
|
|
42607
|
+
*
|
|
42608
|
+
* `warn` when the video leg cannot hold a 4K key-frame burst: that is the state
|
|
42609
|
+
* in which this addon silently drops most of a key frame and the tile stays
|
|
42610
|
+
* black, and it was invisible for the whole life of this code path.
|
|
42611
|
+
*/
|
|
42612
|
+
function logLoopbackBuffer(log, sessionId, leg, outcome) {
|
|
42613
|
+
const meta = {
|
|
42614
|
+
sessionId,
|
|
42615
|
+
leg,
|
|
42616
|
+
requestedBytes: outcome.requestedBytes,
|
|
42617
|
+
effectiveBytes: outcome.effectiveBytes,
|
|
42618
|
+
clamped: outcome.clamped,
|
|
42619
|
+
sufficientForKeyframeBurst: outcome.sufficientForKeyframeBurst,
|
|
42620
|
+
error: outcome.error
|
|
42621
|
+
};
|
|
42622
|
+
if (leg === "video" && !outcome.sufficientForKeyframeBurst) {
|
|
42623
|
+
log.warn("export-hap: loopback receive buffer is TOO SMALL for a key-frame burst — raise net.core.rmem_max on the host", { meta });
|
|
42624
|
+
return;
|
|
42625
|
+
}
|
|
42626
|
+
log.info("export-hap: loopback receive buffer", { meta });
|
|
42627
|
+
}
|
|
42628
|
+
/**
|
|
40354
42629
|
* Resolve once-per-session: the local IP we bind iOS-facing sockets to
|
|
40355
42630
|
* AND its mate on `127.0.0.1` for the ffmpeg loopback path. Both go
|
|
40356
42631
|
* through the bounded-wait `dgram.bind` pattern.
|
|
42632
|
+
*
|
|
42633
|
+
* `SO_RCVBUF` is set AFTER the bind and read back, never assumed. Until
|
|
42634
|
+
* 2026-08-07 nothing set it at all, so these sockets ran on
|
|
42635
|
+
* `net.core.rmem_default` (212 992 B on this hub) — about a fifth of one 4K
|
|
42636
|
+
* key frame, which arrives as ~750 datagrams in one burst. See
|
|
42637
|
+
* `stream-socket-buffer.ts` for the measurement.
|
|
40357
42638
|
*/
|
|
40358
|
-
async function bindLoopback(ipVersion) {
|
|
40359
|
-
|
|
42639
|
+
async function bindLoopback(ipVersion, requestedRcvbufBytes) {
|
|
42640
|
+
const socket = await bindUdp(ipVersion, ipVersion === "ipv6" ? "::1" : "127.0.0.1");
|
|
42641
|
+
return {
|
|
42642
|
+
socket,
|
|
42643
|
+
buffer: applyReceiveBuffer(socket, requestedRcvbufBytes)
|
|
42644
|
+
};
|
|
40360
42645
|
}
|
|
40361
42646
|
/** Book a named drop. Every silent `return` on the streaming path routes here. */
|
|
40362
42647
|
function drop(session, reason) {
|
|
@@ -40497,6 +42782,30 @@ function logReceiverReports(session, leg, reports, isFirst, log) {
|
|
|
40497
42782
|
if (!shouldLogReceiverReport(session, leg)) return;
|
|
40498
42783
|
log.info("export-hap: controller receiver report", { meta });
|
|
40499
42784
|
}
|
|
42785
|
+
/**
|
|
42786
|
+
* Record a forwarded key frame. Kept separate from the packet counter because
|
|
42787
|
+
* the interesting quantity is TIMING, not a tally: the first arrival dates the
|
|
42788
|
+
* moment the controller could begin decoding, and the widest gap says how long
|
|
42789
|
+
* a mid-GOP join can be expected to stare at a loader.
|
|
42790
|
+
*/
|
|
42791
|
+
function recordKeyframe(session) {
|
|
42792
|
+
const now = Date.now();
|
|
42793
|
+
session.videoKeyframes += 1;
|
|
42794
|
+
if (session.firstKeyframeAtMs === null) session.firstKeyframeAtMs = now;
|
|
42795
|
+
else if (session.lastKeyframeAtMs !== null) session.maxKeyframeGapMs = Math.max(session.maxKeyframeGapMs, now - session.lastKeyframeAtMs);
|
|
42796
|
+
session.lastKeyframeAtMs = now;
|
|
42797
|
+
}
|
|
42798
|
+
/**
|
|
42799
|
+
* Refresh the kernel's per-socket drop counters.
|
|
42800
|
+
*
|
|
42801
|
+
* Called immediately before every line that reports them, because a stale
|
|
42802
|
+
* sample on the summary would answer the experiment's central question with
|
|
42803
|
+
* data from five seconds earlier. Cheap: `/proc/net/udp` is memory-backed.
|
|
42804
|
+
*/
|
|
42805
|
+
function sampleLoopbackDrops(session) {
|
|
42806
|
+
session.videoLoopKernelDrops = mergeDropSample(session.videoLoopKernelDrops, readUdpSocketDrops(session.videoLoopPort, session.ipVersion));
|
|
42807
|
+
session.audioLoopKernelDrops = mergeDropSample(session.audioLoopKernelDrops, readUdpSocketDrops(session.audioLoopPort, session.ipVersion));
|
|
42808
|
+
}
|
|
40500
42809
|
/** Snapshot every counter into the summary meta. */
|
|
40501
42810
|
function sessionSummaryMeta(session) {
|
|
40502
42811
|
return summariseSession({
|
|
@@ -40507,6 +42816,9 @@ function sessionSummaryMeta(session) {
|
|
|
40507
42816
|
selectedSlot: session.selectedSlot,
|
|
40508
42817
|
videoPacketsForwarded: session.videoPacketsForwarded,
|
|
40509
42818
|
audioPacketsForwarded: session.audioPacketsForwarded,
|
|
42819
|
+
videoKeyframes: session.videoKeyframes,
|
|
42820
|
+
firstKeyframeAtMs: session.firstKeyframeAtMs,
|
|
42821
|
+
maxKeyframeGapMs: session.maxKeyframeGapMs,
|
|
40510
42822
|
videoRtcpSrSent: session.videoRtcpSrSent,
|
|
40511
42823
|
audioRtcpSrSent: session.audioRtcpSrSent,
|
|
40512
42824
|
videoRtcpReceived: session.videoRtcpReceived,
|
|
@@ -40519,7 +42831,19 @@ function sessionSummaryMeta(session) {
|
|
|
40519
42831
|
audioReceiverReports: session.audioReceiverReports,
|
|
40520
42832
|
drops: session.drops,
|
|
40521
42833
|
ffmpegExit: session.ffmpegExit,
|
|
40522
|
-
stopRequestedByController: session.stopRequestedByController
|
|
42834
|
+
stopRequestedByController: session.stopRequestedByController,
|
|
42835
|
+
videoLoopback: {
|
|
42836
|
+
rcvbufRequestedBytes: session.videoLoopRcvbuf.requestedBytes,
|
|
42837
|
+
rcvbufEffectiveBytes: session.videoLoopRcvbuf.effectiveBytes,
|
|
42838
|
+
rcvbufClamped: session.videoLoopRcvbuf.clamped,
|
|
42839
|
+
kernelDrops: session.videoLoopKernelDrops
|
|
42840
|
+
},
|
|
42841
|
+
audioLoopback: {
|
|
42842
|
+
rcvbufRequestedBytes: session.audioLoopRcvbuf.requestedBytes,
|
|
42843
|
+
rcvbufEffectiveBytes: session.audioLoopRcvbuf.effectiveBytes,
|
|
42844
|
+
rcvbufClamped: session.audioLoopRcvbuf.clamped,
|
|
42845
|
+
kernelDrops: session.audioLoopKernelDrops
|
|
42846
|
+
}
|
|
40523
42847
|
});
|
|
40524
42848
|
}
|
|
40525
42849
|
/**
|
|
@@ -40533,6 +42857,7 @@ function armHeartbeat(session, log) {
|
|
|
40533
42857
|
const timer = setInterval(() => {
|
|
40534
42858
|
const forwarded = session.videoPacketsForwarded - lastVideo;
|
|
40535
42859
|
lastVideo = session.videoPacketsForwarded;
|
|
42860
|
+
sampleLoopbackDrops(session);
|
|
40536
42861
|
log.info("export-hap: stream heartbeat", { meta: {
|
|
40537
42862
|
...sessionSummaryMeta(session),
|
|
40538
42863
|
videoPacketsSinceLastBeat: forwarded,
|
|
@@ -40571,6 +42896,7 @@ function stopHeartbeat(session) {
|
|
|
40571
42896
|
*/
|
|
40572
42897
|
function logSessionSummary(session, log, trigger) {
|
|
40573
42898
|
session.endedAtMs = Date.now();
|
|
42899
|
+
sampleLoopbackDrops(session);
|
|
40574
42900
|
log.info("export-hap: stream session summary", { meta: {
|
|
40575
42901
|
...sessionSummaryMeta(session),
|
|
40576
42902
|
trigger
|
|
@@ -40755,6 +43081,7 @@ async function handleStreamRequest(request, sessions, bctx, advertised) {
|
|
|
40755
43081
|
const hadFfmpeg = session.ffmpeg !== null;
|
|
40756
43082
|
killFfmpeg(session, bctx.ctx, bctx.numericDeviceId);
|
|
40757
43083
|
stopHeartbeat(session);
|
|
43084
|
+
sampleLoopbackDrops(session);
|
|
40758
43085
|
if (!hadFfmpeg) logSessionSummary(session, log, "controller-stop-no-ffmpeg");
|
|
40759
43086
|
await closeIntercomTalkSession(session, bctx).catch(() => void 0);
|
|
40760
43087
|
closeSocket(session.videoUdp);
|
|
@@ -40864,7 +43191,7 @@ async function handleStreamRequest(request, sessions, bctx, advertised) {
|
|
|
40864
43191
|
async function startFfmpegForSession(bctx, session, sessionId, video, advertised) {
|
|
40865
43192
|
const { ctx, proxy, numericDeviceId, options } = bctx;
|
|
40866
43193
|
const startLog = ctx.logger.withTags({ deviceId: numericDeviceId });
|
|
40867
|
-
const entries = await proxy.cameraStreams?.getProfileRtspEntries({}) ?? [];
|
|
43194
|
+
const [entries, brokerStreams] = await Promise.all([(async () => await proxy.cameraStreams?.getProfileRtspEntries({}) ?? [])(), (async () => await proxy.cameraStreams?.getBrokerStreams({}) ?? [])()]);
|
|
40868
43195
|
if (entries.length === 0) {
|
|
40869
43196
|
startLog.warn("export-hap: stream start DROPPED — device publishes no profile RTSP entries", { meta: {
|
|
40870
43197
|
sessionId,
|
|
@@ -40873,16 +43200,21 @@ async function startFfmpegForSession(bctx, session, sessionId, video, advertised
|
|
|
40873
43200
|
throw new Error(`export-hap: no profile RTSP entries for device ${numericDeviceId}`);
|
|
40874
43201
|
}
|
|
40875
43202
|
const pref = options.hapDeviceSettings.streamPreference;
|
|
40876
|
-
const brokerStreams = await proxy.cameraStreams?.getBrokerStreams({}) ?? [];
|
|
40877
43203
|
const bitrates = await probeProfileBitrates({
|
|
40878
43204
|
bctx,
|
|
40879
43205
|
slots: brokerStreams,
|
|
40880
43206
|
log: startLog
|
|
40881
43207
|
});
|
|
43208
|
+
const connection = classifyConnection({
|
|
43209
|
+
negotiatedWidth: video.width,
|
|
43210
|
+
audioPacketTimeMs: session.negotiated?.audioPacketTimeMs ?? 20,
|
|
43211
|
+
viaHomeHub: false
|
|
43212
|
+
});
|
|
40882
43213
|
const fit = selectStreamForBudget({
|
|
40883
43214
|
entries: withSlotCodecs(entries, brokerStreams),
|
|
40884
43215
|
deviceId: numericDeviceId,
|
|
40885
43216
|
pref,
|
|
43217
|
+
connection,
|
|
40886
43218
|
targetResolution: {
|
|
40887
43219
|
width: video.width,
|
|
40888
43220
|
height: video.height
|
|
@@ -40908,7 +43240,7 @@ async function startFfmpegForSession(bctx, session, sessionId, video, advertised
|
|
|
40908
43240
|
const resolvedFps = pickedProfile === null ? void 0 : advertised.fpsByProfile.get(pickedProfile);
|
|
40909
43241
|
const advertisedFps = resolvedFps?.fps ?? video.fps;
|
|
40910
43242
|
const advertisedFpsSource = resolvedFps?.source ?? "assumed";
|
|
40911
|
-
const deliveredFps = needsTranscode ?
|
|
43243
|
+
const deliveredFps = needsTranscode ? video.fps : advertisedFps;
|
|
40912
43244
|
const slotEvidence = pickedProfile === null ? void 0 : bitrates.get(pickedProfile);
|
|
40913
43245
|
const fitNotes = formatFitNotes(fit.notes);
|
|
40914
43246
|
session.selectedSlot = {
|
|
@@ -40953,7 +43285,7 @@ async function startFfmpegForSession(bctx, session, sessionId, video, advertised
|
|
|
40953
43285
|
const audioLoopPort = session.audioLoopUdp.address().port;
|
|
40954
43286
|
const videoTarget = `rtp://127.0.0.1:${videoLoopPort}?pkt_size=${video.mtu}`;
|
|
40955
43287
|
const audioTarget = `rtp://127.0.0.1:${audioLoopPort}?pkt_size=${video.mtu}`;
|
|
40956
|
-
const
|
|
43288
|
+
const videoPlan = buildVideoPlan({
|
|
40957
43289
|
transcode: needsTranscode,
|
|
40958
43290
|
width: video.width,
|
|
40959
43291
|
height: video.height,
|
|
@@ -40962,19 +43294,21 @@ async function startFfmpegForSession(bctx, session, sessionId, video, advertised
|
|
|
40962
43294
|
});
|
|
40963
43295
|
const hwDecode = selectHwDecode({
|
|
40964
43296
|
transcode: needsTranscode,
|
|
40965
|
-
reading: needsTranscode ? await probeDecoderHwaccel({
|
|
40966
|
-
ctx,
|
|
40967
|
-
log: startLog
|
|
40968
|
-
|
|
40969
|
-
|
|
43297
|
+
reading: needsTranscode ? await withDeadline(probeDecoderHwaccel({
|
|
43298
|
+
source: decoderInfoSourceFromContext(ctx),
|
|
43299
|
+
log: startLog,
|
|
43300
|
+
memo: options.decodeMemos.reading
|
|
43301
|
+
}), 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,
|
|
43302
|
+
platform: process.platform,
|
|
43303
|
+
recentlyFailedBackend: options.decodeMemos.failedBackend.read() ?? null
|
|
40970
43304
|
});
|
|
40971
43305
|
logDecodePath(startLog, sessionId, hwDecode, needsTranscode);
|
|
40972
43306
|
const videoSsrcSigned = session.videoSsrc | 0;
|
|
40973
43307
|
const audioSsrcSigned = video.audio_ssrc | 0;
|
|
40974
|
-
const buildArgs = (
|
|
40975
|
-
|
|
43308
|
+
const buildArgs = (decode) => buildFfmpegArgs(buildSessionInvocation({
|
|
43309
|
+
decode,
|
|
40976
43310
|
rtspUrl,
|
|
40977
|
-
|
|
43311
|
+
video: videoPlan,
|
|
40978
43312
|
videoTarget,
|
|
40979
43313
|
audioTarget,
|
|
40980
43314
|
videoPayloadType: video.pt,
|
|
@@ -40983,13 +43317,13 @@ async function startFfmpegForSession(bctx, session, sessionId, video, advertised
|
|
|
40983
43317
|
audioSsrcSigned,
|
|
40984
43318
|
audioPacketTimeMs: video.packet_time ?? 20,
|
|
40985
43319
|
audioSampleRateKhz: video.sample_rate ?? 16
|
|
40986
|
-
});
|
|
43320
|
+
}));
|
|
40987
43321
|
const log = ctx.logger.withTags({ deviceId: numericDeviceId });
|
|
40988
43322
|
let hardwareAlreadyFailed = false;
|
|
40989
43323
|
const spawnFfmpeg = (decision) => {
|
|
40990
43324
|
const spawnedAtMs = Date.now();
|
|
40991
43325
|
const usedHardware = decision.kind === "hardware";
|
|
40992
|
-
const proc = (0, node_child_process.spawn)("ffmpeg", buildArgs(decision
|
|
43326
|
+
const proc = (0, node_child_process.spawn)("ffmpeg", buildArgs(decision), { stdio: [
|
|
40993
43327
|
"ignore",
|
|
40994
43328
|
"ignore",
|
|
40995
43329
|
"pipe"
|
|
@@ -41012,6 +43346,7 @@ async function startFfmpegForSession(bctx, session, sessionId, video, advertised
|
|
|
41012
43346
|
runtimeMs: Date.now() - spawnedAtMs
|
|
41013
43347
|
})) {
|
|
41014
43348
|
hardwareAlreadyFailed = true;
|
|
43349
|
+
if (decision.kind === "hardware") options.decodeMemos.failedBackend.write(decision.backend);
|
|
41015
43350
|
log.warn("export-hap: hardware decode FAILED at init — respawning ffmpeg in software", { meta: {
|
|
41016
43351
|
sessionId,
|
|
41017
43352
|
backend: decision.kind === "hardware" ? decision.backend : null,
|
|
@@ -41021,11 +43356,7 @@ async function startFfmpegForSession(bctx, session, sessionId, video, advertised
|
|
|
41021
43356
|
runtimeMs: Date.now() - spawnedAtMs
|
|
41022
43357
|
} });
|
|
41023
43358
|
if (session.ffmpeg === proc) session.ffmpeg = null;
|
|
41024
|
-
spawnFfmpeg(
|
|
41025
|
-
kind: "software",
|
|
41026
|
-
reason: "hardware-attempt-failed",
|
|
41027
|
-
args: []
|
|
41028
|
-
});
|
|
43359
|
+
spawnFfmpeg(software("hardware-attempt-failed"));
|
|
41029
43360
|
return;
|
|
41030
43361
|
}
|
|
41031
43362
|
onFfmpegExit(proc, code, signal);
|
|
@@ -41070,7 +43401,7 @@ async function startFfmpegForSession(bctx, session, sessionId, video, advertised
|
|
|
41070
43401
|
fitReason: fit.reason,
|
|
41071
43402
|
encodeBudgetKbps: fit.budgetKbps,
|
|
41072
43403
|
audioCodec: "opus",
|
|
41073
|
-
audioBitrateKbps:
|
|
43404
|
+
audioBitrateKbps: OPUS_BITRATE_KBPS,
|
|
41074
43405
|
videoDecode: hwDecode.kind === "hardware" ? hwDecode.backend : "software"
|
|
41075
43406
|
} });
|
|
41076
43407
|
}
|
|
@@ -41275,16 +43606,87 @@ function errMsg$8(err) {
|
|
|
41275
43606
|
return err instanceof Error ? err.message : String(err);
|
|
41276
43607
|
}
|
|
41277
43608
|
//#endregion
|
|
43609
|
+
//#region src/mappers/builders/doorbell-delivery.ts
|
|
43610
|
+
function isRecord(value) {
|
|
43611
|
+
return typeof value === "object" && value !== null;
|
|
43612
|
+
}
|
|
43613
|
+
function numberOrNull(value) {
|
|
43614
|
+
return typeof value === "number" ? value : null;
|
|
43615
|
+
}
|
|
43616
|
+
function isConnectionLike(value) {
|
|
43617
|
+
return isRecord(value) && typeof value["hasEventNotifications"] === "function";
|
|
43618
|
+
}
|
|
43619
|
+
function isIterable(value) {
|
|
43620
|
+
return isRecord(value) && typeof value[Symbol.iterator] === "function";
|
|
43621
|
+
}
|
|
43622
|
+
/** `accessory._server.httpServer.connections`, or null at any missing hop. */
|
|
43623
|
+
function readConnections(accessory) {
|
|
43624
|
+
if (!isRecord(accessory)) return null;
|
|
43625
|
+
const server = accessory["_server"];
|
|
43626
|
+
if (!isRecord(server)) return null;
|
|
43627
|
+
const httpServer = server["httpServer"];
|
|
43628
|
+
if (!isRecord(httpServer)) return null;
|
|
43629
|
+
const connections = httpServer["connections"];
|
|
43630
|
+
return isIterable(connections) ? connections : null;
|
|
43631
|
+
}
|
|
43632
|
+
/**
|
|
43633
|
+
* Probe how far a ring on `characteristic` of `accessory` can travel RIGHT NOW.
|
|
43634
|
+
* Pure with respect to HAP state — it only reads. Never throws.
|
|
43635
|
+
*/
|
|
43636
|
+
function describeDoorbellDelivery(accessory, characteristic) {
|
|
43637
|
+
const aid = isRecord(accessory) ? numberOrNull(accessory["aid"]) : null;
|
|
43638
|
+
const iid = isRecord(characteristic) ? numberOrNull(characteristic["iid"]) : null;
|
|
43639
|
+
const serverPublished = isRecord(accessory) && isRecord(accessory["_server"]);
|
|
43640
|
+
const connections = readConnections(accessory);
|
|
43641
|
+
if (connections === null) return {
|
|
43642
|
+
aid,
|
|
43643
|
+
iid,
|
|
43644
|
+
serverPublished,
|
|
43645
|
+
connectionCount: 0,
|
|
43646
|
+
subscriberCount: 0
|
|
43647
|
+
};
|
|
43648
|
+
let connectionCount = 0;
|
|
43649
|
+
let subscriberCount = 0;
|
|
43650
|
+
for (const connection of connections) {
|
|
43651
|
+
connectionCount += 1;
|
|
43652
|
+
if (aid === null || iid === null) continue;
|
|
43653
|
+
if (isConnectionLike(connection) && connection.hasEventNotifications(aid, iid)) subscriberCount += 1;
|
|
43654
|
+
}
|
|
43655
|
+
return {
|
|
43656
|
+
aid,
|
|
43657
|
+
iid,
|
|
43658
|
+
serverPublished,
|
|
43659
|
+
connectionCount,
|
|
43660
|
+
subscriberCount
|
|
43661
|
+
};
|
|
43662
|
+
}
|
|
43663
|
+
/**
|
|
43664
|
+
* True when the ring provably reached nobody: no connection is subscribed to
|
|
43665
|
+
* the characteristic, so hap-nodejs dropped every event frame silently. The
|
|
43666
|
+
* caller must say so out loud — this is a branch that discards work.
|
|
43667
|
+
*/
|
|
43668
|
+
function ringReachedNobody(report) {
|
|
43669
|
+
return report.subscriberCount === 0;
|
|
43670
|
+
}
|
|
43671
|
+
//#endregion
|
|
41278
43672
|
//#region src/mappers/builders/doorbell.ts
|
|
41279
43673
|
async function buildDoorbell(input) {
|
|
41280
43674
|
const { bctx, controller } = input;
|
|
41281
43675
|
const { ctx, numericDeviceId } = bctx;
|
|
43676
|
+
const log = ctx.logger.withTags({ deviceId: numericDeviceId });
|
|
43677
|
+
log.info("export-hap: doorbell forward armed — HomeKit will ring on doorbell.onPressed");
|
|
41282
43678
|
const unsubscribe = ctx.eventBus.subscribe({ category: EventCategory.DoorbellOnPressed }, (event) => {
|
|
41283
|
-
if (event.data
|
|
43679
|
+
if (event.data?.deviceId !== numericDeviceId) return;
|
|
41284
43680
|
try {
|
|
43681
|
+
const delivery = describeDoorbellDelivery(bctx.accessory, bctx.accessory.getService(_homebridge_hap_nodejs.Service.Doorbell)?.getCharacteristic(_homebridge_hap_nodejs.Characteristic.ProgrammableSwitchEvent) ?? null);
|
|
41285
43682
|
controller.ringDoorbell();
|
|
43683
|
+
if (ringReachedNobody(delivery)) {
|
|
43684
|
+
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 } });
|
|
43685
|
+
return;
|
|
43686
|
+
}
|
|
43687
|
+
log.info("export-hap: doorbell SINGLE_PRESS pushed to HomeKit", { meta: { ...delivery } });
|
|
41286
43688
|
} catch (err) {
|
|
41287
|
-
|
|
43689
|
+
log.warn("export-hap: ringDoorbell() failed", { meta: { error: errMsg$7(err) } });
|
|
41288
43690
|
}
|
|
41289
43691
|
});
|
|
41290
43692
|
return { async dispose() {
|
|
@@ -41320,9 +43722,18 @@ async function buildIntercom(input) {
|
|
|
41320
43722
|
* (`proxy.motion.isDetected({})`) when the motion cap is bound.
|
|
41321
43723
|
*/
|
|
41322
43724
|
var RESET_DEBOUNCE_MS = 5e3;
|
|
41323
|
-
|
|
43725
|
+
/**
|
|
43726
|
+
* @param existing - The controller's OWN `MotionSensor`, when HomeKit Secure
|
|
43727
|
+
* Video is advertised. HKSV derives its `EventTriggerOption.MOTION` from the
|
|
43728
|
+
* service the `CameraController` created (`sensors: { motion: true }`) and is
|
|
43729
|
+
* blind to any other one — a second MotionSensor added here would keep working
|
|
43730
|
+
* as a sensor in the Home app while silently triggering no recording at all.
|
|
43731
|
+
* `null` when recording is off, in which case this builder owns the service as
|
|
43732
|
+
* it always has.
|
|
43733
|
+
*/
|
|
43734
|
+
async function buildMotionSensor(bctx, existing = null) {
|
|
41324
43735
|
const { ctx, accessory, proxy, numericDeviceId, displayName } = bctx;
|
|
41325
|
-
const motionService = accessory.addService(_homebridge_hap_nodejs.Service.MotionSensor, hapServiceName([displayName], `Camera ${numericDeviceId}`));
|
|
43736
|
+
const motionService = existing ?? accessory.addService(_homebridge_hap_nodejs.Service.MotionSensor, hapServiceName([displayName], `Camera ${numericDeviceId}`));
|
|
41326
43737
|
motionService.setCharacteristic(_homebridge_hap_nodejs.Characteristic.MotionDetected, false);
|
|
41327
43738
|
try {
|
|
41328
43739
|
const detected = await proxy.motion?.isDetected({});
|
|
@@ -41360,6 +43771,78 @@ function errMsg$6(err) {
|
|
|
41360
43771
|
return err instanceof Error ? err.message : String(err);
|
|
41361
43772
|
}
|
|
41362
43773
|
//#endregion
|
|
43774
|
+
//#region src/mappers/builders/service-label.ts
|
|
43775
|
+
/**
|
|
43776
|
+
* The ONE place a secondary service on the camera accessory gets its label.
|
|
43777
|
+
*
|
|
43778
|
+
* A "secondary service" here is a Switch or Lightbulb published alongside the
|
|
43779
|
+
* camera on the same accessory — the privacy switch, each accessory child
|
|
43780
|
+
* (siren, floodlight), each PTZ action. iOS Home renders these as their own
|
|
43781
|
+
* controls, and the operator has seen them as "Interruttore 1", "Interruttore
|
|
43782
|
+
* 2" through three separate rounds of fixes.
|
|
43783
|
+
*
|
|
43784
|
+
* ## Why `Name` alone cannot rename anything
|
|
43785
|
+
*
|
|
43786
|
+
* Two facts about hap-nodejs 2.1.7, both measured against the installed copy
|
|
43787
|
+
* rather than reasoned about:
|
|
43788
|
+
*
|
|
43789
|
+
* 1. `accessory.addService(Type, displayName, subtype)` ALREADY writes
|
|
43790
|
+
* `displayName` to `Characteristic.Name` (`Service` constructor). So every
|
|
43791
|
+
* round of this bug — including the one that moved the label onto
|
|
43792
|
+
* `ConfiguredName` — shipped with `Name` correctly set. "iOS had no name
|
|
43793
|
+
* to render" was never true.
|
|
43794
|
+
* 2. The mDNS configuration number (`c#`) is a sha1 over
|
|
43795
|
+
* `internalHAPRepresentation(false)`, which OMITS characteristic VALUES.
|
|
43796
|
+
* Changing the string in `Name` therefore does not bump `c#`, a paired
|
|
43797
|
+
* controller gets no signal to re-read `/accessories`, and the name it
|
|
43798
|
+
* cached at first enumeration stands forever.
|
|
43799
|
+
*
|
|
43800
|
+
* `Name` is also declared `pr` only — paired read, no write, no notify. It is
|
|
43801
|
+
* the seed a controller seeds its database from once; it is not a channel.
|
|
43802
|
+
*
|
|
43803
|
+
* ## Why `ConfiguredName`
|
|
43804
|
+
*
|
|
43805
|
+
* `ConfiguredName` (`000000E3`) is declared `pr | pw | ev` — the only name
|
|
43806
|
+
* characteristic a controller may write and may subscribe to. It is what iOS
|
|
43807
|
+
* 16+ reads for a service the user can rename, and adding it CHANGES the
|
|
43808
|
+
* accessory structure, so `c#` does bump and the controller re-reads.
|
|
43809
|
+
*
|
|
43810
|
+
* It was removed once because hap-nodejs logged
|
|
43811
|
+
*
|
|
43812
|
+
* ```
|
|
43813
|
+
* Characteristic not in required or optional characteristic section for
|
|
43814
|
+
* service Switch. Adding anyway.
|
|
43815
|
+
* ```
|
|
43816
|
+
*
|
|
43817
|
+
* That line is a WARNING, not a rejection: `Service.getCharacteristic` calls
|
|
43818
|
+
* `addCharacteristic` unconditionally and only then emits the warning. The
|
|
43819
|
+
* characteristic was always present and always published. hap-nodejs'
|
|
43820
|
+
* per-service optional lists simply predate `ConfiguredName` being valid on
|
|
43821
|
+
* any service.
|
|
43822
|
+
*
|
|
43823
|
+
* Registering it with {@link Service.addOptionalCharacteristic} first takes
|
|
43824
|
+
* the branch above the warning, so the accessory still builds with ZERO
|
|
43825
|
+
* characteristic warnings — which is what `service-naming.spec.ts` asserts.
|
|
43826
|
+
*
|
|
43827
|
+
* ## Scope
|
|
43828
|
+
*
|
|
43829
|
+
* Switch- and Lightbulb-shaped services only. `Service.MotionSensor` on a
|
|
43830
|
+
* camera accessory is not a separately named tile in iOS Home, so giving it a
|
|
43831
|
+
* writable name would be a guess, and this module does not guess.
|
|
43832
|
+
*/
|
|
43833
|
+
/**
|
|
43834
|
+
* Publish `name` as both the immutable `Name` and the controller-visible
|
|
43835
|
+
* `ConfiguredName` of `service`.
|
|
43836
|
+
*
|
|
43837
|
+
* `name` must already be HAP-valid — build it with `service-names.ts`, which
|
|
43838
|
+
* cannot return a string hap-nodejs' `checkName` would warn about.
|
|
43839
|
+
*/
|
|
43840
|
+
function applyServiceLabel(service, name) {
|
|
43841
|
+
service.setCharacteristic(_homebridge_hap_nodejs.Characteristic.Name, name);
|
|
43842
|
+
if (!service.optionalCharacteristics.some((characteristic) => characteristic.UUID === _homebridge_hap_nodejs.Characteristic.ConfiguredName.UUID)) service.addOptionalCharacteristic(_homebridge_hap_nodejs.Characteristic.ConfiguredName);
|
|
43843
|
+
service.setCharacteristic(_homebridge_hap_nodejs.Characteristic.ConfiguredName, name);
|
|
43844
|
+
}
|
|
43845
|
+
//#endregion
|
|
41363
43846
|
//#region src/mappers/builders/privacy-switch.ts
|
|
41364
43847
|
/**
|
|
41365
43848
|
* Privacy-mask switch builder — turns the camstack `privacy-mask` cap's
|
|
@@ -41377,12 +43860,12 @@ function errMsg$6(err) {
|
|
|
41377
43860
|
* camera-enabled switch — distinct from privacy-mask).
|
|
41378
43861
|
*/
|
|
41379
43862
|
async function buildPrivacySwitch(bctx) {
|
|
41380
|
-
const { ctx, accessory, proxy, numericDeviceId
|
|
43863
|
+
const { ctx, accessory, proxy, numericDeviceId } = bctx;
|
|
41381
43864
|
const log = ctx.logger.withTags({ deviceId: numericDeviceId });
|
|
41382
43865
|
const subtype = "privacy-mask";
|
|
41383
|
-
const serviceName = privacyServiceName(
|
|
43866
|
+
const serviceName = privacyServiceName();
|
|
41384
43867
|
const service = accessory.addService(_homebridge_hap_nodejs.Service.Switch, serviceName, subtype);
|
|
41385
|
-
service
|
|
43868
|
+
applyServiceLabel(service, serviceName);
|
|
41386
43869
|
try {
|
|
41387
43870
|
const status = await proxy.privacyMask?.getStatus({});
|
|
41388
43871
|
if (status && typeof status.enabled === "boolean") service.updateCharacteristic(_homebridge_hap_nodejs.Characteristic.On, status.enabled);
|
|
@@ -41472,23 +43955,17 @@ function ptzPresetLabel(presetName) {
|
|
|
41472
43955
|
* `proxy.ptzAutotrack.setEnabled({enabled})`. Initial value is
|
|
41473
43956
|
* hydrated from `getStatus({})`.
|
|
41474
43957
|
*
|
|
41475
|
-
* Naming:
|
|
41476
|
-
* by `ptzServiceName` and
|
|
41477
|
-
*
|
|
41478
|
-
*
|
|
41479
|
-
*
|
|
41480
|
-
*
|
|
41481
|
-
*
|
|
41482
|
-
* discarded the name and showed "Interruttore N". A previous round dropped
|
|
41483
|
-
* the camera prefix along with the em-dash; only the em-dash was the fault.
|
|
41484
|
-
* - The bare label that replaced it was then written to `ConfiguredName`,
|
|
41485
|
-
* which `Service.Switch` does not list, so hap-nodejs rejected the
|
|
41486
|
-
* characteristic outright — SIX rejections per PTZ camera per build, never
|
|
41487
|
-
* reported because only the two switches on the non-PTZ camera were noticed.
|
|
43958
|
+
* Naming: the bare action — "Preset stanza", "Pan Left", "Autotrack" — built
|
|
43959
|
+
* by `ptzServiceName` and published through `applyServiceLabel`, which writes
|
|
43960
|
+
* it to BOTH `Name` and `ConfiguredName`. The camera name is deliberately not
|
|
43961
|
+
* prefixed — these eight services live on that camera's accessory and iOS
|
|
43962
|
+
* shows them there. THREE rounds of this bug have been through this file;
|
|
43963
|
+
* `service-label.ts` records what each got wrong, and why only the writable
|
|
43964
|
+
* characteristic can rename a service after pairing.
|
|
41488
43965
|
*/
|
|
41489
43966
|
var MOMENTARY_RESET_MS = 1e3;
|
|
41490
43967
|
async function buildPtz(bctx) {
|
|
41491
|
-
const { ctx, accessory, proxy, numericDeviceId,
|
|
43968
|
+
const { ctx, accessory, proxy, numericDeviceId, options } = bctx;
|
|
41492
43969
|
const log = ctx.logger.withTags({ deviceId: numericDeviceId });
|
|
41493
43970
|
const timers = /* @__PURE__ */ new Set();
|
|
41494
43971
|
const armReset = (cb, delay) => {
|
|
@@ -41500,10 +43977,10 @@ async function buildPtz(bctx) {
|
|
|
41500
43977
|
};
|
|
41501
43978
|
const presets = await readPresets(bctx);
|
|
41502
43979
|
for (const preset of presets) {
|
|
41503
|
-
const label = ptzServiceName(
|
|
43980
|
+
const label = ptzServiceName(ptzPresetLabel(preset.name));
|
|
41504
43981
|
const subtype = `ptz-preset-${preset.id}`;
|
|
41505
43982
|
const service = accessory.addService(_homebridge_hap_nodejs.Service.Switch, label, subtype);
|
|
41506
|
-
service
|
|
43983
|
+
applyServiceLabel(service, label);
|
|
41507
43984
|
service.getCharacteristic(_homebridge_hap_nodejs.Characteristic.On).onSet(async (value) => {
|
|
41508
43985
|
if (value !== true) return;
|
|
41509
43986
|
try {
|
|
@@ -41518,9 +43995,9 @@ async function buildPtz(bctx) {
|
|
|
41518
43995
|
});
|
|
41519
43996
|
}
|
|
41520
43997
|
if (proxy.ptz) for (const dir of PTZ_DIRECTIONS) {
|
|
41521
|
-
const label = ptzServiceName(
|
|
43998
|
+
const label = ptzServiceName(dir.label);
|
|
41522
43999
|
const service = accessory.addService(_homebridge_hap_nodejs.Service.Switch, label, dir.subtype);
|
|
41523
|
-
service
|
|
44000
|
+
applyServiceLabel(service, label);
|
|
41524
44001
|
service.getCharacteristic(_homebridge_hap_nodejs.Characteristic.On).onSet(async (value) => {
|
|
41525
44002
|
if (value !== true) return;
|
|
41526
44003
|
try {
|
|
@@ -41564,12 +44041,12 @@ async function readPresets(bctx) {
|
|
|
41564
44041
|
}
|
|
41565
44042
|
}
|
|
41566
44043
|
async function tryBuildAutotrack(bctx) {
|
|
41567
|
-
const { ctx, accessory, proxy, numericDeviceId
|
|
44044
|
+
const { ctx, accessory, proxy, numericDeviceId } = bctx;
|
|
41568
44045
|
if (!proxy.ptzAutotrack) return { async dispose() {} };
|
|
41569
44046
|
const log = ctx.logger.withTags({ deviceId: numericDeviceId });
|
|
41570
|
-
const label = ptzServiceName(
|
|
44047
|
+
const label = ptzServiceName(PTZ_AUTOTRACK_LABEL);
|
|
41571
44048
|
const service = accessory.addService(_homebridge_hap_nodejs.Service.Switch, label, "ptz-autotrack");
|
|
41572
|
-
service
|
|
44049
|
+
applyServiceLabel(service, label);
|
|
41573
44050
|
try {
|
|
41574
44051
|
const status = await proxy.ptzAutotrack.getStatus({});
|
|
41575
44052
|
if (status && typeof status.enabled === "boolean") service.updateCharacteristic(_homebridge_hap_nodejs.Characteristic.On, status.enabled);
|
|
@@ -41652,6 +44129,814 @@ async function probe(call, label, log) {
|
|
|
41652
44129
|
}
|
|
41653
44130
|
}
|
|
41654
44131
|
//#endregion
|
|
44132
|
+
//#region src/hksv/recording-options.ts
|
|
44133
|
+
/**
|
|
44134
|
+
* The HomeKit Secure Video ADVERTISEMENT — `CameraRecordingOptions`, derived
|
|
44135
|
+
* from what the fMP4 sink will actually produce for THIS camera.
|
|
44136
|
+
*
|
|
44137
|
+
* ## The rule this file exists to enforce
|
|
44138
|
+
*
|
|
44139
|
+
* Never advertise something we cannot serve. That is not a slogan here: it is
|
|
44140
|
+
* the diagnosis of [D50](../../../../docs/decisions/adr-0050.md) — an
|
|
44141
|
+
* advertised `recording` whose delegate yielded nothing put every motion-capable
|
|
44142
|
+
* camera into a ~12 s timeout loop every 20-60 s, all day. So every number below
|
|
44143
|
+
* is derived from the picked source (`recording-source.ts`) or from a measured
|
|
44144
|
+
* property of the sink, and none of them is a plausible-looking constant.
|
|
44145
|
+
*
|
|
44146
|
+
* ## The fragment length is the subtle one
|
|
44147
|
+
*
|
|
44148
|
+
* HKSV requires every media fragment to be **no longer** than the length the
|
|
44149
|
+
* controller selected. On the copy branch the fragment length is the SOURCE's
|
|
44150
|
+
* key-frame cadence ([D80](../../../../docs/decisions/adr-0080.md)) — we do not
|
|
44151
|
+
* get to choose it, we can only be honest about it. So:
|
|
44152
|
+
*
|
|
44153
|
+
* - when the camera reports its GOP (`stream-params`), the advertised length is
|
|
44154
|
+
* the smallest offered value that COVERS it;
|
|
44155
|
+
* - when it does not, we advertise the 4000 ms every HKSV camera uses and the
|
|
44156
|
+
* delegate warns at `warn` with `tags: { deviceId }` if the fragments that
|
|
44157
|
+
* actually arrive are longer.
|
|
44158
|
+
*
|
|
44159
|
+
* A camera whose GOP exceeds the longest value we offer does not advertise
|
|
44160
|
+
* recording at all. See {@link deriveFragmentLengthMs}.
|
|
44161
|
+
*/
|
|
44162
|
+
/**
|
|
44163
|
+
* The prebuffer we promise. HAP's floor is 4000 ms and its documented sensible
|
|
44164
|
+
* range is [4000, 8000]; the plane's ring is sized from this, so the two cannot
|
|
44165
|
+
* disagree. Asking for more than we retain would be the same lie in the other
|
|
44166
|
+
* direction.
|
|
44167
|
+
*/
|
|
44168
|
+
var HKSV_PREBUFFER_MS = 4e3;
|
|
44169
|
+
/**
|
|
44170
|
+
* The fragment lengths we are willing to advertise, shortest first. 4000 ms is
|
|
44171
|
+
* what every shipping HKSV camera uses; 8000 exists for a camera whose GOP is
|
|
44172
|
+
* 8 s, which is common enough on this fleet's defaults to be worth covering
|
|
44173
|
+
* rather than refusing.
|
|
44174
|
+
*/
|
|
44175
|
+
var HKSV_FRAGMENT_LENGTHS_MS = [4e3, 8e3];
|
|
44176
|
+
/**
|
|
44177
|
+
* AAC-LC at 24 kHz mono. Fixed rather than negotiated: an fMP4 fragment carries
|
|
44178
|
+
* its audio in-band, so unlike the live SRTP path there is no second plane on
|
|
44179
|
+
* which to answer a different sample rate, and D80 records that HKSV takes AAC
|
|
44180
|
+
* and nothing else.
|
|
44181
|
+
*/
|
|
44182
|
+
var HKSV_AUDIO_SAMPLE_RATE_HZ = 24e3;
|
|
44183
|
+
/**
|
|
44184
|
+
* The advertised fragment length for a camera whose key-frame cadence is
|
|
44185
|
+
* `sourceGopMs`, or `null` when no offered length covers it.
|
|
44186
|
+
*
|
|
44187
|
+
* `undefined` — the camera does not report a GOP — takes the shortest offered
|
|
44188
|
+
* length. That is a guess, and it is the RIGHT guess (4 s is the near-universal
|
|
44189
|
+
* default), but it is a guess: the delegate measures the arriving cadence and
|
|
44190
|
+
* says so when reality disagrees.
|
|
44191
|
+
*/
|
|
44192
|
+
function deriveFragmentLengthMs(sourceGopMs) {
|
|
44193
|
+
const shortest = HKSV_FRAGMENT_LENGTHS_MS[0];
|
|
44194
|
+
if (shortest === void 0) return null;
|
|
44195
|
+
if (sourceGopMs === void 0 || sourceGopMs <= 0) return shortest;
|
|
44196
|
+
return HKSV_FRAGMENT_LENGTHS_MS.find((ms) => ms >= sourceGopMs) ?? null;
|
|
44197
|
+
}
|
|
44198
|
+
/**
|
|
44199
|
+
* Build the advertisement.
|
|
44200
|
+
*
|
|
44201
|
+
* ONE resolution is advertised — the one slot the recording child pulls. HAP's
|
|
44202
|
+
* documentation lists 1920×1080 and 1280×720 as "required to be supported", and
|
|
44203
|
+
* listing both when the source is only one of them is precisely the D50 failure
|
|
44204
|
+
* in miniature: iOS would select a configuration we then cannot deliver, on the
|
|
44205
|
+
* copy branch, with no encoder to resize with.
|
|
44206
|
+
*/
|
|
44207
|
+
function buildRecordingOptions(input) {
|
|
44208
|
+
const resolution = [
|
|
44209
|
+
input.width,
|
|
44210
|
+
input.height,
|
|
44211
|
+
Math.max(1, Math.round(input.fps))
|
|
44212
|
+
];
|
|
44213
|
+
return {
|
|
44214
|
+
prebufferLength: HKSV_PREBUFFER_MS,
|
|
44215
|
+
mediaContainerConfiguration: {
|
|
44216
|
+
type: _homebridge_hap_nodejs.MediaContainerType.FRAGMENTED_MP4,
|
|
44217
|
+
fragmentLength: input.fragmentLengthMs
|
|
44218
|
+
},
|
|
44219
|
+
video: {
|
|
44220
|
+
type: _homebridge_hap_nodejs.VideoCodecType.H264,
|
|
44221
|
+
parameters: {
|
|
44222
|
+
profiles: [
|
|
44223
|
+
_homebridge_hap_nodejs.H264Profile.BASELINE,
|
|
44224
|
+
_homebridge_hap_nodejs.H264Profile.MAIN,
|
|
44225
|
+
_homebridge_hap_nodejs.H264Profile.HIGH
|
|
44226
|
+
],
|
|
44227
|
+
levels: [
|
|
44228
|
+
_homebridge_hap_nodejs.H264Level.LEVEL3_1,
|
|
44229
|
+
_homebridge_hap_nodejs.H264Level.LEVEL3_2,
|
|
44230
|
+
_homebridge_hap_nodejs.H264Level.LEVEL4_0
|
|
44231
|
+
]
|
|
44232
|
+
},
|
|
44233
|
+
resolutions: [resolution]
|
|
44234
|
+
},
|
|
44235
|
+
audio: { codecs: [{
|
|
44236
|
+
type: _homebridge_hap_nodejs.AudioRecordingCodecType.AAC_LC,
|
|
44237
|
+
audioChannels: 1,
|
|
44238
|
+
bitrateMode: _homebridge_hap_nodejs.AudioBitrate.VARIABLE,
|
|
44239
|
+
samplerate: [_homebridge_hap_nodejs.AudioRecordingSamplerate.KHZ_24]
|
|
44240
|
+
}] }
|
|
44241
|
+
};
|
|
44242
|
+
}
|
|
44243
|
+
//#endregion
|
|
44244
|
+
//#region src/hksv/fragment-source.ts
|
|
44245
|
+
/**
|
|
44246
|
+
* How far back the prebuffer ring reaches.
|
|
44247
|
+
*
|
|
44248
|
+
* Twice {@link HKSV_PREBUFFER_MS}, and the factor is structural rather than
|
|
44249
|
+
* generous: the ring holds WHOLE fragments, so a window of exactly 4 s can hold
|
|
44250
|
+
* a single 4 s fragment that is about to age out — a trigger landing a moment
|
|
44251
|
+
* later would replay nothing. Two fragment lengths guarantee at least one
|
|
44252
|
+
* covering fragment at every instant.
|
|
44253
|
+
*/
|
|
44254
|
+
var PREBUFFER_WINDOW_MS = HKSV_PREBUFFER_MS * 2;
|
|
44255
|
+
/**
|
|
44256
|
+
* The ring's hard byte ceiling, per camera.
|
|
44257
|
+
*
|
|
44258
|
+
* Measured fragment sizes on this fleet: ~145 KB for 4 s at 720p, ~3.6 MB for
|
|
44259
|
+
* 4 s at 4K. 16 MB covers two 4K fragments with room and bounds the exporter's
|
|
44260
|
+
* heap at a figure an operator can multiply by the camera count — which is the
|
|
44261
|
+
* number a time-only bound refuses to give.
|
|
44262
|
+
*/
|
|
44263
|
+
var PREBUFFER_MAX_BYTES = 16 * 1024 * 1024;
|
|
44264
|
+
/** Backoff after a child that died while live. Bounded, never a tight loop. */
|
|
44265
|
+
var RESPAWN_BACKOFF_MS = [
|
|
44266
|
+
2e3,
|
|
44267
|
+
5e3,
|
|
44268
|
+
15e3,
|
|
44269
|
+
3e4
|
|
44270
|
+
];
|
|
44271
|
+
var HksvFragmentSource = class {
|
|
44272
|
+
input;
|
|
44273
|
+
plane = null;
|
|
44274
|
+
child = null;
|
|
44275
|
+
stopped = false;
|
|
44276
|
+
starting = null;
|
|
44277
|
+
respawnAttempt = 0;
|
|
44278
|
+
respawnTimer = null;
|
|
44279
|
+
audioActive;
|
|
44280
|
+
log;
|
|
44281
|
+
constructor(input) {
|
|
44282
|
+
this.input = input;
|
|
44283
|
+
this.audioActive = input.audioActive;
|
|
44284
|
+
this.log = input.logger;
|
|
44285
|
+
}
|
|
44286
|
+
/** True once a child has produced its initialisation segment. */
|
|
44287
|
+
get isRunning() {
|
|
44288
|
+
return this.child !== null && this.plane !== null && !this.plane.isEnded;
|
|
44289
|
+
}
|
|
44290
|
+
/**
|
|
44291
|
+
* Spawn the child and start filling the ring. Idempotent, and concurrent
|
|
44292
|
+
* calls share one attempt — `updateRecordingActive(true)` and a stream
|
|
44293
|
+
* request can arrive in either order.
|
|
44294
|
+
*/
|
|
44295
|
+
async start() {
|
|
44296
|
+
if (this.stopped) throw new Error("hksv fragment source: already stopped");
|
|
44297
|
+
if (this.isRunning) return;
|
|
44298
|
+
const inflight = this.starting;
|
|
44299
|
+
if (inflight !== null) return inflight;
|
|
44300
|
+
const attempt = this.spawn();
|
|
44301
|
+
this.starting = attempt;
|
|
44302
|
+
try {
|
|
44303
|
+
await attempt;
|
|
44304
|
+
} finally {
|
|
44305
|
+
this.starting = null;
|
|
44306
|
+
}
|
|
44307
|
+
}
|
|
44308
|
+
/** Stop the child, end the plane, forget the ring. Idempotent. */
|
|
44309
|
+
async stop(reason) {
|
|
44310
|
+
if (this.stopped) return;
|
|
44311
|
+
this.stopped = true;
|
|
44312
|
+
this.clearRespawn();
|
|
44313
|
+
this.log.info("hksv fragment source: stopping", {
|
|
44314
|
+
tags: { deviceId: this.input.deviceId },
|
|
44315
|
+
meta: {
|
|
44316
|
+
reason,
|
|
44317
|
+
brokerId: this.input.source.brokerId
|
|
44318
|
+
}
|
|
44319
|
+
});
|
|
44320
|
+
const child = this.child;
|
|
44321
|
+
this.child = null;
|
|
44322
|
+
if (child) await child.stop();
|
|
44323
|
+
this.plane?.dispose();
|
|
44324
|
+
this.plane = null;
|
|
44325
|
+
}
|
|
44326
|
+
/**
|
|
44327
|
+
* iOS turned recording audio on or off. Respawns onto the other url when it
|
|
44328
|
+
* genuinely changed — the fragments themselves must carry or omit the track,
|
|
44329
|
+
* there is nothing to strip downstream.
|
|
44330
|
+
*/
|
|
44331
|
+
async setAudioActive(active) {
|
|
44332
|
+
if (active === this.audioActive) return;
|
|
44333
|
+
this.audioActive = active;
|
|
44334
|
+
this.log.info("hksv fragment source: RecordingAudioActive changed — respawning the child", {
|
|
44335
|
+
tags: { deviceId: this.input.deviceId },
|
|
44336
|
+
meta: {
|
|
44337
|
+
audioActive: active,
|
|
44338
|
+
brokerId: this.input.source.brokerId
|
|
44339
|
+
}
|
|
44340
|
+
});
|
|
44341
|
+
if (!this.isRunning) return;
|
|
44342
|
+
const child = this.child;
|
|
44343
|
+
this.child = null;
|
|
44344
|
+
if (child) await child.stop();
|
|
44345
|
+
this.plane?.dispose();
|
|
44346
|
+
this.plane = null;
|
|
44347
|
+
await this.start();
|
|
44348
|
+
}
|
|
44349
|
+
/**
|
|
44350
|
+
* Subscribe to the live fragments, replaying the prebuffer first.
|
|
44351
|
+
*
|
|
44352
|
+
* Returns `null` when there is no plane — the caller MUST treat that as
|
|
44353
|
+
* "cannot serve this recording" rather than opening an HDS stream it cannot
|
|
44354
|
+
* feed, which is the D50 failure exactly.
|
|
44355
|
+
*/
|
|
44356
|
+
subscribe(tag) {
|
|
44357
|
+
const plane = this.plane;
|
|
44358
|
+
if (plane === null || plane.isEnded) return null;
|
|
44359
|
+
const stats = plane.prebufferStats();
|
|
44360
|
+
this.log.info("hksv fragment source: subscribing a recording stream", {
|
|
44361
|
+
tags: { deviceId: this.input.deviceId },
|
|
44362
|
+
meta: {
|
|
44363
|
+
tag,
|
|
44364
|
+
prebufferFragments: stats.fragments,
|
|
44365
|
+
prebufferBytes: stats.bytes,
|
|
44366
|
+
prebufferSpanMs: stats.spanMs
|
|
44367
|
+
}
|
|
44368
|
+
});
|
|
44369
|
+
return plane.subscribe({
|
|
44370
|
+
tag,
|
|
44371
|
+
withPrebuffer: true
|
|
44372
|
+
});
|
|
44373
|
+
}
|
|
44374
|
+
/** What the ring holds — surfaced so the delegate can log what it served. */
|
|
44375
|
+
prebufferSpanMs() {
|
|
44376
|
+
return this.plane?.prebufferStats().spanMs ?? 0;
|
|
44377
|
+
}
|
|
44378
|
+
async spawn() {
|
|
44379
|
+
const plane = new Fmp4FragmentPlane(this.log.child("hksv-plane"), {
|
|
44380
|
+
windowMs: PREBUFFER_WINDOW_MS,
|
|
44381
|
+
maxBytes: PREBUFFER_MAX_BYTES
|
|
44382
|
+
}, this.input.now ?? Date.now);
|
|
44383
|
+
const child = new Fmp4FragmentChild({
|
|
44384
|
+
logger: this.log.child("hksv-fmp4"),
|
|
44385
|
+
ffmpegBinaryPath: this.input.ffmpegBinaryPath,
|
|
44386
|
+
spawnFn: this.input.spawnFn,
|
|
44387
|
+
onChildExit: (error) => this.onChildExit(error)
|
|
44388
|
+
}, {
|
|
44389
|
+
sourceId: `hksv/${this.input.deviceId}`,
|
|
44390
|
+
deviceId: this.input.deviceId,
|
|
44391
|
+
fragmentMs: this.input.fragmentMs,
|
|
44392
|
+
invocation: this.buildInvocation(),
|
|
44393
|
+
plane
|
|
44394
|
+
});
|
|
44395
|
+
this.plane = plane;
|
|
44396
|
+
this.child = child;
|
|
44397
|
+
try {
|
|
44398
|
+
await child.start();
|
|
44399
|
+
this.respawnAttempt = 0;
|
|
44400
|
+
this.log.info("hksv fragment source: prebuffer running", {
|
|
44401
|
+
tags: { deviceId: this.input.deviceId },
|
|
44402
|
+
meta: {
|
|
44403
|
+
brokerId: this.input.source.brokerId,
|
|
44404
|
+
resolution: `${this.input.source.width}x${this.input.source.height}`,
|
|
44405
|
+
fragmentMs: this.input.fragmentMs,
|
|
44406
|
+
audioActive: this.audioActive,
|
|
44407
|
+
windowMs: PREBUFFER_WINDOW_MS
|
|
44408
|
+
}
|
|
44409
|
+
});
|
|
44410
|
+
} catch (err) {
|
|
44411
|
+
this.plane = null;
|
|
44412
|
+
this.child = null;
|
|
44413
|
+
plane.dispose();
|
|
44414
|
+
throw err;
|
|
44415
|
+
}
|
|
44416
|
+
}
|
|
44417
|
+
/**
|
|
44418
|
+
* The child died while live. The prebuffer is gone with it — and saying so is
|
|
44419
|
+
* the point: a source that silently stopped filling reads, from the delegate,
|
|
44420
|
+
* exactly like a camera nothing ever happens on.
|
|
44421
|
+
*/
|
|
44422
|
+
onChildExit(error) {
|
|
44423
|
+
if (this.stopped) return;
|
|
44424
|
+
this.child = null;
|
|
44425
|
+
this.plane = null;
|
|
44426
|
+
const delay = RESPAWN_BACKOFF_MS[Math.min(this.respawnAttempt, RESPAWN_BACKOFF_MS.length - 1)];
|
|
44427
|
+
this.respawnAttempt += 1;
|
|
44428
|
+
this.log.warn("hksv fragment source: the child DIED — the prebuffer is empty until it respawns", {
|
|
44429
|
+
tags: { deviceId: this.input.deviceId },
|
|
44430
|
+
meta: {
|
|
44431
|
+
brokerId: this.input.source.brokerId,
|
|
44432
|
+
attempt: this.respawnAttempt,
|
|
44433
|
+
respawnInMs: delay,
|
|
44434
|
+
error: error.message
|
|
44435
|
+
}
|
|
44436
|
+
});
|
|
44437
|
+
this.clearRespawn();
|
|
44438
|
+
const schedule = this.input.setTimeoutFn ?? setTimeout;
|
|
44439
|
+
this.respawnTimer = schedule(() => {
|
|
44440
|
+
this.respawnTimer = null;
|
|
44441
|
+
if (this.stopped) return;
|
|
44442
|
+
this.start().catch((err) => {
|
|
44443
|
+
this.log.warn("hksv fragment source: respawn failed", {
|
|
44444
|
+
tags: { deviceId: this.input.deviceId },
|
|
44445
|
+
meta: {
|
|
44446
|
+
brokerId: this.input.source.brokerId,
|
|
44447
|
+
error: err instanceof Error ? err.message : String(err)
|
|
44448
|
+
}
|
|
44449
|
+
});
|
|
44450
|
+
});
|
|
44451
|
+
}, delay ?? 3e4);
|
|
44452
|
+
this.respawnTimer?.unref?.();
|
|
44453
|
+
}
|
|
44454
|
+
clearRespawn() {
|
|
44455
|
+
if (this.respawnTimer !== null) {
|
|
44456
|
+
clearTimeout(this.respawnTimer);
|
|
44457
|
+
this.respawnTimer = null;
|
|
44458
|
+
}
|
|
44459
|
+
}
|
|
44460
|
+
/**
|
|
44461
|
+
* The invocation, minus the sink the child owns.
|
|
44462
|
+
*
|
|
44463
|
+
* `kind: 'copy'` is not a preference: it is the 128× measurement, and it is
|
|
44464
|
+
* why `pickRecordingSource` refuses a camera whose only slots are H.265. The
|
|
44465
|
+
* audio IS encoded — the source mic is G.711/PCM depending on vendor and HKSV
|
|
44466
|
+
* takes AAC only — which the same measurement priced at 0.4 % of a core.
|
|
44467
|
+
*/
|
|
44468
|
+
buildInvocation() {
|
|
44469
|
+
const audio = this.audioActive ? {
|
|
44470
|
+
kind: "encode",
|
|
44471
|
+
codec: "aac",
|
|
44472
|
+
bitrateKbps: 32,
|
|
44473
|
+
sampleRateHz: HKSV_AUDIO_SAMPLE_RATE_HZ,
|
|
44474
|
+
channels: 1
|
|
44475
|
+
} : { kind: "none" };
|
|
44476
|
+
return {
|
|
44477
|
+
logLevel: "error",
|
|
44478
|
+
decodeHwAccel: null,
|
|
44479
|
+
input: {
|
|
44480
|
+
url: this.audioActive ? this.input.source.url : this.input.source.mutedUrl,
|
|
44481
|
+
rtspTransport: "tcp",
|
|
44482
|
+
analyzeDurationUs: 1e6,
|
|
44483
|
+
probeSizeBytes: 1e6
|
|
44484
|
+
},
|
|
44485
|
+
video: { kind: "copy" },
|
|
44486
|
+
audio,
|
|
44487
|
+
threadCount: 0,
|
|
44488
|
+
outputArgs: []
|
|
44489
|
+
};
|
|
44490
|
+
}
|
|
44491
|
+
};
|
|
44492
|
+
//#endregion
|
|
44493
|
+
//#region src/hksv/recording-delegate.ts
|
|
44494
|
+
/**
|
|
44495
|
+
* `HDSProtocolSpecificErrorReason` is a `const enum`, so there is no reverse
|
|
44496
|
+
* map to index — and a bare number in the log is the difference between "iOS
|
|
44497
|
+
* closed it normally" and "iOS rejected our data", which is the whole reason
|
|
44498
|
+
* this line exists.
|
|
44499
|
+
*/
|
|
44500
|
+
var HDS_REASON_NAMES = {
|
|
44501
|
+
[_homebridge_hap_nodejs.HDSProtocolSpecificErrorReason.NORMAL]: "normal",
|
|
44502
|
+
[_homebridge_hap_nodejs.HDSProtocolSpecificErrorReason.NOT_ALLOWED]: "not-allowed",
|
|
44503
|
+
[_homebridge_hap_nodejs.HDSProtocolSpecificErrorReason.BUSY]: "busy",
|
|
44504
|
+
[_homebridge_hap_nodejs.HDSProtocolSpecificErrorReason.CANCELLED]: "cancelled",
|
|
44505
|
+
[_homebridge_hap_nodejs.HDSProtocolSpecificErrorReason.UNSUPPORTED]: "unsupported",
|
|
44506
|
+
[_homebridge_hap_nodejs.HDSProtocolSpecificErrorReason.UNEXPECTED_FAILURE]: "unexpected-failure",
|
|
44507
|
+
[_homebridge_hap_nodejs.HDSProtocolSpecificErrorReason.TIMEOUT]: "timeout",
|
|
44508
|
+
[_homebridge_hap_nodejs.HDSProtocolSpecificErrorReason.BAD_DATA]: "bad-data",
|
|
44509
|
+
[_homebridge_hap_nodejs.HDSProtocolSpecificErrorReason.PROTOCOL_ERROR]: "protocol-error",
|
|
44510
|
+
[_homebridge_hap_nodejs.HDSProtocolSpecificErrorReason.INVALID_CONFIGURATION]: "invalid-configuration"
|
|
44511
|
+
};
|
|
44512
|
+
function hdsReasonName(reason) {
|
|
44513
|
+
return HDS_REASON_NAMES[reason] ?? `unknown(${String(reason)})`;
|
|
44514
|
+
}
|
|
44515
|
+
var HksvRecordingDelegate = class {
|
|
44516
|
+
input;
|
|
44517
|
+
active = false;
|
|
44518
|
+
configuration = void 0;
|
|
44519
|
+
source = null;
|
|
44520
|
+
log;
|
|
44521
|
+
/** The stream currently being yielded, so `closeRecordingStream` can end it. */
|
|
44522
|
+
open = null;
|
|
44523
|
+
constructor(input) {
|
|
44524
|
+
this.input = input;
|
|
44525
|
+
this.log = input.logger;
|
|
44526
|
+
}
|
|
44527
|
+
/** Test/diagnostic view — the prebuffer is running for this camera. */
|
|
44528
|
+
get prebufferRunning() {
|
|
44529
|
+
return this.source?.isRunning === true;
|
|
44530
|
+
}
|
|
44531
|
+
updateRecordingActive(active) {
|
|
44532
|
+
if (active === this.active) return;
|
|
44533
|
+
this.active = active;
|
|
44534
|
+
this.log.info("hksv: recording active changed", {
|
|
44535
|
+
tags: { deviceId: this.input.deviceId },
|
|
44536
|
+
meta: {
|
|
44537
|
+
active,
|
|
44538
|
+
hasConfiguration: this.configuration !== void 0
|
|
44539
|
+
}
|
|
44540
|
+
});
|
|
44541
|
+
this.reconcile("recording-active");
|
|
44542
|
+
}
|
|
44543
|
+
updateRecordingConfiguration(configuration) {
|
|
44544
|
+
this.configuration = configuration;
|
|
44545
|
+
if (configuration === void 0) {
|
|
44546
|
+
this.log.info("hksv: the selected configuration was CLEARED — stopping the prebuffer", { tags: { deviceId: this.input.deviceId } });
|
|
44547
|
+
this.reconcile("configuration-cleared");
|
|
44548
|
+
return;
|
|
44549
|
+
}
|
|
44550
|
+
const selectedMs = configuration.mediaContainerConfiguration.fragmentLength;
|
|
44551
|
+
this.log.info("hksv: iOS selected a recording configuration", {
|
|
44552
|
+
tags: { deviceId: this.input.deviceId },
|
|
44553
|
+
meta: {
|
|
44554
|
+
fragmentLengthMs: selectedMs,
|
|
44555
|
+
prebufferLengthMs: configuration.prebufferLength,
|
|
44556
|
+
resolution: configuration.videoCodec.resolution.join("x"),
|
|
44557
|
+
audioCodec: configuration.audioCodec.type,
|
|
44558
|
+
eventTriggers: configuration.eventTriggerTypes
|
|
44559
|
+
}
|
|
44560
|
+
});
|
|
44561
|
+
if (selectedMs < this.input.advertisedFragmentMs) this.log.warn("hksv: iOS selected a SHORTER fragment length than the source can cut", {
|
|
44562
|
+
tags: { deviceId: this.input.deviceId },
|
|
44563
|
+
meta: {
|
|
44564
|
+
selectedMs,
|
|
44565
|
+
advertisedMs: this.input.advertisedFragmentMs
|
|
44566
|
+
}
|
|
44567
|
+
});
|
|
44568
|
+
this.reconcile("configuration-selected");
|
|
44569
|
+
}
|
|
44570
|
+
async *handleRecordingStreamRequest(streamId, signal) {
|
|
44571
|
+
const source = this.source;
|
|
44572
|
+
if (source === null || !source.isRunning) {
|
|
44573
|
+
this.log.warn("hksv: recording stream requested with NO prebuffer running — refusing", {
|
|
44574
|
+
tags: { deviceId: this.input.deviceId },
|
|
44575
|
+
meta: {
|
|
44576
|
+
streamId,
|
|
44577
|
+
active: this.active,
|
|
44578
|
+
hasConfiguration: this.configuration !== void 0
|
|
44579
|
+
}
|
|
44580
|
+
});
|
|
44581
|
+
throw new _homebridge_hap_nodejs.HDSProtocolError(_homebridge_hap_nodejs.HDSProtocolSpecificErrorReason.NOT_ALLOWED);
|
|
44582
|
+
}
|
|
44583
|
+
const subscription = source.subscribe(`hksv/${this.input.deviceId}#${streamId}`);
|
|
44584
|
+
if (subscription === null) {
|
|
44585
|
+
this.log.warn("hksv: the fragment plane refused a subscription — refusing the stream", {
|
|
44586
|
+
tags: { deviceId: this.input.deviceId },
|
|
44587
|
+
meta: { streamId }
|
|
44588
|
+
});
|
|
44589
|
+
throw new _homebridge_hap_nodejs.HDSProtocolError(_homebridge_hap_nodejs.HDSProtocolSpecificErrorReason.NOT_ALLOWED);
|
|
44590
|
+
}
|
|
44591
|
+
this.open = {
|
|
44592
|
+
streamId,
|
|
44593
|
+
subscription
|
|
44594
|
+
};
|
|
44595
|
+
const startedAt = Date.now();
|
|
44596
|
+
const prebufferSpanMs = source.prebufferSpanMs();
|
|
44597
|
+
let packets = 0;
|
|
44598
|
+
let bytes = 0;
|
|
44599
|
+
let markedLast = false;
|
|
44600
|
+
let longestFragmentGapMs = 0;
|
|
44601
|
+
let lastPacketAt = startedAt;
|
|
44602
|
+
try {
|
|
44603
|
+
for await (const packet of subscription.packets()) {
|
|
44604
|
+
if (signal?.aborted === true) {
|
|
44605
|
+
this.log.info("hksv: the recording stream was aborted — ending the generator", {
|
|
44606
|
+
tags: { deviceId: this.input.deviceId },
|
|
44607
|
+
meta: {
|
|
44608
|
+
streamId,
|
|
44609
|
+
packets
|
|
44610
|
+
}
|
|
44611
|
+
});
|
|
44612
|
+
return;
|
|
44613
|
+
}
|
|
44614
|
+
packets += 1;
|
|
44615
|
+
bytes += packet.data.length;
|
|
44616
|
+
if (packet.kind === "fragment") {
|
|
44617
|
+
const now = Date.now();
|
|
44618
|
+
longestFragmentGapMs = Math.max(longestFragmentGapMs, now - lastPacketAt);
|
|
44619
|
+
lastPacketAt = now;
|
|
44620
|
+
}
|
|
44621
|
+
markedLast = markedLast || packet.isLast;
|
|
44622
|
+
yield {
|
|
44623
|
+
data: Buffer.from(packet.data),
|
|
44624
|
+
isLast: packet.isLast
|
|
44625
|
+
};
|
|
44626
|
+
if (packet.isLast) return;
|
|
44627
|
+
}
|
|
44628
|
+
if (!markedLast && subscription.closedReason !== "released") {
|
|
44629
|
+
this.log.warn("hksv: the fragment stream ended without a final packet", {
|
|
44630
|
+
tags: { deviceId: this.input.deviceId },
|
|
44631
|
+
meta: {
|
|
44632
|
+
streamId,
|
|
44633
|
+
packets,
|
|
44634
|
+
closedReason: subscription.closedReason,
|
|
44635
|
+
truncated: subscription.closedReason === "slow-consumer"
|
|
44636
|
+
}
|
|
44637
|
+
});
|
|
44638
|
+
if (packets === 0) throw new _homebridge_hap_nodejs.HDSProtocolError(_homebridge_hap_nodejs.HDSProtocolSpecificErrorReason.UNEXPECTED_FAILURE);
|
|
44639
|
+
yield {
|
|
44640
|
+
data: Buffer.alloc(0),
|
|
44641
|
+
isLast: true
|
|
44642
|
+
};
|
|
44643
|
+
}
|
|
44644
|
+
} finally {
|
|
44645
|
+
subscription.release();
|
|
44646
|
+
if (this.open?.streamId === streamId) this.open = null;
|
|
44647
|
+
const fragmentOverrun = longestFragmentGapMs > this.input.advertisedFragmentMs * 1.5;
|
|
44648
|
+
this.log.info("hksv: recording stream finished", {
|
|
44649
|
+
tags: { deviceId: this.input.deviceId },
|
|
44650
|
+
meta: {
|
|
44651
|
+
streamId,
|
|
44652
|
+
packets,
|
|
44653
|
+
bytes,
|
|
44654
|
+
durationMs: Date.now() - startedAt,
|
|
44655
|
+
prebufferSpanMs,
|
|
44656
|
+
longestFragmentGapMs,
|
|
44657
|
+
closedReason: subscription.closedReason,
|
|
44658
|
+
markedLast
|
|
44659
|
+
}
|
|
44660
|
+
});
|
|
44661
|
+
if (fragmentOverrun) this.log.warn("hksv: fragments arrived LONGER than the advertised length", {
|
|
44662
|
+
tags: { deviceId: this.input.deviceId },
|
|
44663
|
+
meta: {
|
|
44664
|
+
longestFragmentGapMs,
|
|
44665
|
+
advertisedMs: this.input.advertisedFragmentMs
|
|
44666
|
+
}
|
|
44667
|
+
});
|
|
44668
|
+
}
|
|
44669
|
+
}
|
|
44670
|
+
acknowledgeStream(streamId) {
|
|
44671
|
+
this.log.info("hksv: iOS acknowledged the end of stream — the clip landed", {
|
|
44672
|
+
tags: { deviceId: this.input.deviceId },
|
|
44673
|
+
meta: { streamId }
|
|
44674
|
+
});
|
|
44675
|
+
}
|
|
44676
|
+
closeRecordingStream(streamId, reason) {
|
|
44677
|
+
this.log.info("hksv: the recording stream was closed by the controller", {
|
|
44678
|
+
tags: { deviceId: this.input.deviceId },
|
|
44679
|
+
meta: {
|
|
44680
|
+
streamId,
|
|
44681
|
+
reason: reason === void 0 ? "connection-closed" : hdsReasonName(reason)
|
|
44682
|
+
}
|
|
44683
|
+
});
|
|
44684
|
+
const open = this.open;
|
|
44685
|
+
if (open?.streamId === streamId) {
|
|
44686
|
+
open.subscription.release();
|
|
44687
|
+
this.open = null;
|
|
44688
|
+
}
|
|
44689
|
+
}
|
|
44690
|
+
/** Tear the prebuffer down — the accessory is being unexposed. */
|
|
44691
|
+
async dispose() {
|
|
44692
|
+
this.open?.subscription.release();
|
|
44693
|
+
this.open = null;
|
|
44694
|
+
const source = this.source;
|
|
44695
|
+
this.source = null;
|
|
44696
|
+
if (source) await source.stop("accessory disposed");
|
|
44697
|
+
}
|
|
44698
|
+
/**
|
|
44699
|
+
* Start the prebuffer when iOS wants recording AND has chosen how, stop it
|
|
44700
|
+
* otherwise. Called from every state edge rather than each edge deciding for
|
|
44701
|
+
* itself: the two characteristics arrive in an order hap-nodejs explicitly
|
|
44702
|
+
* does not guarantee, and a per-edge decision has to re-derive the same
|
|
44703
|
+
* conjunction in two places.
|
|
44704
|
+
*/
|
|
44705
|
+
async reconcile(trigger) {
|
|
44706
|
+
if (!(this.active && this.configuration !== void 0)) {
|
|
44707
|
+
const source = this.source;
|
|
44708
|
+
this.source = null;
|
|
44709
|
+
if (source) await source.stop(`recording no longer wanted (${trigger})`);
|
|
44710
|
+
return;
|
|
44711
|
+
}
|
|
44712
|
+
const configuration = this.configuration;
|
|
44713
|
+
if (configuration === void 0) return;
|
|
44714
|
+
const audioActive = this.input.isAudioActive();
|
|
44715
|
+
const existing = this.source;
|
|
44716
|
+
if (existing !== null) {
|
|
44717
|
+
await existing.setAudioActive(audioActive);
|
|
44718
|
+
if (!existing.isRunning) await existing.start();
|
|
44719
|
+
return;
|
|
44720
|
+
}
|
|
44721
|
+
const source = this.input.createSource({
|
|
44722
|
+
fragmentMs: configuration.mediaContainerConfiguration.fragmentLength,
|
|
44723
|
+
audioActive
|
|
44724
|
+
});
|
|
44725
|
+
this.source = source;
|
|
44726
|
+
try {
|
|
44727
|
+
await source.start();
|
|
44728
|
+
} catch (err) {
|
|
44729
|
+
this.source = null;
|
|
44730
|
+
this.log.error("hksv: the prebuffer FAILED to start — this camera will record nothing", {
|
|
44731
|
+
tags: { deviceId: this.input.deviceId },
|
|
44732
|
+
meta: {
|
|
44733
|
+
trigger,
|
|
44734
|
+
error: err instanceof Error ? err.message : String(err)
|
|
44735
|
+
}
|
|
44736
|
+
});
|
|
44737
|
+
}
|
|
44738
|
+
}
|
|
44739
|
+
};
|
|
44740
|
+
//#endregion
|
|
44741
|
+
//#region src/hksv/recording-source.ts
|
|
44742
|
+
/** The tallest frame the recording path will hold in its prebuffer. */
|
|
44743
|
+
var MAX_RECORDING_HEIGHT = 1080;
|
|
44744
|
+
/** HKSV takes H.264 only — AAC audio and H.264 video, no negotiation. */
|
|
44745
|
+
var RECORDABLE_CODEC = "h264";
|
|
44746
|
+
/**
|
|
44747
|
+
* Pick the slot the recording child pulls.
|
|
44748
|
+
*
|
|
44749
|
+
* Deliberately NOT `pickPreferredRtspEntry`: that picker resolves the operator's
|
|
44750
|
+
* LIVE preference and, on `auto`, steers by the resolution iOS negotiated for a
|
|
44751
|
+
* live session — neither is a fact about recording, and on 615 it selects `mid`,
|
|
44752
|
+
* a 10 fps slot. Recording has one criterion, applied here and nowhere else:
|
|
44753
|
+
* the largest copyable frame that does not exceed {@link MAX_RECORDING_HEIGHT}.
|
|
44754
|
+
*/
|
|
44755
|
+
function pickRecordingSource(entries) {
|
|
44756
|
+
const enabled = entries.filter((e) => e.enabled);
|
|
44757
|
+
if (enabled.length === 0) return {
|
|
44758
|
+
ok: false,
|
|
44759
|
+
refusal: "no-enabled-stream"
|
|
44760
|
+
};
|
|
44761
|
+
const h264 = enabled.filter((e) => normaliseCodec(e.codec) === RECORDABLE_CODEC);
|
|
44762
|
+
if (h264.length === 0) return {
|
|
44763
|
+
ok: false,
|
|
44764
|
+
refusal: "no-h264-stream"
|
|
44765
|
+
};
|
|
44766
|
+
const sized = h264.filter(hasUsableResolution);
|
|
44767
|
+
if (sized.length === 0) return {
|
|
44768
|
+
ok: false,
|
|
44769
|
+
refusal: "no-resolution"
|
|
44770
|
+
};
|
|
44771
|
+
const withinCeiling = sized.filter((e) => height(e) <= MAX_RECORDING_HEIGHT);
|
|
44772
|
+
const best = [...withinCeiling.length > 0 ? withinCeiling : sized].sort((a, b) => withinCeiling.length > 0 ? height(b) - height(a) : height(a) - height(b))[0];
|
|
44773
|
+
if (best === void 0 || best.resolution === void 0) return {
|
|
44774
|
+
ok: false,
|
|
44775
|
+
refusal: "no-resolution"
|
|
44776
|
+
};
|
|
44777
|
+
return {
|
|
44778
|
+
ok: true,
|
|
44779
|
+
source: {
|
|
44780
|
+
brokerId: best.brokerId,
|
|
44781
|
+
profile: best.profile ?? best.brokerId,
|
|
44782
|
+
url: best.url,
|
|
44783
|
+
mutedUrl: best.mutedUrl,
|
|
44784
|
+
width: best.resolution.width,
|
|
44785
|
+
height: best.resolution.height
|
|
44786
|
+
}
|
|
44787
|
+
};
|
|
44788
|
+
}
|
|
44789
|
+
/** A one-line reason for the log — silence about a withdrawn service reads as a bug. */
|
|
44790
|
+
function refusalReason(refusal) {
|
|
44791
|
+
switch (refusal) {
|
|
44792
|
+
case "no-enabled-stream": return "the camera has no enabled RTSP profile";
|
|
44793
|
+
case "no-h264-stream": return "every enabled profile is H.265 — HKSV takes H.264 only, and a permanent transcode costs 128x a copy";
|
|
44794
|
+
case "no-resolution": return "no enabled profile declares a resolution, so nothing honest could be advertised";
|
|
44795
|
+
}
|
|
44796
|
+
}
|
|
44797
|
+
function height(entry) {
|
|
44798
|
+
return entry.resolution?.height ?? 0;
|
|
44799
|
+
}
|
|
44800
|
+
function hasUsableResolution(entry) {
|
|
44801
|
+
const r = entry.resolution;
|
|
44802
|
+
return r !== void 0 && r.width > 0 && r.height > 0;
|
|
44803
|
+
}
|
|
44804
|
+
/** Publishers spell H.265 four ways; the same normalisation the broker uses. */
|
|
44805
|
+
function normaliseCodec(codec) {
|
|
44806
|
+
return (codec ?? "").toLowerCase().replace(/[.\s-]/g, "");
|
|
44807
|
+
}
|
|
44808
|
+
//#endregion
|
|
44809
|
+
//#region src/hksv/build-recording.ts
|
|
44810
|
+
/**
|
|
44811
|
+
* Assemble HomeKit Secure Video for one camera — the ADVERTISEMENT and the
|
|
44812
|
+
* DELEGATE, together, or neither.
|
|
44813
|
+
*
|
|
44814
|
+
* That pairing is the whole rule and it is why nothing shipped for HKSV before
|
|
44815
|
+
* this: `recording` is optional on `CameraControllerOptions`, and passing it IS
|
|
44816
|
+
* the entire user-visible change. There is no "phase 1 behind a flag" for an
|
|
44817
|
+
* advertisement — either iOS is offered a recording toggle backed by a delegate
|
|
44818
|
+
* that yields real fragments, or the services are not on the accessory at all
|
|
44819
|
+
* ([D50](../../../../docs/decisions/adr-0050.md)).
|
|
44820
|
+
*
|
|
44821
|
+
* So this returns `null` for every reason a camera cannot record, and each of
|
|
44822
|
+
* them is logged at `info`/`warn` with `tags: { deviceId }`. A withdrawn
|
|
44823
|
+
* capability that says nothing is indistinguishable from a bug — and on this
|
|
44824
|
+
* surface the operator's first question is always "why does 617 have it and 615
|
|
44825
|
+
* not?".
|
|
44826
|
+
*/
|
|
44827
|
+
/**
|
|
44828
|
+
* The ffmpeg on `PATH`, exactly as the live streaming path resolves it
|
|
44829
|
+
* (`camera-streams.ts` spawns `'ffmpeg'`). One resolution per addon, not two.
|
|
44830
|
+
*/
|
|
44831
|
+
var FFMPEG_BINARY = "ffmpeg";
|
|
44832
|
+
/** Fallback when nothing measured a rate for the picked slot. */
|
|
44833
|
+
var ASSUMED_RECORDING_FPS = 15;
|
|
44834
|
+
async function buildHksvRecording(input) {
|
|
44835
|
+
const { bctx } = input;
|
|
44836
|
+
const { ctx, numericDeviceId } = bctx;
|
|
44837
|
+
const log = ctx.logger.withTags({ deviceId: numericDeviceId });
|
|
44838
|
+
const entries = await readProfileEntries(bctx);
|
|
44839
|
+
if (entries === null) {
|
|
44840
|
+
log.warn("export-hap: HKSV withheld — could not read the camera profiles", {});
|
|
44841
|
+
return null;
|
|
44842
|
+
}
|
|
44843
|
+
const choice = pickRecordingSource(entries);
|
|
44844
|
+
if (!choice.ok) {
|
|
44845
|
+
log.info("export-hap: HKSV withheld — no recordable stream", { meta: {
|
|
44846
|
+
refusal: choice.refusal,
|
|
44847
|
+
reason: refusalReason(choice.refusal)
|
|
44848
|
+
} });
|
|
44849
|
+
return null;
|
|
44850
|
+
}
|
|
44851
|
+
const source = choice.source;
|
|
44852
|
+
const gopMs = await readSourceGopMs(bctx, source.width, source.height);
|
|
44853
|
+
const fragmentLengthMs = deriveFragmentLengthMs(gopMs);
|
|
44854
|
+
if (fragmentLengthMs === null) {
|
|
44855
|
+
log.warn("export-hap: HKSV withheld — the camera key-frame interval is longer than any fragment length we advertise", { meta: {
|
|
44856
|
+
gopMs,
|
|
44857
|
+
brokerId: source.brokerId
|
|
44858
|
+
} });
|
|
44859
|
+
return null;
|
|
44860
|
+
}
|
|
44861
|
+
const fps = resolveFps(input.fpsByProfile, source.profile);
|
|
44862
|
+
const options = buildRecordingOptions({
|
|
44863
|
+
width: source.width,
|
|
44864
|
+
height: source.height,
|
|
44865
|
+
fps,
|
|
44866
|
+
fragmentLengthMs
|
|
44867
|
+
});
|
|
44868
|
+
const delegate = new HksvRecordingDelegate({
|
|
44869
|
+
logger: log,
|
|
44870
|
+
deviceId: numericDeviceId,
|
|
44871
|
+
isAudioActive: input.isAudioActive,
|
|
44872
|
+
advertisedFragmentMs: fragmentLengthMs,
|
|
44873
|
+
createSource: ({ fragmentMs, audioActive }) => new HksvFragmentSource({
|
|
44874
|
+
logger: log,
|
|
44875
|
+
deviceId: numericDeviceId,
|
|
44876
|
+
ffmpegBinaryPath: FFMPEG_BINARY,
|
|
44877
|
+
spawnFn: node_child_process.spawn,
|
|
44878
|
+
source,
|
|
44879
|
+
fragmentMs,
|
|
44880
|
+
audioActive
|
|
44881
|
+
})
|
|
44882
|
+
});
|
|
44883
|
+
log.info("export-hap: HKSV ADVERTISED — recording is offered for this camera", { meta: {
|
|
44884
|
+
brokerId: source.brokerId,
|
|
44885
|
+
profile: source.profile,
|
|
44886
|
+
resolution: `${source.width}x${source.height}`,
|
|
44887
|
+
fps,
|
|
44888
|
+
fragmentLengthMs,
|
|
44889
|
+
sourceGopMs: gopMs ?? "unknown"
|
|
44890
|
+
} });
|
|
44891
|
+
return {
|
|
44892
|
+
options,
|
|
44893
|
+
delegate,
|
|
44894
|
+
dispose: () => delegate.dispose()
|
|
44895
|
+
};
|
|
44896
|
+
}
|
|
44897
|
+
/** `cameraStreams.getProfileRtspEntries`, or `null` when the cap is unreachable. */
|
|
44898
|
+
async function readProfileEntries(bctx) {
|
|
44899
|
+
try {
|
|
44900
|
+
return await bctx.proxy.cameraStreams?.getProfileRtspEntries({}) ?? null;
|
|
44901
|
+
} catch {
|
|
44902
|
+
return null;
|
|
44903
|
+
}
|
|
44904
|
+
}
|
|
44905
|
+
/**
|
|
44906
|
+
* The camera's own key-frame interval in ms, from `stream-params`, matched to
|
|
44907
|
+
* the picked slot BY RESOLUTION.
|
|
44908
|
+
*
|
|
44909
|
+
* By resolution and not by name on purpose: `stream-params` names its profiles
|
|
44910
|
+
* `main`/`sub`/`ext` while the broker names its slots `high`/`mid`/`low`, and
|
|
44911
|
+
* on 615 `ext` is the 1280×720 slot the broker calls `mid` — a name-based match
|
|
44912
|
+
* would silently read the 4K slot's GOP for a 720p recording.
|
|
44913
|
+
*
|
|
44914
|
+
* `undefined` when the cap is not bound, which is most non-Hikvision providers.
|
|
44915
|
+
*/
|
|
44916
|
+
async function readSourceGopMs(bctx, width, height) {
|
|
44917
|
+
try {
|
|
44918
|
+
const status = await bctx.proxy.streamParams?.getStatus({});
|
|
44919
|
+
if (!status) return void 0;
|
|
44920
|
+
for (const profile of [
|
|
44921
|
+
status.main,
|
|
44922
|
+
status.sub,
|
|
44923
|
+
status.ext
|
|
44924
|
+
]) {
|
|
44925
|
+
if (!profile) continue;
|
|
44926
|
+
if (profile.width !== width || profile.height !== height) continue;
|
|
44927
|
+
const { gop, framerate } = profile;
|
|
44928
|
+
if (gop === void 0 || gop <= 0 || framerate <= 0) return void 0;
|
|
44929
|
+
return Math.round(gop / framerate * 1e3);
|
|
44930
|
+
}
|
|
44931
|
+
return;
|
|
44932
|
+
} catch {
|
|
44933
|
+
return;
|
|
44934
|
+
}
|
|
44935
|
+
}
|
|
44936
|
+
function resolveFps(fpsByProfile, profile) {
|
|
44937
|
+
return fpsByProfile.get(profile)?.fps ?? ASSUMED_RECORDING_FPS;
|
|
44938
|
+
}
|
|
44939
|
+
//#endregion
|
|
41655
44940
|
//#region src/mappers/builders/child-switch.ts
|
|
41656
44941
|
/**
|
|
41657
44942
|
* Child-switch builder — turns a camstack accessory child device (siren,
|
|
@@ -41696,7 +44981,7 @@ async function buildChildSwitch(bctx, subtype, deviceType) {
|
|
|
41696
44981
|
const isLightingDevice = deviceType === DeviceType.Light || deviceType === DeviceType.Generic;
|
|
41697
44982
|
const useLightbulb = hasBrightness && isLightingDevice;
|
|
41698
44983
|
const service = useLightbulb ? accessory.addService(_homebridge_hap_nodejs.Service.Lightbulb, displayName, subtype) : accessory.addService(_homebridge_hap_nodejs.Service.Switch, displayName, subtype);
|
|
41699
|
-
service
|
|
44984
|
+
applyServiceLabel(service, displayName);
|
|
41700
44985
|
try {
|
|
41701
44986
|
const switchStatus = await proxy.switch?.getStatus({});
|
|
41702
44987
|
if (switchStatus && typeof switchStatus.on === "boolean") service.updateCharacteristic(_homebridge_hap_nodejs.Characteristic.On, switchStatus.on);
|
|
@@ -41857,19 +45142,39 @@ async function buildCameraAccessory(input) {
|
|
|
41857
45142
|
displayName,
|
|
41858
45143
|
options
|
|
41859
45144
|
};
|
|
41860
|
-
const
|
|
45145
|
+
const advertisedVideo = await probeAdvertisedVideoProfile(bctx);
|
|
45146
|
+
const streams = buildCameraStreamingDelegate(bctx, advertisedVideo);
|
|
41861
45147
|
const handles = [];
|
|
41862
45148
|
if (capNames.has("intercom")) handles.push(await buildIntercom({
|
|
41863
45149
|
bctx,
|
|
41864
45150
|
streamingOptions: streams.streamingOptions
|
|
41865
45151
|
}));
|
|
45152
|
+
const recordingEnabled = options.hapDeviceSettings.hksvRecording === true;
|
|
45153
|
+
let recordingAudioActive = true;
|
|
45154
|
+
const recording = recordingEnabled ? await buildHksvRecording({
|
|
45155
|
+
bctx,
|
|
45156
|
+
fpsByProfile: advertisedVideo.fpsByProfile,
|
|
45157
|
+
isAudioActive: () => recordingAudioActive
|
|
45158
|
+
}) : null;
|
|
41866
45159
|
const controller = new (isDoorbell ? _homebridge_hap_nodejs.DoorbellController : _homebridge_hap_nodejs.CameraController)({
|
|
41867
45160
|
delegate: streams.delegate,
|
|
41868
45161
|
streamingOptions: streams.streamingOptions,
|
|
41869
|
-
cameraStreamCount: 2
|
|
45162
|
+
cameraStreamCount: 2,
|
|
45163
|
+
...recording === null ? {} : { recording },
|
|
45164
|
+
...recording === null || !capNames.has("motion-detection") ? {} : { sensors: { motion: true } }
|
|
41870
45165
|
});
|
|
41871
45166
|
accessory.configureController(controller);
|
|
41872
|
-
if (
|
|
45167
|
+
if (recording !== null) {
|
|
45168
|
+
handles.push({ dispose: () => recording.dispose() });
|
|
45169
|
+
const audioCharacteristic = (controller.recordingManagement?.operatingModeService)?.getCharacteristic(_homebridge_hap_nodejs.Characteristic.RecordingAudioActive);
|
|
45170
|
+
if (audioCharacteristic) {
|
|
45171
|
+
recordingAudioActive = audioCharacteristic.value !== 0 && audioCharacteristic.value !== false;
|
|
45172
|
+
audioCharacteristic.on("change", ({ newValue }) => {
|
|
45173
|
+
recordingAudioActive = newValue !== 0 && newValue !== false;
|
|
45174
|
+
});
|
|
45175
|
+
}
|
|
45176
|
+
}
|
|
45177
|
+
if (capNames.has("motion-detection")) handles.push(await buildMotionSensor(bctx, recording === null ? null : controller.motionService ?? null));
|
|
41873
45178
|
if (isDoorbell && controller instanceof _homebridge_hap_nodejs.DoorbellController) handles.push(await buildDoorbell({
|
|
41874
45179
|
bctx,
|
|
41875
45180
|
controller
|
|
@@ -41936,6 +45241,118 @@ function pickMapperKind(_capabilities) {
|
|
|
41936
45241
|
return "camera";
|
|
41937
45242
|
}
|
|
41938
45243
|
//#endregion
|
|
45244
|
+
//#region src/mappers/builders/stream-hwaccel-memo.ts
|
|
45245
|
+
/**
|
|
45246
|
+
* The two bounded memos HomeKit's decode path owns.
|
|
45247
|
+
*
|
|
45248
|
+
* ## Why they exist
|
|
45249
|
+
*
|
|
45250
|
+
* Everything D67 was actually about — one argv builder, one set of constants,
|
|
45251
|
+
* one hwaccel authority — HomeKit already had. What it did NOT have were the
|
|
45252
|
+
* two things the broker gained alongside them:
|
|
45253
|
+
*
|
|
45254
|
+
* 1. **A memo.** `probeDecoderHwaccel` issued a cross-process
|
|
45255
|
+
* `decoder.getInfo` per SESSION. iOS starts sessions in bursts — one on
|
|
45256
|
+
* record was started three times in 16 s — and every one of those paid a
|
|
45257
|
+
* cap call on a hub whose main thread is the scarce resource.
|
|
45258
|
+
* 2. **Failure feedback.** When HomeKit's hardware child died at init and
|
|
45259
|
+
* `shouldRetryInSoftware` saved the session, HomeKit told nobody. The next
|
|
45260
|
+
* session re-picked the same corpse and paid the same two-second death.
|
|
45261
|
+
* `EgressTranscodeManager` fixed exactly this for its own children
|
|
45262
|
+
* (26a522cd5) by reporting the dead backend into the broker's 60 s memo.
|
|
45263
|
+
*
|
|
45264
|
+
* ## Both ride `HwAccelCache`, deliberately
|
|
45265
|
+
*
|
|
45266
|
+
* `createHwAccelCache` from `@camstack/types` is the primitive the broker's own
|
|
45267
|
+
* `egressHwAccelCache` is built from, and the discipline it encodes is the
|
|
45268
|
+
* point: **caller-owned, never a module global** — a module global would
|
|
45269
|
+
* outlive an addon respawn and survive an operator changing the decoder
|
|
45270
|
+
* backend. Same TTL as the broker's, so "has hardware come back yet" cannot
|
|
45271
|
+
* answer differently depending on which consumer asked.
|
|
45272
|
+
*
|
|
45273
|
+
* ## The cross-process gap, stated honestly
|
|
45274
|
+
*
|
|
45275
|
+
* These memos are scoped to the `export-hap` PROCESS. When HomeKit's vaapi
|
|
45276
|
+
* child dies, the broker's next child still pays its own two-second death, and
|
|
45277
|
+
* vice versa — because addons may never import each other and there is no
|
|
45278
|
+
* capability for "this backend is dead on this node right now". Closing that
|
|
45279
|
+
* would need a new cap surface, which Phase 0 explicitly does not take. What is
|
|
45280
|
+
* closed here is HomeKit's own repetition of the cost, across cameras and
|
|
45281
|
+
* across sessions.
|
|
45282
|
+
*/
|
|
45283
|
+
/**
|
|
45284
|
+
* The window both memos answer for.
|
|
45285
|
+
*
|
|
45286
|
+
* 60 s, the same as `stream-broker-manager`'s `egressHwAccelCache`. Long enough
|
|
45287
|
+
* that a burst of session restarts pays one read; short enough that an operator
|
|
45288
|
+
* who changes the decoder backend, or a host whose accelerator recovers, is
|
|
45289
|
+
* obeyed on the next session rather than after an addon respawn.
|
|
45290
|
+
*/
|
|
45291
|
+
var HAP_DECODE_MEMO_TTL_MS = 6e4;
|
|
45292
|
+
/**
|
|
45293
|
+
* Separator inside the encoded reading. A control character, because a backend
|
|
45294
|
+
* name is `[a-z0-9]+` and the decoder's non-backend choices are `auto` /
|
|
45295
|
+
* `none` / `''`, none of which can contain one — so the split is total.
|
|
45296
|
+
*/
|
|
45297
|
+
var READING_SEPARATOR = "";
|
|
45298
|
+
/**
|
|
45299
|
+
* Marks a `null` FIELD, distinct from an EMPTY one.
|
|
45300
|
+
*
|
|
45301
|
+
* `probedBestHwaccel: ''` means the decoder answered and has never probed
|
|
45302
|
+
* (=> `not-probed`); `null` means the field was absent altogether. Encoding
|
|
45303
|
+
* both as `''` would lose a distinction `selectHwDecode` acts on.
|
|
45304
|
+
*/
|
|
45305
|
+
var NULL_FIELD = "\0";
|
|
45306
|
+
function encodeField(value) {
|
|
45307
|
+
return value === null ? NULL_FIELD : value;
|
|
45308
|
+
}
|
|
45309
|
+
function decodeField(value) {
|
|
45310
|
+
return value === NULL_FIELD ? null : value;
|
|
45311
|
+
}
|
|
45312
|
+
/**
|
|
45313
|
+
* A reading as ONE `string | null`, which is what {@link HwAccelCache} stores.
|
|
45314
|
+
*
|
|
45315
|
+
* The cache's three states are exactly the three a memoised reading needs:
|
|
45316
|
+
* `undefined` (never asked, or expired), `null` (asked, and the decoder could
|
|
45317
|
+
* not be reached), and a value. Encoding into the one cache rather than
|
|
45318
|
+
* splitting across two is what keeps those three from skewing — two caches
|
|
45319
|
+
* written together can still be READ across an expiry boundary.
|
|
45320
|
+
*/
|
|
45321
|
+
function encodeDecoderReading(reading) {
|
|
45322
|
+
if (reading === null) return null;
|
|
45323
|
+
return `${encodeField(reading.hwaccel)}${READING_SEPARATOR}${encodeField(reading.probedBestHwaccel)}`;
|
|
45324
|
+
}
|
|
45325
|
+
function decodeDecoderReading(value) {
|
|
45326
|
+
if (value === null) return null;
|
|
45327
|
+
const [hwaccel = NULL_FIELD, probed = NULL_FIELD] = value.split(READING_SEPARATOR);
|
|
45328
|
+
return {
|
|
45329
|
+
hwaccel: decodeField(hwaccel),
|
|
45330
|
+
probedBestHwaccel: decodeField(probed)
|
|
45331
|
+
};
|
|
45332
|
+
}
|
|
45333
|
+
function createDecoderReadingMemo(options) {
|
|
45334
|
+
const cache = createHwAccelCache(options);
|
|
45335
|
+
return {
|
|
45336
|
+
read() {
|
|
45337
|
+
const cached = cache.read();
|
|
45338
|
+
return cached === void 0 ? void 0 : decodeDecoderReading(cached);
|
|
45339
|
+
},
|
|
45340
|
+
write(reading) {
|
|
45341
|
+
cache.write(encodeDecoderReading(reading));
|
|
45342
|
+
}
|
|
45343
|
+
};
|
|
45344
|
+
}
|
|
45345
|
+
function createHapDecodeMemos(now) {
|
|
45346
|
+
const options = {
|
|
45347
|
+
ttlMs: HAP_DECODE_MEMO_TTL_MS,
|
|
45348
|
+
...now ? { now } : {}
|
|
45349
|
+
};
|
|
45350
|
+
return {
|
|
45351
|
+
reading: createDecoderReadingMemo(options),
|
|
45352
|
+
failedBackend: createHwAccelCache(options)
|
|
45353
|
+
};
|
|
45354
|
+
}
|
|
45355
|
+
//#endregion
|
|
41939
45356
|
//#region src/reconcile/sync-state.ts
|
|
41940
45357
|
function syncStateFromJson(json) {
|
|
41941
45358
|
const map = /* @__PURE__ */ new Map();
|
|
@@ -42002,7 +45419,10 @@ function syncStateToJson(map) {
|
|
|
42002
45419
|
* intercom upload bridge, HomeKit Secure Video, recording, native
|
|
42003
45420
|
* H.264 stream tap (currently uses RTSP + ffmpeg copy).
|
|
42004
45421
|
*/
|
|
42005
|
-
var DEFAULT_DEVICE_SETTINGS = {
|
|
45422
|
+
var DEFAULT_DEVICE_SETTINGS = {
|
|
45423
|
+
streamPreference: "auto",
|
|
45424
|
+
hksvRecording: false
|
|
45425
|
+
};
|
|
42006
45426
|
var HAP_STREAM_PREFERENCE_OPTIONS = [
|
|
42007
45427
|
{
|
|
42008
45428
|
value: "auto",
|
|
@@ -42078,6 +45498,18 @@ var ExportHapAddon = class extends BaseAddon {
|
|
|
42078
45498
|
pincode = "";
|
|
42079
45499
|
/** Optional mDNS/bind interface (config.interfaceName), or undefined. */
|
|
42080
45500
|
bind;
|
|
45501
|
+
/**
|
|
45502
|
+
* What this PROCESS remembers about decode hardware, shared by every camera
|
|
45503
|
+
* mapper: the decoder addon's per-node reading (60 s), and the backend that
|
|
45504
|
+
* last died at init (60 s).
|
|
45505
|
+
*
|
|
45506
|
+
* Owned here rather than as a module global for the reason `HwAccelCache`
|
|
45507
|
+
* itself records — a module global outlives an addon respawn and survives an
|
|
45508
|
+
* operator changing the decoder backend. Owned here rather than per mapper
|
|
45509
|
+
* because the whole point is that camera B does not re-pay camera A's failed
|
|
45510
|
+
* hardware init.
|
|
45511
|
+
*/
|
|
45512
|
+
decodeMemos = createHapDecodeMemos();
|
|
42081
45513
|
constructor() {
|
|
42082
45514
|
super({ ...DEFAULT_CONFIG });
|
|
42083
45515
|
}
|
|
@@ -42219,13 +45651,14 @@ var ExportHapAddon = class extends BaseAddon {
|
|
|
42219
45651
|
const mapperKind = pickMapperKind(capabilities);
|
|
42220
45652
|
if (!mapperKind) throw new Error(`export-hap: no mapper for capabilities ${JSON.stringify(capabilities ?? [])}`);
|
|
42221
45653
|
const displayName = await this.resolveDisplayName(deviceId);
|
|
42222
|
-
const
|
|
45654
|
+
const previous = this.config.exposed.find((e) => e.deviceId === deviceId);
|
|
45655
|
+
const baseEntry = carryForward({
|
|
42223
45656
|
deviceId,
|
|
42224
45657
|
displayName,
|
|
42225
45658
|
mapperKind,
|
|
42226
|
-
addedAt: Date.now(),
|
|
45659
|
+
addedAt: previous?.addedAt ?? Date.now(),
|
|
42227
45660
|
...capabilities ? { capabilities: [...capabilities] } : {}
|
|
42228
|
-
};
|
|
45661
|
+
}, previous, ["settings", "capabilities"]);
|
|
42229
45662
|
const attached = await this.attachMapper(baseEntry);
|
|
42230
45663
|
const finalEntry = {
|
|
42231
45664
|
...baseEntry,
|
|
@@ -42244,13 +45677,13 @@ var ExportHapAddon = class extends BaseAddon {
|
|
|
42244
45677
|
childCount: attached.childAccessoryUuids.length
|
|
42245
45678
|
} });
|
|
42246
45679
|
}
|
|
42247
|
-
async unexposeDevice(deviceId) {
|
|
45680
|
+
async unexposeDevice(deviceId, options = {}) {
|
|
42248
45681
|
const numericId = Number.parseInt(deviceId, 10);
|
|
42249
45682
|
const log = this.ctx.logger.withTags({ deviceId: numericId });
|
|
42250
45683
|
await this.detachMapper(deviceId);
|
|
42251
45684
|
const next = this.config.exposed.filter((e) => e.deviceId !== deviceId);
|
|
42252
45685
|
if (next.length !== this.config.exposed.length) await this.updateGlobalSettings({ exposed: next });
|
|
42253
|
-
clearPairingFiles(_homebridge_hap_nodejs.uuid.generate(`camstack:camera:${numericId}`), this.ctx.logger);
|
|
45686
|
+
if (options.clearPairing !== false) clearPairingFiles(_homebridge_hap_nodejs.uuid.generate(`camstack:camera:${numericId}`), this.ctx.logger);
|
|
42254
45687
|
await this.forgetFingerprint(numericId);
|
|
42255
45688
|
log.info("export-hap: unexposed device");
|
|
42256
45689
|
}
|
|
@@ -42263,7 +45696,11 @@ var ExportHapAddon = class extends BaseAddon {
|
|
|
42263
45696
|
displayName: entry.displayName,
|
|
42264
45697
|
options: {
|
|
42265
45698
|
ptzPulseMs: this.config.ptzPulseMs,
|
|
42266
|
-
|
|
45699
|
+
decodeMemos: this.decodeMemos,
|
|
45700
|
+
hapDeviceSettings: {
|
|
45701
|
+
streamPreference: entrySettings.streamPreference ?? "auto",
|
|
45702
|
+
hksvRecording: entrySettings.hksvRecording === true
|
|
45703
|
+
}
|
|
42267
45704
|
}
|
|
42268
45705
|
});
|
|
42269
45706
|
for (const accessory of mapper.accessories) await publishStandalone(accessory, {
|
|
@@ -42538,6 +45975,7 @@ var ExportHapAddon = class extends BaseAddon {
|
|
|
42538
45975
|
const enabled = entry !== null;
|
|
42539
45976
|
const enabledKey = `hap:${deviceId}:enabled`;
|
|
42540
45977
|
const streamPreferenceKey = `hap:${deviceId}:streamPreference`;
|
|
45978
|
+
const hksvKey = `hap:${deviceId}:hksvRecording`;
|
|
42541
45979
|
const mapper = this.exposed.get(String(deviceId)) ?? null;
|
|
42542
45980
|
const paired = mapper ? accessoryPaired(mapper.accessory) : false;
|
|
42543
45981
|
const name = entry?.displayName ?? `Device ${deviceId}`;
|
|
@@ -42605,6 +46043,19 @@ var ExportHapAddon = class extends BaseAddon {
|
|
|
42605
46043
|
equals: true
|
|
42606
46044
|
},
|
|
42607
46045
|
immediate: true
|
|
46046
|
+
},
|
|
46047
|
+
{
|
|
46048
|
+
type: "boolean",
|
|
46049
|
+
key: hksvKey,
|
|
46050
|
+
label: "HomeKit recording (Secure Video)",
|
|
46051
|
+
description: "Offer “Stream and Allow Recording” in iOS Home. Requires iCloud+ and a home hub. Keeps a continuous 8s prebuffer for this camera (~0.7% of one CPU core, H.264 sources only).",
|
|
46052
|
+
style: "switch",
|
|
46053
|
+
value: settings.hksvRecording === true,
|
|
46054
|
+
showWhen: {
|
|
46055
|
+
field: enabledKey,
|
|
46056
|
+
equals: true
|
|
46057
|
+
},
|
|
46058
|
+
immediate: true
|
|
42608
46059
|
}
|
|
42609
46060
|
]
|
|
42610
46061
|
}]
|
|
@@ -42629,12 +46080,15 @@ var ExportHapAddon = class extends BaseAddon {
|
|
|
42629
46080
|
const wasEnabled = this.exposed.has(deviceIdStr);
|
|
42630
46081
|
const enabledKey = `hap:${deviceId}:enabled`;
|
|
42631
46082
|
const streamPreferenceKey = `hap:${deviceId}:streamPreference`;
|
|
46083
|
+
const hksvKey = `hap:${deviceId}:hksvRecording`;
|
|
42632
46084
|
const enabledValue = enabledKey in patch ? Boolean(patch[enabledKey]) : wasEnabled;
|
|
42633
46085
|
const streamPreferenceRaw = streamPreferenceKey in patch ? patch[streamPreferenceKey] : current?.settings?.streamPreference;
|
|
42634
46086
|
const streamPreference = typeof streamPreferenceRaw === "string" && streamPreferenceRaw.trim().length > 0 ? streamPreferenceRaw : "auto";
|
|
46087
|
+
const hksvRecording = hksvKey in patch ? Boolean(patch[hksvKey]) : current?.settings?.hksvRecording === true;
|
|
42635
46088
|
const nextSettings = {
|
|
42636
46089
|
...current?.settings ?? DEFAULT_DEVICE_SETTINGS,
|
|
42637
|
-
streamPreference
|
|
46090
|
+
streamPreference,
|
|
46091
|
+
hksvRecording
|
|
42638
46092
|
};
|
|
42639
46093
|
if (!enabledValue) {
|
|
42640
46094
|
if (wasEnabled) await this.unexposeDevice(deviceIdStr);
|
|
@@ -42646,18 +46100,24 @@ var ExportHapAddon = class extends BaseAddon {
|
|
|
42646
46100
|
return { success: true };
|
|
42647
46101
|
}
|
|
42648
46102
|
const currentPref = current?.settings?.streamPreference ?? "auto";
|
|
46103
|
+
const currentHksv = current?.settings?.hksvRecording === true;
|
|
42649
46104
|
await this.updateEntrySettings(deviceIdStr, nextSettings);
|
|
42650
|
-
if (currentPref !== streamPreference) {
|
|
42651
|
-
log.info("export-hap:
|
|
42652
|
-
|
|
42653
|
-
|
|
46105
|
+
if (currentPref !== streamPreference || currentHksv !== hksvRecording) {
|
|
46106
|
+
log.info("export-hap: per-camera export settings changed — refreshing accessory", { meta: {
|
|
46107
|
+
streamPreference: {
|
|
46108
|
+
from: currentPref,
|
|
46109
|
+
to: streamPreference
|
|
46110
|
+
},
|
|
46111
|
+
hksvRecording: {
|
|
46112
|
+
from: currentHksv,
|
|
46113
|
+
to: hksvRecording
|
|
46114
|
+
}
|
|
42654
46115
|
} });
|
|
42655
46116
|
try {
|
|
42656
|
-
await this.unexposeDevice(deviceIdStr);
|
|
46117
|
+
await this.unexposeDevice(deviceIdStr, { clearPairing: false });
|
|
42657
46118
|
await this.exposeDevice(deviceIdStr);
|
|
42658
|
-
await this.updateEntrySettings(deviceIdStr, nextSettings);
|
|
42659
46119
|
} catch (err) {
|
|
42660
|
-
log.warn("export-hap: failed to refresh accessory after
|
|
46120
|
+
log.warn("export-hap: failed to refresh accessory after a settings change", { meta: { error: errMsg(err) } });
|
|
42661
46121
|
}
|
|
42662
46122
|
}
|
|
42663
46123
|
return { success: true };
|