@camstack/addon-export-hap 1.2.14 → 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 +2052 -123
- package/dist/export-hap.addon.mjs +2054 -125
- package/package.json +1 -1
package/dist/export-hap.addon.js
CHANGED
|
@@ -6536,7 +6536,20 @@ var BrokerStatsSchema = object({
|
|
|
6536
6536
|
sampleRate: number(),
|
|
6537
6537
|
channels: number(),
|
|
6538
6538
|
supported: boolean()
|
|
6539
|
-
}).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()
|
|
6540
6553
|
});
|
|
6541
6554
|
/**
|
|
6542
6555
|
* Exporter-facing "profile restream" entry. Returned by
|
|
@@ -7067,104 +7080,7 @@ method(object({ deviceId: number() }), array(StreamSourceEntrySchema)), method(o
|
|
|
7067
7080
|
input: unknown()
|
|
7068
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" });
|
|
7069
7082
|
//#endregion
|
|
7070
|
-
//#region ../types/dist/
|
|
7071
|
-
/**
|
|
7072
|
-
* Deterministic SHA-256 hash of an arbitrary serialisable value. The
|
|
7073
|
-
* canonical form sorts object keys alphabetically at every depth so two
|
|
7074
|
-
* structurally-equal inputs with different key insertion orders produce
|
|
7075
|
-
* the same hash. Returns a 64-char lowercase hex digest.
|
|
7076
|
-
*
|
|
7077
|
-
* Used by export adapters (Alexa, HAP) to short-circuit re-discovery /
|
|
7078
|
-
* accessory-rebuild work when the upstream shape is byte-identical to
|
|
7079
|
-
* the last applied state — preventing user-visible "re-discovery"
|
|
7080
|
-
* notifications on every addon-runner respawn. Each respawn re-fires
|
|
7081
|
-
* `DeviceBindingsChanged` for every cap registration, which without
|
|
7082
|
-
* this guard would propagate redundant pushes.
|
|
7083
|
-
*
|
|
7084
|
-
* Note: this is a SYMPTOMATIC fix layered on top of the binding-change
|
|
7085
|
-
* subscription. The proper fix is a single "device ready" lifecycle
|
|
7086
|
-
* barrier so exports react only when the full cap set has landed —
|
|
7087
|
-
* tracked separately for post-HA-integration work.
|
|
7088
|
-
*/
|
|
7089
|
-
function canonicalHash(value) {
|
|
7090
|
-
const canonical = JSON.stringify(value, replaceWithSortedKeys);
|
|
7091
|
-
return (0, node_crypto.createHash)("sha256").update(canonical ?? "").digest("hex");
|
|
7092
|
-
}
|
|
7093
|
-
function replaceWithSortedKeys(_key, value) {
|
|
7094
|
-
if (value && typeof value === "object" && !Array.isArray(value)) {
|
|
7095
|
-
const obj = value;
|
|
7096
|
-
const out = {};
|
|
7097
|
-
for (const k of Object.keys(obj).toSorted()) out[k] = obj[k];
|
|
7098
|
-
return out;
|
|
7099
|
-
}
|
|
7100
|
-
return value;
|
|
7101
|
-
}
|
|
7102
|
-
var EncodeProfileSchema = object({
|
|
7103
|
-
video: object({
|
|
7104
|
-
codec: _enum([
|
|
7105
|
-
"h264",
|
|
7106
|
-
"h265",
|
|
7107
|
-
"copy"
|
|
7108
|
-
]),
|
|
7109
|
-
profile: _enum([
|
|
7110
|
-
"baseline",
|
|
7111
|
-
"main",
|
|
7112
|
-
"high"
|
|
7113
|
-
]).optional(),
|
|
7114
|
-
/**
|
|
7115
|
-
* `-level`, e.g. `'3.1'`. A consumer that ADVERTISES a level in its SDP
|
|
7116
|
-
* (`profile-level-id=42e01f` is Baseline 3.1) must constrain the encoder to
|
|
7117
|
-
* it, or it ships a stream that does not match its own advertisement — the
|
|
7118
|
-
* defect class that kept HomeKit black for a year and that Alexa carried
|
|
7119
|
-
* silently. Optional because a browser negotiates the level itself.
|
|
7120
|
-
*/
|
|
7121
|
-
level: string().optional(),
|
|
7122
|
-
width: number().int().positive().optional(),
|
|
7123
|
-
height: number().int().positive().optional(),
|
|
7124
|
-
fps: number().positive().optional(),
|
|
7125
|
-
bitrateKbps: number().int().positive().optional(),
|
|
7126
|
-
gopFrames: number().int().positive().optional(),
|
|
7127
|
-
bf: number().int().min(0).optional(),
|
|
7128
|
-
preset: _enum([
|
|
7129
|
-
"ultrafast",
|
|
7130
|
-
"superfast",
|
|
7131
|
-
"veryfast",
|
|
7132
|
-
"faster",
|
|
7133
|
-
"fast",
|
|
7134
|
-
"medium"
|
|
7135
|
-
]).optional(),
|
|
7136
|
-
tune: _enum([
|
|
7137
|
-
"zerolatency",
|
|
7138
|
-
"film",
|
|
7139
|
-
"animation"
|
|
7140
|
-
]).optional()
|
|
7141
|
-
}),
|
|
7142
|
-
audio: union([literal("passthrough"), object({
|
|
7143
|
-
codec: _enum([
|
|
7144
|
-
"opus",
|
|
7145
|
-
"aac",
|
|
7146
|
-
"pcmu",
|
|
7147
|
-
"pcma",
|
|
7148
|
-
"copy"
|
|
7149
|
-
]),
|
|
7150
|
-
bitrateKbps: number().int().positive().optional(),
|
|
7151
|
-
sampleRateHz: number().int().positive().optional(),
|
|
7152
|
-
channels: union([literal(1), literal(2)]).optional()
|
|
7153
|
-
})]),
|
|
7154
|
-
/**
|
|
7155
|
-
* ffmpeg input-side args, inserted between the fixed global flags
|
|
7156
|
-
* (`-hide_banner -loglevel error`) and `-i pipe:0`. Free-text array
|
|
7157
|
-
* — the widget surfaces a textarea + suggestion chips for the most-
|
|
7158
|
-
* used demuxer/format options.
|
|
7159
|
-
*/
|
|
7160
|
-
inputArgs: array(string()).optional(),
|
|
7161
|
-
/**
|
|
7162
|
-
* ffmpeg output-side args, inserted between the encode block and
|
|
7163
|
-
* the final `-f <muxer> pipe:1`. Use for muxer options, bitstream
|
|
7164
|
-
* filters, codec-specific overrides. Free-text array.
|
|
7165
|
-
*/
|
|
7166
|
-
outputArgs: array(string()).optional()
|
|
7167
|
-
});
|
|
7083
|
+
//#region ../types/dist/fmp4-box-splitter-B53u9-Nu.mjs
|
|
7168
7084
|
var AUDIO_ENCODER_BY_CODEC = {
|
|
7169
7085
|
opus: "libopus",
|
|
7170
7086
|
aac: "aac",
|
|
@@ -7345,13 +7261,42 @@ function buildStdoutOrRtspSinkArgs(sink) {
|
|
|
7345
7261
|
"pipe:1"
|
|
7346
7262
|
];
|
|
7347
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;
|
|
7348
7293
|
/** `-movflags … -min_frag_duration <us> -f mp4 pipe:1`. */
|
|
7349
7294
|
function buildFmp4SinkArgs(sink) {
|
|
7350
7295
|
return [
|
|
7351
7296
|
"-movflags",
|
|
7352
7297
|
FMP4_MOVFLAGS,
|
|
7353
7298
|
"-min_frag_duration",
|
|
7354
|
-
String(Math.max(0, Math.round(sink.fragmentMs * 1e3))),
|
|
7299
|
+
String(Math.max(0, Math.round(sink.fragmentMs * FMP4_MIN_FRAG_MARGIN * 1e3))),
|
|
7355
7300
|
"-f",
|
|
7356
7301
|
"mp4",
|
|
7357
7302
|
"pipe:1"
|
|
@@ -7430,6 +7375,287 @@ function buildFfmpegArgs(inv) {
|
|
|
7430
7375
|
];
|
|
7431
7376
|
}
|
|
7432
7377
|
/**
|
|
7378
|
+
* Deterministic SHA-256 hash of an arbitrary serialisable value. The
|
|
7379
|
+
* canonical form sorts object keys alphabetically at every depth so two
|
|
7380
|
+
* structurally-equal inputs with different key insertion orders produce
|
|
7381
|
+
* the same hash. Returns a 64-char lowercase hex digest.
|
|
7382
|
+
*
|
|
7383
|
+
* Used by export adapters (Alexa, HAP) to short-circuit re-discovery /
|
|
7384
|
+
* accessory-rebuild work when the upstream shape is byte-identical to
|
|
7385
|
+
* the last applied state — preventing user-visible "re-discovery"
|
|
7386
|
+
* notifications on every addon-runner respawn. Each respawn re-fires
|
|
7387
|
+
* `DeviceBindingsChanged` for every cap registration, which without
|
|
7388
|
+
* this guard would propagate redundant pushes.
|
|
7389
|
+
*
|
|
7390
|
+
* Note: this is a SYMPTOMATIC fix layered on top of the binding-change
|
|
7391
|
+
* subscription. The proper fix is a single "device ready" lifecycle
|
|
7392
|
+
* barrier so exports react only when the full cap set has landed —
|
|
7393
|
+
* tracked separately for post-HA-integration work.
|
|
7394
|
+
*/
|
|
7395
|
+
function canonicalHash(value) {
|
|
7396
|
+
const canonical = JSON.stringify(value, replaceWithSortedKeys);
|
|
7397
|
+
return (0, node_crypto.createHash)("sha256").update(canonical ?? "").digest("hex");
|
|
7398
|
+
}
|
|
7399
|
+
function replaceWithSortedKeys(_key, value) {
|
|
7400
|
+
if (value && typeof value === "object" && !Array.isArray(value)) {
|
|
7401
|
+
const obj = value;
|
|
7402
|
+
const out = {};
|
|
7403
|
+
for (const k of Object.keys(obj).toSorted()) out[k] = obj[k];
|
|
7404
|
+
return out;
|
|
7405
|
+
}
|
|
7406
|
+
return value;
|
|
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
|
+
}
|
|
7592
|
+
var EncodeProfileSchema = object({
|
|
7593
|
+
video: object({
|
|
7594
|
+
codec: _enum([
|
|
7595
|
+
"h264",
|
|
7596
|
+
"h265",
|
|
7597
|
+
"copy"
|
|
7598
|
+
]),
|
|
7599
|
+
profile: _enum([
|
|
7600
|
+
"baseline",
|
|
7601
|
+
"main",
|
|
7602
|
+
"high"
|
|
7603
|
+
]).optional(),
|
|
7604
|
+
/**
|
|
7605
|
+
* `-level`, e.g. `'3.1'`. A consumer that ADVERTISES a level in its SDP
|
|
7606
|
+
* (`profile-level-id=42e01f` is Baseline 3.1) must constrain the encoder to
|
|
7607
|
+
* it, or it ships a stream that does not match its own advertisement — the
|
|
7608
|
+
* defect class that kept HomeKit black for a year and that Alexa carried
|
|
7609
|
+
* silently. Optional because a browser negotiates the level itself.
|
|
7610
|
+
*/
|
|
7611
|
+
level: string().optional(),
|
|
7612
|
+
width: number().int().positive().optional(),
|
|
7613
|
+
height: number().int().positive().optional(),
|
|
7614
|
+
fps: number().positive().optional(),
|
|
7615
|
+
bitrateKbps: number().int().positive().optional(),
|
|
7616
|
+
gopFrames: number().int().positive().optional(),
|
|
7617
|
+
bf: number().int().min(0).optional(),
|
|
7618
|
+
preset: _enum([
|
|
7619
|
+
"ultrafast",
|
|
7620
|
+
"superfast",
|
|
7621
|
+
"veryfast",
|
|
7622
|
+
"faster",
|
|
7623
|
+
"fast",
|
|
7624
|
+
"medium"
|
|
7625
|
+
]).optional(),
|
|
7626
|
+
tune: _enum([
|
|
7627
|
+
"zerolatency",
|
|
7628
|
+
"film",
|
|
7629
|
+
"animation"
|
|
7630
|
+
]).optional()
|
|
7631
|
+
}),
|
|
7632
|
+
audio: union([literal("passthrough"), object({
|
|
7633
|
+
codec: _enum([
|
|
7634
|
+
"opus",
|
|
7635
|
+
"aac",
|
|
7636
|
+
"pcmu",
|
|
7637
|
+
"pcma",
|
|
7638
|
+
"copy"
|
|
7639
|
+
]),
|
|
7640
|
+
bitrateKbps: number().int().positive().optional(),
|
|
7641
|
+
sampleRateHz: number().int().positive().optional(),
|
|
7642
|
+
channels: union([literal(1), literal(2)]).optional()
|
|
7643
|
+
})]),
|
|
7644
|
+
/**
|
|
7645
|
+
* ffmpeg input-side args, inserted between the fixed global flags
|
|
7646
|
+
* (`-hide_banner -loglevel error`) and `-i pipe:0`. Free-text array
|
|
7647
|
+
* — the widget surfaces a textarea + suggestion chips for the most-
|
|
7648
|
+
* used demuxer/format options.
|
|
7649
|
+
*/
|
|
7650
|
+
inputArgs: array(string()).optional(),
|
|
7651
|
+
/**
|
|
7652
|
+
* ffmpeg output-side args, inserted between the encode block and
|
|
7653
|
+
* the final `-f <muxer> pipe:1`. Use for muxer options, bitstream
|
|
7654
|
+
* filters, codec-specific overrides. Free-text array.
|
|
7655
|
+
*/
|
|
7656
|
+
outputArgs: array(string()).optional()
|
|
7657
|
+
});
|
|
7658
|
+
/**
|
|
7433
7659
|
* The shape every live egress starts from: H.264 Baseline 3.1 at 720p25.
|
|
7434
7660
|
* Baseline because it is the one profile every consumer in this repo decodes
|
|
7435
7661
|
* (Echo, iOS, an old browser); 3.1 because that is what the SDPs advertise.
|
|
@@ -7549,6 +7775,19 @@ object({
|
|
|
7549
7775
|
* | `notifications` | `notificationRules.setDeviceMuted` | `NotificationCenter.evaluateAndEnqueue` returns before any rule is evaluated |
|
|
7550
7776
|
* | `privacy-mask` | `privacyMask.setMask({ enabled })` → the CAMERA | the camera blanks the masked regions itself; every stream and recording carries the black boxes |
|
|
7551
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.
|
|
7552
7791
|
*
|
|
7553
7792
|
* ## The two switches whose authority is not on this server
|
|
7554
7793
|
*
|
|
@@ -7610,6 +7849,7 @@ var CameraSwitchIdSchema = _enum([
|
|
|
7610
7849
|
"object-detection",
|
|
7611
7850
|
"privacy-mask",
|
|
7612
7851
|
"device-audio",
|
|
7852
|
+
"broker-audio",
|
|
7613
7853
|
"audio-analysis",
|
|
7614
7854
|
"recording",
|
|
7615
7855
|
"notifications"
|
|
@@ -7635,7 +7875,8 @@ var CameraSwitchAuthoritySchema = discriminatedUnion("kind", [
|
|
|
7635
7875
|
object({
|
|
7636
7876
|
kind: literal("camera-mask"),
|
|
7637
7877
|
capName: string()
|
|
7638
|
-
})
|
|
7878
|
+
}),
|
|
7879
|
+
object({ kind: literal("broker-audio-mute") })
|
|
7639
7880
|
]);
|
|
7640
7881
|
/**
|
|
7641
7882
|
* Why a switch is not offered for this camera. Rendered instead of the
|
|
@@ -9886,7 +10127,25 @@ method(object({
|
|
|
9886
10127
|
}), _void(), {
|
|
9887
10128
|
kind: "mutation",
|
|
9888
10129
|
auth: "admin"
|
|
9889
|
-
}), 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({
|
|
9890
10149
|
deviceId: number().int().nonnegative(),
|
|
9891
10150
|
camStreamId: string(),
|
|
9892
10151
|
profile: CamProfileSchema
|
|
@@ -15209,6 +15468,30 @@ var TrackSourceSchema = _enum([
|
|
|
15209
15468
|
"audio"
|
|
15210
15469
|
]);
|
|
15211
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
|
+
/**
|
|
15212
15495
|
* Per-track OPERATOR flags — set by hand from the admin UI or the viewer, never
|
|
15213
15496
|
* by the pipeline. Spread into `TrackSchema` and `KeyEventSchema` from one place
|
|
15214
15497
|
* so the two surfaces cannot drift.
|
|
@@ -15218,18 +15501,31 @@ var TrackSourceSchema = _enum([
|
|
|
15218
15501
|
* columns existed read as absent, and a consumer that needs a boolean should say
|
|
15219
15502
|
* `flag === true`, not `flag !== false`.
|
|
15220
15503
|
*
|
|
15221
|
-
*
|
|
15222
|
-
*
|
|
15223
|
-
*
|
|
15224
|
-
* `
|
|
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.
|
|
15225
15512
|
*/
|
|
15226
15513
|
var TrackFlagFields = {
|
|
15227
|
-
/** Operator marked this track as training material.
|
|
15514
|
+
/** Operator marked this track as training material — i.e. `retrainStatus` is
|
|
15515
|
+
* `'staging'`. */
|
|
15228
15516
|
markForTrain: boolean().optional(),
|
|
15229
15517
|
/** Operator marked this track for diagnostic attention. */
|
|
15230
15518
|
debug: boolean().optional()
|
|
15231
15519
|
};
|
|
15232
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
|
+
/**
|
|
15233
15529
|
* The write half: a PARTIAL patch. An omitted key is left untouched, so setting
|
|
15234
15530
|
* one flag can never clear the other — the toggles are independent and are
|
|
15235
15531
|
* driven from three surfaces that do not know about each other.
|
|
@@ -15243,7 +15539,32 @@ var TrackFlagsPatchSchema = object(TrackFlagFields);
|
|
|
15243
15539
|
var TrackFlagsSchema = object({
|
|
15244
15540
|
trackId: string(),
|
|
15245
15541
|
markForTrain: boolean(),
|
|
15246
|
-
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()
|
|
15247
15568
|
});
|
|
15248
15569
|
var TrackSchema = object({
|
|
15249
15570
|
trackId: string(),
|
|
@@ -15288,7 +15609,8 @@ var TrackSchema = object({
|
|
|
15288
15609
|
* Populated from the persisted envelope columns on historical reads;
|
|
15289
15610
|
* absent on legacy rows, dims-less tracks and active (in-RAM) tracks. */
|
|
15290
15611
|
envelope: TrackEnvelopeSchema.optional(),
|
|
15291
|
-
...TrackFlagFields
|
|
15612
|
+
...TrackFlagFields,
|
|
15613
|
+
...TrackRetrainFields
|
|
15292
15614
|
});
|
|
15293
15615
|
var BaseEventFields = {
|
|
15294
15616
|
id: string(),
|
|
@@ -15502,7 +15824,8 @@ var KeyEventSchema = object({
|
|
|
15502
15824
|
bestEventId: string(),
|
|
15503
15825
|
/** Track lifetime in ms (lastSeen - firstSeen). */
|
|
15504
15826
|
windowMs: number().optional(),
|
|
15505
|
-
...TrackFlagFields
|
|
15827
|
+
...TrackFlagFields,
|
|
15828
|
+
...TrackRetrainFields
|
|
15506
15829
|
});
|
|
15507
15830
|
object({
|
|
15508
15831
|
trackId: string(),
|
|
@@ -15763,6 +16086,12 @@ DeviceType.Camera, method(object({ deviceId: number() }), array(TrackSchema).rea
|
|
|
15763
16086
|
}), method(OpsLogQueryInputSchema, array(OpsLogEntrySchema).readonly(), {
|
|
15764
16087
|
kind: "query",
|
|
15765
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"
|
|
15766
16095
|
}), method(object({
|
|
15767
16096
|
eventId: string(),
|
|
15768
16097
|
kind: MediaFileKindEnum.optional()
|
|
@@ -21423,6 +21752,173 @@ DeviceType.Camera, method(object({
|
|
|
21423
21752
|
status: OsdStatusSchema
|
|
21424
21753
|
});
|
|
21425
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
|
+
/**
|
|
21426
21922
|
* Feeder connectivity / power status — mirrors the HA petkit device-status
|
|
21427
21923
|
* enum: `normal` (online, mains), `offline` (not reaching PetKit cloud),
|
|
21428
21924
|
* `on_batteries` (running on battery backup). `null` until first reported.
|
|
@@ -26378,6 +26874,48 @@ Object.freeze({
|
|
|
26378
26874
|
addonId: null,
|
|
26379
26875
|
access: "create"
|
|
26380
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
|
+
},
|
|
26381
26919
|
"petFeeder.callPet": {
|
|
26382
26920
|
capName: "pet-feeder",
|
|
26383
26921
|
capScope: "device",
|
|
@@ -26540,6 +27078,18 @@ Object.freeze({
|
|
|
26540
27078
|
addonId: null,
|
|
26541
27079
|
access: "view"
|
|
26542
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
|
+
},
|
|
26543
27093
|
"pipelineAnalytics.listEventKinds": {
|
|
26544
27094
|
capName: "pipeline-analytics",
|
|
26545
27095
|
capScope: "device",
|
|
@@ -28016,6 +28566,12 @@ Object.freeze({
|
|
|
28016
28566
|
addonId: null,
|
|
28017
28567
|
access: "view"
|
|
28018
28568
|
},
|
|
28569
|
+
"streamBroker.getDeviceAudioMute": {
|
|
28570
|
+
capName: "stream-broker",
|
|
28571
|
+
capScope: "system",
|
|
28572
|
+
addonId: null,
|
|
28573
|
+
access: "view"
|
|
28574
|
+
},
|
|
28019
28575
|
"streamBroker.getPreBufferInfo": {
|
|
28020
28576
|
capName: "stream-broker",
|
|
28021
28577
|
capScope: "system",
|
|
@@ -28136,6 +28692,12 @@ Object.freeze({
|
|
|
28136
28692
|
addonId: null,
|
|
28137
28693
|
access: "create"
|
|
28138
28694
|
},
|
|
28695
|
+
"streamBroker.setDeviceAudioMute": {
|
|
28696
|
+
capName: "stream-broker",
|
|
28697
|
+
capScope: "system",
|
|
28698
|
+
addonId: null,
|
|
28699
|
+
access: "create"
|
|
28700
|
+
},
|
|
28139
28701
|
"streamBroker.setPreBufferDuration": {
|
|
28140
28702
|
capName: "stream-broker",
|
|
28141
28703
|
capScope: "system",
|
|
@@ -29004,6 +29566,506 @@ function resolveExportFingerprint(input) {
|
|
|
29004
29566
|
if (input.ready) return input.fresh;
|
|
29005
29567
|
return input.persisted ?? input.fresh;
|
|
29006
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
|
+
};
|
|
29007
30069
|
//#endregion
|
|
29008
30070
|
//#region src/accessory-publisher.ts
|
|
29009
30071
|
/**
|
|
@@ -42660,9 +43722,18 @@ async function buildIntercom(input) {
|
|
|
42660
43722
|
* (`proxy.motion.isDetected({})`) when the motion cap is bound.
|
|
42661
43723
|
*/
|
|
42662
43724
|
var RESET_DEBOUNCE_MS = 5e3;
|
|
42663
|
-
|
|
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) {
|
|
42664
43735
|
const { ctx, accessory, proxy, numericDeviceId, displayName } = bctx;
|
|
42665
|
-
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}`));
|
|
42666
43737
|
motionService.setCharacteristic(_homebridge_hap_nodejs.Characteristic.MotionDetected, false);
|
|
42667
43738
|
try {
|
|
42668
43739
|
const detected = await proxy.motion?.isDetected({});
|
|
@@ -43058,6 +44129,814 @@ async function probe(call, label, log) {
|
|
|
43058
44129
|
}
|
|
43059
44130
|
}
|
|
43060
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
|
|
43061
44940
|
//#region src/mappers/builders/child-switch.ts
|
|
43062
44941
|
/**
|
|
43063
44942
|
* Child-switch builder — turns a camstack accessory child device (siren,
|
|
@@ -43263,19 +45142,39 @@ async function buildCameraAccessory(input) {
|
|
|
43263
45142
|
displayName,
|
|
43264
45143
|
options
|
|
43265
45144
|
};
|
|
43266
|
-
const
|
|
45145
|
+
const advertisedVideo = await probeAdvertisedVideoProfile(bctx);
|
|
45146
|
+
const streams = buildCameraStreamingDelegate(bctx, advertisedVideo);
|
|
43267
45147
|
const handles = [];
|
|
43268
45148
|
if (capNames.has("intercom")) handles.push(await buildIntercom({
|
|
43269
45149
|
bctx,
|
|
43270
45150
|
streamingOptions: streams.streamingOptions
|
|
43271
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;
|
|
43272
45159
|
const controller = new (isDoorbell ? _homebridge_hap_nodejs.DoorbellController : _homebridge_hap_nodejs.CameraController)({
|
|
43273
45160
|
delegate: streams.delegate,
|
|
43274
45161
|
streamingOptions: streams.streamingOptions,
|
|
43275
|
-
cameraStreamCount: 2
|
|
45162
|
+
cameraStreamCount: 2,
|
|
45163
|
+
...recording === null ? {} : { recording },
|
|
45164
|
+
...recording === null || !capNames.has("motion-detection") ? {} : { sensors: { motion: true } }
|
|
43276
45165
|
});
|
|
43277
45166
|
accessory.configureController(controller);
|
|
43278
|
-
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));
|
|
43279
45178
|
if (isDoorbell && controller instanceof _homebridge_hap_nodejs.DoorbellController) handles.push(await buildDoorbell({
|
|
43280
45179
|
bctx,
|
|
43281
45180
|
controller
|
|
@@ -43520,7 +45419,10 @@ function syncStateToJson(map) {
|
|
|
43520
45419
|
* intercom upload bridge, HomeKit Secure Video, recording, native
|
|
43521
45420
|
* H.264 stream tap (currently uses RTSP + ffmpeg copy).
|
|
43522
45421
|
*/
|
|
43523
|
-
var DEFAULT_DEVICE_SETTINGS = {
|
|
45422
|
+
var DEFAULT_DEVICE_SETTINGS = {
|
|
45423
|
+
streamPreference: "auto",
|
|
45424
|
+
hksvRecording: false
|
|
45425
|
+
};
|
|
43524
45426
|
var HAP_STREAM_PREFERENCE_OPTIONS = [
|
|
43525
45427
|
{
|
|
43526
45428
|
value: "auto",
|
|
@@ -43795,7 +45697,10 @@ var ExportHapAddon = class extends BaseAddon {
|
|
|
43795
45697
|
options: {
|
|
43796
45698
|
ptzPulseMs: this.config.ptzPulseMs,
|
|
43797
45699
|
decodeMemos: this.decodeMemos,
|
|
43798
|
-
hapDeviceSettings: {
|
|
45700
|
+
hapDeviceSettings: {
|
|
45701
|
+
streamPreference: entrySettings.streamPreference ?? "auto",
|
|
45702
|
+
hksvRecording: entrySettings.hksvRecording === true
|
|
45703
|
+
}
|
|
43799
45704
|
}
|
|
43800
45705
|
});
|
|
43801
45706
|
for (const accessory of mapper.accessories) await publishStandalone(accessory, {
|
|
@@ -44070,6 +45975,7 @@ var ExportHapAddon = class extends BaseAddon {
|
|
|
44070
45975
|
const enabled = entry !== null;
|
|
44071
45976
|
const enabledKey = `hap:${deviceId}:enabled`;
|
|
44072
45977
|
const streamPreferenceKey = `hap:${deviceId}:streamPreference`;
|
|
45978
|
+
const hksvKey = `hap:${deviceId}:hksvRecording`;
|
|
44073
45979
|
const mapper = this.exposed.get(String(deviceId)) ?? null;
|
|
44074
45980
|
const paired = mapper ? accessoryPaired(mapper.accessory) : false;
|
|
44075
45981
|
const name = entry?.displayName ?? `Device ${deviceId}`;
|
|
@@ -44137,6 +46043,19 @@ var ExportHapAddon = class extends BaseAddon {
|
|
|
44137
46043
|
equals: true
|
|
44138
46044
|
},
|
|
44139
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
|
|
44140
46059
|
}
|
|
44141
46060
|
]
|
|
44142
46061
|
}]
|
|
@@ -44161,12 +46080,15 @@ var ExportHapAddon = class extends BaseAddon {
|
|
|
44161
46080
|
const wasEnabled = this.exposed.has(deviceIdStr);
|
|
44162
46081
|
const enabledKey = `hap:${deviceId}:enabled`;
|
|
44163
46082
|
const streamPreferenceKey = `hap:${deviceId}:streamPreference`;
|
|
46083
|
+
const hksvKey = `hap:${deviceId}:hksvRecording`;
|
|
44164
46084
|
const enabledValue = enabledKey in patch ? Boolean(patch[enabledKey]) : wasEnabled;
|
|
44165
46085
|
const streamPreferenceRaw = streamPreferenceKey in patch ? patch[streamPreferenceKey] : current?.settings?.streamPreference;
|
|
44166
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;
|
|
44167
46088
|
const nextSettings = {
|
|
44168
46089
|
...current?.settings ?? DEFAULT_DEVICE_SETTINGS,
|
|
44169
|
-
streamPreference
|
|
46090
|
+
streamPreference,
|
|
46091
|
+
hksvRecording
|
|
44170
46092
|
};
|
|
44171
46093
|
if (!enabledValue) {
|
|
44172
46094
|
if (wasEnabled) await this.unexposeDevice(deviceIdStr);
|
|
@@ -44178,17 +46100,24 @@ var ExportHapAddon = class extends BaseAddon {
|
|
|
44178
46100
|
return { success: true };
|
|
44179
46101
|
}
|
|
44180
46102
|
const currentPref = current?.settings?.streamPreference ?? "auto";
|
|
46103
|
+
const currentHksv = current?.settings?.hksvRecording === true;
|
|
44181
46104
|
await this.updateEntrySettings(deviceIdStr, nextSettings);
|
|
44182
|
-
if (currentPref !== streamPreference) {
|
|
44183
|
-
log.info("export-hap:
|
|
44184
|
-
|
|
44185
|
-
|
|
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
|
+
}
|
|
44186
46115
|
} });
|
|
44187
46116
|
try {
|
|
44188
46117
|
await this.unexposeDevice(deviceIdStr, { clearPairing: false });
|
|
44189
46118
|
await this.exposeDevice(deviceIdStr);
|
|
44190
46119
|
} catch (err) {
|
|
44191
|
-
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) } });
|
|
44192
46121
|
}
|
|
44193
46122
|
}
|
|
44194
46123
|
return { success: true };
|