@camstack/addon-export-hap 1.2.14 → 1.2.16

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,9 +1,9 @@
1
1
  import { createRequire } from "node:module";
2
- import { createHash, randomBytes } from "node:crypto";
2
+ import { createHash, randomBytes, randomUUID } from "node:crypto";
3
3
  import * as path from "node:path";
4
4
  import { readFileSync } from "node:fs";
5
5
  import { spawn } from "node:child_process";
6
- import { Accessory, AudioBitrate, AudioStreamingCodecType, AudioStreamingSamplerate, CameraController, Categories, Characteristic, DoorbellController, H264Level, H264Profile, HAPStorage, SRTPCryptoSuites, Service, uuid } from "@homebridge/hap-nodejs";
6
+ import { Accessory, AudioBitrate, AudioRecordingCodecType, AudioRecordingSamplerate, AudioStreamingCodecType, AudioStreamingSamplerate, CameraController, Categories, Characteristic, DoorbellController, H264Level, H264Profile, HAPStorage, HDSProtocolError, HDSProtocolSpecificErrorReason, MediaContainerType, SRTPCryptoSuites, Service, VideoCodecType, uuid } from "@homebridge/hap-nodejs";
7
7
  import * as fs from "node:fs/promises";
8
8
  import { createSocket } from "node:dgram";
9
9
  import { networkInterfaces } from "node:os";
@@ -6510,7 +6510,20 @@ var BrokerStatsSchema = object({
6510
6510
  sampleRate: number(),
6511
6511
  channels: number(),
6512
6512
  supported: boolean()
6513
- }).nullable().optional()
6513
+ }).nullable().optional(),
6514
+ /**
6515
+ * BROKER-SIDE AUDIO MUTE (D83). `true` = this broker is deliberately
6516
+ * distributing none of the device's audio, on live or recording.
6517
+ *
6518
+ * Present so a silent camera can be told apart from a broken one on the
6519
+ * stream panel itself, without cross-referencing the switch group: a
6520
+ * broker holding an `audio` track descriptor while `audioMuted` is true is
6521
+ * working exactly as asked. `audioMutedDropped` counts the audio units
6522
+ * thrown away since the current dial — it is how you confirm from stats
6523
+ * alone that the mute is on the packet path and not merely persisted.
6524
+ */
6525
+ audioMuted: boolean().optional(),
6526
+ audioMutedDropped: number().optional()
6514
6527
  });
6515
6528
  /**
6516
6529
  * Exporter-facing "profile restream" entry. Returned by
@@ -7041,104 +7054,7 @@ method(object({ deviceId: number() }), array(StreamSourceEntrySchema)), method(o
7041
7054
  input: unknown()
7042
7055
  }), unknown(), { kind: "mutation" }), method(object({ deviceId: number() }), _void(), { kind: "mutation" }), method(object({ deviceId: number() }), unknown().nullable()), method(object({ deviceId: number() }), RawStateResultSchema.nullable(), { auth: "protected" });
7043
7056
  //#endregion
7044
- //#region ../types/dist/canonical-hash-7nfBbEqR.mjs
7045
- /**
7046
- * Deterministic SHA-256 hash of an arbitrary serialisable value. The
7047
- * canonical form sorts object keys alphabetically at every depth so two
7048
- * structurally-equal inputs with different key insertion orders produce
7049
- * the same hash. Returns a 64-char lowercase hex digest.
7050
- *
7051
- * Used by export adapters (Alexa, HAP) to short-circuit re-discovery /
7052
- * accessory-rebuild work when the upstream shape is byte-identical to
7053
- * the last applied state — preventing user-visible "re-discovery"
7054
- * notifications on every addon-runner respawn. Each respawn re-fires
7055
- * `DeviceBindingsChanged` for every cap registration, which without
7056
- * this guard would propagate redundant pushes.
7057
- *
7058
- * Note: this is a SYMPTOMATIC fix layered on top of the binding-change
7059
- * subscription. The proper fix is a single "device ready" lifecycle
7060
- * barrier so exports react only when the full cap set has landed —
7061
- * tracked separately for post-HA-integration work.
7062
- */
7063
- function canonicalHash(value) {
7064
- const canonical = JSON.stringify(value, replaceWithSortedKeys);
7065
- return createHash("sha256").update(canonical ?? "").digest("hex");
7066
- }
7067
- function replaceWithSortedKeys(_key, value) {
7068
- if (value && typeof value === "object" && !Array.isArray(value)) {
7069
- const obj = value;
7070
- const out = {};
7071
- for (const k of Object.keys(obj).toSorted()) out[k] = obj[k];
7072
- return out;
7073
- }
7074
- return value;
7075
- }
7076
- var EncodeProfileSchema = object({
7077
- video: object({
7078
- codec: _enum([
7079
- "h264",
7080
- "h265",
7081
- "copy"
7082
- ]),
7083
- profile: _enum([
7084
- "baseline",
7085
- "main",
7086
- "high"
7087
- ]).optional(),
7088
- /**
7089
- * `-level`, e.g. `'3.1'`. A consumer that ADVERTISES a level in its SDP
7090
- * (`profile-level-id=42e01f` is Baseline 3.1) must constrain the encoder to
7091
- * it, or it ships a stream that does not match its own advertisement — the
7092
- * defect class that kept HomeKit black for a year and that Alexa carried
7093
- * silently. Optional because a browser negotiates the level itself.
7094
- */
7095
- level: string().optional(),
7096
- width: number().int().positive().optional(),
7097
- height: number().int().positive().optional(),
7098
- fps: number().positive().optional(),
7099
- bitrateKbps: number().int().positive().optional(),
7100
- gopFrames: number().int().positive().optional(),
7101
- bf: number().int().min(0).optional(),
7102
- preset: _enum([
7103
- "ultrafast",
7104
- "superfast",
7105
- "veryfast",
7106
- "faster",
7107
- "fast",
7108
- "medium"
7109
- ]).optional(),
7110
- tune: _enum([
7111
- "zerolatency",
7112
- "film",
7113
- "animation"
7114
- ]).optional()
7115
- }),
7116
- audio: union([literal("passthrough"), object({
7117
- codec: _enum([
7118
- "opus",
7119
- "aac",
7120
- "pcmu",
7121
- "pcma",
7122
- "copy"
7123
- ]),
7124
- bitrateKbps: number().int().positive().optional(),
7125
- sampleRateHz: number().int().positive().optional(),
7126
- channels: union([literal(1), literal(2)]).optional()
7127
- })]),
7128
- /**
7129
- * ffmpeg input-side args, inserted between the fixed global flags
7130
- * (`-hide_banner -loglevel error`) and `-i pipe:0`. Free-text array
7131
- * — the widget surfaces a textarea + suggestion chips for the most-
7132
- * used demuxer/format options.
7133
- */
7134
- inputArgs: array(string()).optional(),
7135
- /**
7136
- * ffmpeg output-side args, inserted between the encode block and
7137
- * the final `-f <muxer> pipe:1`. Use for muxer options, bitstream
7138
- * filters, codec-specific overrides. Free-text array.
7139
- */
7140
- outputArgs: array(string()).optional()
7141
- });
7057
+ //#region ../types/dist/fmp4-box-splitter-B53u9-Nu.mjs
7142
7058
  var AUDIO_ENCODER_BY_CODEC = {
7143
7059
  opus: "libopus",
7144
7060
  aac: "aac",
@@ -7319,13 +7235,42 @@ function buildStdoutOrRtspSinkArgs(sink) {
7319
7235
  "pipe:1"
7320
7236
  ];
7321
7237
  }
7238
+ /**
7239
+ * How far BELOW the negotiated fragment length `-min_frag_duration` is set.
7240
+ *
7241
+ * `-min_frag_duration` refuses to cut before that much media has accumulated,
7242
+ * and then waits for the next key frame. Set to exactly `fragmentMs`, the
7243
+ * commonest camera configuration in existence — a key-frame grid EQUAL to the
7244
+ * requested fragment length — lands the deadline on the same instant as the key
7245
+ * frame, loses the race, and skips to the following one: **every fragment comes
7246
+ * out at twice the requested length.**
7247
+ *
7248
+ * Measured on the live fleet 2026-08-07, camera 615, `-c:v copy` (D84):
7249
+ *
7250
+ * | slot | GOP | `-min_frag_duration` | median gap |
7251
+ * | --- | --- | --- | --- |
7252
+ * | 1280×720 | 40 f @ 10 fps = 4.0 s | 4000 ms | **7944 ms** |
7253
+ * | 1280×720 | 40 f @ 10 fps = 4.0 s | 3600 ms | 3973 ms |
7254
+ * | 3840×2160 | 100 f @ 25 fps = 4.0 s | 4000 ms | 8042 ms |
7255
+ * | 3840×2160 | 100 f @ 25 fps = 4.0 s | 3600 ms | 3998 ms |
7256
+ *
7257
+ * A doubled fragment is not a cosmetic overshoot: HKSV requires every fragment
7258
+ * to be no longer than the length the controller SELECTED, so the shipped-but-
7259
+ * inert phase-1 sink would have violated the contract on its first real clip.
7260
+ *
7261
+ * 10 % is chosen against the two failures either side of it. Too small and
7262
+ * ordinary jitter (measured spread 3953-4096 ms) re-loses the race; too large
7263
+ * and a source with a key frame slightly EARLY than the grid gets cut there,
7264
+ * yielding a short fragment for no reason.
7265
+ */
7266
+ var FMP4_MIN_FRAG_MARGIN = .9;
7322
7267
  /** `-movflags … -min_frag_duration <us> -f mp4 pipe:1`. */
7323
7268
  function buildFmp4SinkArgs(sink) {
7324
7269
  return [
7325
7270
  "-movflags",
7326
7271
  FMP4_MOVFLAGS,
7327
7272
  "-min_frag_duration",
7328
- String(Math.max(0, Math.round(sink.fragmentMs * 1e3))),
7273
+ String(Math.max(0, Math.round(sink.fragmentMs * FMP4_MIN_FRAG_MARGIN * 1e3))),
7329
7274
  "-f",
7330
7275
  "mp4",
7331
7276
  "pipe:1"
@@ -7404,6 +7349,287 @@ function buildFfmpegArgs(inv) {
7404
7349
  ];
7405
7350
  }
7406
7351
  /**
7352
+ * Deterministic SHA-256 hash of an arbitrary serialisable value. The
7353
+ * canonical form sorts object keys alphabetically at every depth so two
7354
+ * structurally-equal inputs with different key insertion orders produce
7355
+ * the same hash. Returns a 64-char lowercase hex digest.
7356
+ *
7357
+ * Used by export adapters (Alexa, HAP) to short-circuit re-discovery /
7358
+ * accessory-rebuild work when the upstream shape is byte-identical to
7359
+ * the last applied state — preventing user-visible "re-discovery"
7360
+ * notifications on every addon-runner respawn. Each respawn re-fires
7361
+ * `DeviceBindingsChanged` for every cap registration, which without
7362
+ * this guard would propagate redundant pushes.
7363
+ *
7364
+ * Note: this is a SYMPTOMATIC fix layered on top of the binding-change
7365
+ * subscription. The proper fix is a single "device ready" lifecycle
7366
+ * barrier so exports react only when the full cap set has landed —
7367
+ * tracked separately for post-HA-integration work.
7368
+ */
7369
+ function canonicalHash(value) {
7370
+ const canonical = JSON.stringify(value, replaceWithSortedKeys);
7371
+ return createHash("sha256").update(canonical ?? "").digest("hex");
7372
+ }
7373
+ function replaceWithSortedKeys(_key, value) {
7374
+ if (value && typeof value === "object" && !Array.isArray(value)) {
7375
+ const obj = value;
7376
+ const out = {};
7377
+ for (const k of Object.keys(obj).toSorted()) out[k] = obj[k];
7378
+ return out;
7379
+ }
7380
+ return value;
7381
+ }
7382
+ var DEFAULT_MAX_UNIT_BYTES = 16 * 1024 * 1024;
7383
+ /** Header size for a normal box, and for one carrying a 64-bit `largesize`. */
7384
+ var BOX_HEADER_BYTES = 8;
7385
+ var LARGE_BOX_HEADER_BYTES = 16;
7386
+ var Fmp4BoxSplitter = class {
7387
+ maxUnitBytes;
7388
+ /** Bytes of the CURRENT unit plus any partial box after it. */
7389
+ buffer = new Uint8Array(0);
7390
+ /** Where the current unit starts inside {@link buffer}. */
7391
+ unitStart = 0;
7392
+ /** Where the box scanner has reached inside {@link buffer}. */
7393
+ cursor = 0;
7394
+ state = "init";
7395
+ nextSequence = 0;
7396
+ faultReason = null;
7397
+ interstitial = /* @__PURE__ */ new Set();
7398
+ constructor(options = {}) {
7399
+ this.maxUnitBytes = options.maxUnitBytes ?? DEFAULT_MAX_UNIT_BYTES;
7400
+ }
7401
+ /**
7402
+ * Non-null once the stream cannot be split. The splitter emits nothing
7403
+ * further, so a caller polls this to kill the child rather than watching a
7404
+ * silent stall — a fragmenter that quietly stops producing looks exactly like
7405
+ * a camera with no motion.
7406
+ */
7407
+ get fault() {
7408
+ return this.faultReason;
7409
+ }
7410
+ /** Bytes currently held. The memory bound, observable rather than asserted. */
7411
+ get pendingBytes() {
7412
+ return this.buffer.length - this.unitStart;
7413
+ }
7414
+ /**
7415
+ * Top-level box types seen BETWEEN fragments and discarded — `mfra`, `free`,
7416
+ * a stray `sidx`. Reported rather than dropped in silence: they are legal and
7417
+ * useless to a fragment consumer, but a type nobody expected showing up here
7418
+ * is the first symptom of a muxer that is not writing what we think it is.
7419
+ */
7420
+ get discardedInterstitialTypes() {
7421
+ return [...this.interstitial];
7422
+ }
7423
+ /**
7424
+ * Feed bytes; get back whatever units completed. Returns `[]` once faulted.
7425
+ */
7426
+ push(chunk) {
7427
+ if (this.faultReason !== null || chunk.length === 0) return [];
7428
+ this.append(chunk);
7429
+ if (this.pendingBytes > this.maxUnitBytes) return this.fail(`a single fMP4 unit exceeded ${this.maxUnitBytes} bytes — this stream is not fragmented`);
7430
+ return this.drainBoxes();
7431
+ }
7432
+ append(chunk) {
7433
+ if (this.buffer.length === 0) {
7434
+ this.buffer = chunk.slice();
7435
+ return;
7436
+ }
7437
+ const next = new Uint8Array(this.buffer.length + chunk.length);
7438
+ next.set(this.buffer, 0);
7439
+ next.set(chunk, this.buffer.length);
7440
+ this.buffer = next;
7441
+ }
7442
+ /** Consume every COMPLETE top-level box now in the buffer. */
7443
+ drainBoxes() {
7444
+ const units = [];
7445
+ for (;;) {
7446
+ const header = this.readHeader();
7447
+ if (this.faultReason !== null) return units;
7448
+ if (header === null) break;
7449
+ if (this.cursor + header.totalBytes > this.buffer.length) break;
7450
+ const boxStart = this.cursor;
7451
+ const boxEnd = boxStart + header.totalBytes;
7452
+ this.cursor = boxEnd;
7453
+ const unit = this.consumeBox(header.type, boxStart, boxEnd);
7454
+ if (this.faultReason !== null) return units;
7455
+ if (unit !== null) units.push(unit);
7456
+ }
7457
+ this.compact();
7458
+ return units;
7459
+ }
7460
+ /**
7461
+ * Apply one box to the state machine. Returns a unit when this box CLOSED
7462
+ * one, `null` otherwise.
7463
+ */
7464
+ consumeBox(type, boxStart, boxEnd) {
7465
+ if (this.state === "init") {
7466
+ if (type !== "moof") return null;
7467
+ if (boxStart === this.unitStart) {
7468
+ this.fail("a moof arrived before any initialisation box — there is no ftyp/moov to send");
7469
+ return null;
7470
+ }
7471
+ const init = this.emit("init", this.unitStart, boxStart);
7472
+ this.unitStart = boxStart;
7473
+ this.state = "fragment";
7474
+ return init;
7475
+ }
7476
+ if (this.state === "idle") {
7477
+ if (type !== "moof") {
7478
+ this.interstitial.add(type);
7479
+ this.unitStart = boxEnd;
7480
+ return null;
7481
+ }
7482
+ this.unitStart = boxStart;
7483
+ this.state = "fragment";
7484
+ return null;
7485
+ }
7486
+ if (type !== "mdat") return null;
7487
+ const fragment = this.emit("fragment", this.unitStart, boxEnd);
7488
+ this.unitStart = boxEnd;
7489
+ this.state = "idle";
7490
+ return fragment;
7491
+ }
7492
+ /**
7493
+ * Parse the header at {@link cursor}, or `null` when too few bytes have
7494
+ * arrived to know. Faults on a size the splitter cannot honour.
7495
+ */
7496
+ readHeader() {
7497
+ const available = this.buffer.length - this.cursor;
7498
+ if (available < BOX_HEADER_BYTES) return null;
7499
+ const view = new DataView(this.buffer.buffer, this.buffer.byteOffset, this.buffer.byteLength);
7500
+ const size = view.getUint32(this.cursor);
7501
+ 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);
7502
+ if (size === 0) {
7503
+ this.fail(`box "${type}" declares size 0 (to EOF) — an unbounded box cannot be fragmented`);
7504
+ return null;
7505
+ }
7506
+ if (size === 1) {
7507
+ if (available < LARGE_BOX_HEADER_BYTES) return null;
7508
+ const large = view.getBigUint64(this.cursor + BOX_HEADER_BYTES);
7509
+ if (large > BigInt(this.maxUnitBytes)) {
7510
+ this.fail(`box "${type}" declares ${large} bytes, over the ${this.maxUnitBytes} byte bound`);
7511
+ return null;
7512
+ }
7513
+ return {
7514
+ type,
7515
+ totalBytes: Number(large)
7516
+ };
7517
+ }
7518
+ if (size < BOX_HEADER_BYTES) {
7519
+ this.fail(`box "${type}" declares an impossible size of ${size} bytes`);
7520
+ return null;
7521
+ }
7522
+ return {
7523
+ type,
7524
+ totalBytes: size
7525
+ };
7526
+ }
7527
+ emit(kind, start, end) {
7528
+ const sequence = this.nextSequence;
7529
+ this.nextSequence += 1;
7530
+ return {
7531
+ kind,
7532
+ data: this.buffer.slice(start, end),
7533
+ sequence
7534
+ };
7535
+ }
7536
+ /**
7537
+ * Drop everything already emitted or discarded. Without this the buffer is
7538
+ * the whole stream and the process dies in hours, not minutes.
7539
+ */
7540
+ compact() {
7541
+ if (this.unitStart === 0) return;
7542
+ this.buffer = this.buffer.slice(this.unitStart);
7543
+ this.cursor -= this.unitStart;
7544
+ this.unitStart = 0;
7545
+ }
7546
+ fail(reason) {
7547
+ this.faultReason = reason;
7548
+ this.buffer = new Uint8Array(0);
7549
+ this.unitStart = 0;
7550
+ this.cursor = 0;
7551
+ return [];
7552
+ }
7553
+ };
7554
+ //#endregion
7555
+ //#region ../types/dist/err-msg-IQTHeDzc.mjs
7556
+ /**
7557
+ import { errMsg } from '@camstack/types'
7558
+ * Extract a human-readable message from an unknown error value.
7559
+ * Replaces the ubiquitous `errMsg(err)` pattern.
7560
+ */
7561
+ function errMsg$12(err) {
7562
+ if (err instanceof Error) return err.message;
7563
+ if (typeof err === "string") return err;
7564
+ return String(err);
7565
+ }
7566
+ var EncodeProfileSchema = object({
7567
+ video: object({
7568
+ codec: _enum([
7569
+ "h264",
7570
+ "h265",
7571
+ "copy"
7572
+ ]),
7573
+ profile: _enum([
7574
+ "baseline",
7575
+ "main",
7576
+ "high"
7577
+ ]).optional(),
7578
+ /**
7579
+ * `-level`, e.g. `'3.1'`. A consumer that ADVERTISES a level in its SDP
7580
+ * (`profile-level-id=42e01f` is Baseline 3.1) must constrain the encoder to
7581
+ * it, or it ships a stream that does not match its own advertisement — the
7582
+ * defect class that kept HomeKit black for a year and that Alexa carried
7583
+ * silently. Optional because a browser negotiates the level itself.
7584
+ */
7585
+ level: string().optional(),
7586
+ width: number().int().positive().optional(),
7587
+ height: number().int().positive().optional(),
7588
+ fps: number().positive().optional(),
7589
+ bitrateKbps: number().int().positive().optional(),
7590
+ gopFrames: number().int().positive().optional(),
7591
+ bf: number().int().min(0).optional(),
7592
+ preset: _enum([
7593
+ "ultrafast",
7594
+ "superfast",
7595
+ "veryfast",
7596
+ "faster",
7597
+ "fast",
7598
+ "medium"
7599
+ ]).optional(),
7600
+ tune: _enum([
7601
+ "zerolatency",
7602
+ "film",
7603
+ "animation"
7604
+ ]).optional()
7605
+ }),
7606
+ audio: union([literal("passthrough"), object({
7607
+ codec: _enum([
7608
+ "opus",
7609
+ "aac",
7610
+ "pcmu",
7611
+ "pcma",
7612
+ "copy"
7613
+ ]),
7614
+ bitrateKbps: number().int().positive().optional(),
7615
+ sampleRateHz: number().int().positive().optional(),
7616
+ channels: union([literal(1), literal(2)]).optional()
7617
+ })]),
7618
+ /**
7619
+ * ffmpeg input-side args, inserted between the fixed global flags
7620
+ * (`-hide_banner -loglevel error`) and `-i pipe:0`. Free-text array
7621
+ * — the widget surfaces a textarea + suggestion chips for the most-
7622
+ * used demuxer/format options.
7623
+ */
7624
+ inputArgs: array(string()).optional(),
7625
+ /**
7626
+ * ffmpeg output-side args, inserted between the encode block and
7627
+ * the final `-f <muxer> pipe:1`. Use for muxer options, bitstream
7628
+ * filters, codec-specific overrides. Free-text array.
7629
+ */
7630
+ outputArgs: array(string()).optional()
7631
+ });
7632
+ /**
7407
7633
  * The shape every live egress starts from: H.264 Baseline 3.1 at 720p25.
7408
7634
  * Baseline because it is the one profile every consumer in this repo decodes
7409
7635
  * (Echo, iOS, an old browser); 3.1 because that is what the SDPs advertise.
@@ -7523,6 +7749,19 @@ object({
7523
7749
  * | `notifications` | `notificationRules.setDeviceMuted` | `NotificationCenter.evaluateAndEnqueue` returns before any rule is evaluated |
7524
7750
  * | `privacy-mask` | `privacyMask.setMask({ enabled })` → the CAMERA | the camera blanks the masked regions itself; every stream and recording carries the black boxes |
7525
7751
  * | `device-audio` | `privacyMask.setAudioEnabled` → the CAMERA | the camera stops encoding an audio track at all; every consumer sees silent video |
7752
+ * | `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 |
7753
+ *
7754
+ * ## `device-audio` and `broker-audio` are two functions, not two knobs
7755
+ *
7756
+ * They look adjacent and they are not the same control ([D83](../../../../docs/decisions/adr-0083.md)):
7757
+ * `device-audio` writes the CAMERA, so it is hardware privacy — the microphone
7758
+ * genuinely stops, it survives CamStack entirely, and it costs a multi-second
7759
+ * encoder restart on every flip. `broker-audio` writes THIS server, so it is
7760
+ * instant, vendor-independent and reversible without touching the camera, and
7761
+ * a camera that ignores or lacks the ISAPI/Reolink control is still silenced.
7762
+ * D62 forbids a second switch that *disagrees* with the first; these two
7763
+ * cannot disagree, because neither reads the other's store — the camera holds
7764
+ * one, the broker holds the other, and each reports its own fact.
7526
7765
  *
7527
7766
  * ## The two switches whose authority is not on this server
7528
7767
  *
@@ -7584,6 +7823,7 @@ var CameraSwitchIdSchema = _enum([
7584
7823
  "object-detection",
7585
7824
  "privacy-mask",
7586
7825
  "device-audio",
7826
+ "broker-audio",
7587
7827
  "audio-analysis",
7588
7828
  "recording",
7589
7829
  "notifications"
@@ -7609,7 +7849,8 @@ var CameraSwitchAuthoritySchema = discriminatedUnion("kind", [
7609
7849
  object({
7610
7850
  kind: literal("camera-mask"),
7611
7851
  capName: string()
7612
- })
7852
+ }),
7853
+ object({ kind: literal("broker-audio-mute") })
7613
7854
  ]);
7614
7855
  /**
7615
7856
  * Why a switch is not offered for this camera. Rendered instead of the
@@ -9860,7 +10101,25 @@ method(object({
9860
10101
  }), _void(), {
9861
10102
  kind: "mutation",
9862
10103
  auth: "admin"
9863
- }), method(object({ brokerId: string() }), boolean()), object({
10104
+ }), method(object({ brokerId: string() }), boolean()), method(object({ deviceId: number().int() }), object({
10105
+ muted: boolean(),
10106
+ /**
10107
+ * How many live non-derived brokers currently hold the mute. Purely
10108
+ * diagnostic: `muted` is the policy and is authoritative on its own
10109
+ * (it applies to brokers that do not exist yet), while this says
10110
+ * whether anything is presently being silenced.
10111
+ */
10112
+ appliedBrokers: number().int().nonnegative()
10113
+ })), method(object({
10114
+ deviceId: number().int(),
10115
+ muted: boolean()
10116
+ }), object({
10117
+ muted: boolean(),
10118
+ appliedBrokers: number().int().nonnegative()
10119
+ }), {
10120
+ kind: "mutation",
10121
+ auth: "admin"
10122
+ }), object({
9864
10123
  deviceId: number().int().nonnegative(),
9865
10124
  camStreamId: string(),
9866
10125
  profile: CamProfileSchema
@@ -15183,6 +15442,30 @@ var TrackSourceSchema = _enum([
15183
15442
  "audio"
15184
15443
  ]);
15185
15444
  /**
15445
+ * Where a track sits in the RETRAIN lifecycle (D81).
15446
+ *
15447
+ * - `none` — never marked, or un-marked. Evictable.
15448
+ * - `staging` — the operator wants this track as training material and has not
15449
+ * finished with it. **This is the only state retention holds**: the track and
15450
+ * everything it owns (object events, crops, keyframes, CLIP vector) survive
15451
+ * the device's age window.
15452
+ * - `trained` — the retrain page has taken what it needed. The frames it chose
15453
+ * were COPIED into the retrain dataset at selection time, so the dataset no
15454
+ * longer depends on the track's media and the track becomes EVICTABLE again.
15455
+ * Terminal for the plain `markForTrain` toggle: returning it to `staging` is
15456
+ * a deliberate action of the retrain page, not a side effect of a checkbox.
15457
+ *
15458
+ * There is no `null`. The state is stored `TEXT NOT NULL DEFAULT 'none'` because
15459
+ * the store's filter language has only positive equality and `whereIn` — no
15460
+ * negation, no IS NULL — so a NULL would be unselectable by ANY predicate and
15461
+ * would make the entire pre-column history immortal in one deploy.
15462
+ */
15463
+ var RetrainStatusSchema = _enum([
15464
+ "none",
15465
+ "staging",
15466
+ "trained"
15467
+ ]);
15468
+ /**
15186
15469
  * Per-track OPERATOR flags — set by hand from the admin UI or the viewer, never
15187
15470
  * by the pipeline. Spread into `TrackSchema` and `KeyEventSchema` from one place
15188
15471
  * so the two surfaces cannot drift.
@@ -15192,18 +15475,31 @@ var TrackSourceSchema = _enum([
15192
15475
  * columns existed read as absent, and a consumer that needs a boolean should say
15193
15476
  * `flag === true`, not `flag !== false`.
15194
15477
  *
15195
- * What the flags DO is deliberately UNDEFINED at the time of writing: they are
15196
- * operator curation, and the behaviour they drive will be specified separately.
15197
- * In particular a `markForTrain` track is NOT pinned against retention — see
15198
- * `docs/decisions/adr-0059.md` for why that is a store-level change, not a flag.
15478
+ * `markForTrain` is the WIRE FACE of {@link RetrainStatusSchema}, not a column:
15479
+ * it is exactly `retrainStatus === 'staging'`, in both directions. Writing
15480
+ * `true` moves `none → staging`, writing `false` moves `staging none`, and a
15481
+ * `trained` track reports `false` while refusing both writes. The boolean is
15482
+ * kept because three surfaces drive a toggle off it; anything that needs to tell
15483
+ * "never marked" from "already trained" must read `retrainStatus`.
15484
+ *
15485
+ * `debug` does NOT pin; it is attention, not durability.
15199
15486
  */
15200
15487
  var TrackFlagFields = {
15201
- /** Operator marked this track as training material. */
15488
+ /** Operator marked this track as training material — i.e. `retrainStatus` is
15489
+ * `'staging'`. */
15202
15490
  markForTrain: boolean().optional(),
15203
15491
  /** Operator marked this track for diagnostic attention. */
15204
15492
  debug: boolean().optional()
15205
15493
  };
15206
15494
  /**
15495
+ * The lifecycle field itself, on the READ surfaces only (`Track`, `KeyEvent`).
15496
+ * Deliberately NOT part of {@link TrackFlagFields}: that group also builds the
15497
+ * write patch, and the status is not something the toggle sets — it is what the
15498
+ * toggle's boolean is derived from. Absent on an in-RAM track never touched;
15499
+ * always present on a persisted row (the column default materialises `'none'`).
15500
+ */
15501
+ var TrackRetrainFields = { retrainStatus: RetrainStatusSchema.optional() };
15502
+ /**
15207
15503
  * The write half: a PARTIAL patch. An omitted key is left untouched, so setting
15208
15504
  * one flag can never clear the other — the toggles are independent and are
15209
15505
  * driven from three surfaces that do not know about each other.
@@ -15217,13 +15513,92 @@ var TrackFlagsPatchSchema = object(TrackFlagFields);
15217
15513
  var TrackFlagsSchema = object({
15218
15514
  trackId: string(),
15219
15515
  markForTrain: boolean(),
15220
- debug: boolean()
15516
+ debug: boolean(),
15517
+ /** The lifecycle state the boolean was derived from. Required here (unlike on
15518
+ * a track row) because this shape is only ever produced by the write body,
15519
+ * which always knows it — and a surface that has just written needs to render
15520
+ * `trained` without a re-fetch. */
15521
+ retrainStatus: RetrainStatusSchema
15522
+ });
15523
+ union([literal(1), literal(2)]);
15524
+ /**
15525
+ * WHO decided a label, and when. Carried per tier so a value can be traced to
15526
+ * the step and model that produced it — which is what makes the write rule
15527
+ * arguable after the fact ("why is 592's label `dog` and not `Canis lupus`?")
15528
+ * and what lets a migrated, UNATTRIBUTED value be told apart from a real one.
15529
+ *
15530
+ * `stepId` is the pipeline step id (`animal-classifier`, `bird-classifier`,
15531
+ * `plate-ocr`, `face-embedding`, `object-detection`), or the sentinel
15532
+ * `migration:4g` for a value the 4g migration moved from the single-slot era —
15533
+ * that value has no provenance, and the write rule lets ANY properly-attributed
15534
+ * write of the same tier replace it regardless of score.
15535
+ */
15536
+ var LabelAttributionSchema = object({
15537
+ stepId: string(),
15538
+ modelId: string().optional(),
15539
+ decidedAt: number()
15540
+ });
15541
+ /**
15542
+ * The TIERED label model (roadmap 4g), spread into `TrackSchema` and
15543
+ * `ObjectEventSchema` from ONE place so the two surfaces cannot drift — a
15544
+ * track and its events always answer the same question the same way.
15545
+ *
15546
+ * Two scalar columns, not an array: every consumer wants "the coarse one" or
15547
+ * "the fine one", and an array made both a scan. `label` is tier 1, `subLabel`
15548
+ * is tier 2, and each carries its own score + attribution.
15549
+ *
15550
+ * **Reading it.** What a human should be shown is `subLabel ?? label` — the
15551
+ * finest thing known. Before 4g the single `label` column held the finest
15552
+ * value, so a consumer that has not been updated reads the tier-1 slot and
15553
+ * shows nothing on a species-only row; that is why the migration puts every
15554
+ * pre-4g value in tier 2 (it cannot regress a display that reads the fallback)
15555
+ * and why the read surfaces were changed in the same train.
15556
+ *
15557
+ * **Writing it.** The slots are independent, which is the whole point: a
15558
+ * tier-1 write (`bird`) can never overwrite a tier-2 value (`Turdus
15559
+ * migratorius`), so fineness cannot regress by construction. Within a tier the
15560
+ * higher score wins. One rule, one implementation — see
15561
+ * `pipeline/label-tier.ts` in addon-post-analysis.
15562
+ */
15563
+ var TieredLabelFields = {
15564
+ /** Tier 1 — the sub-class. See {@link LabelTierSchema}. */
15565
+ label: string().optional(),
15566
+ /** Confidence of the tier-1 value, as reported by the deciding step. */
15567
+ labelScore: number().optional(),
15568
+ /** Provenance of the tier-1 value. See {@link LabelAttributionSchema}. */
15569
+ labelMeta: LabelAttributionSchema.optional(),
15570
+ /** Tier 2 — the instance. See {@link LabelTierSchema}. */
15571
+ subLabel: string().optional(),
15572
+ /** Confidence of the tier-2 value, as reported by the deciding step. */
15573
+ subLabelScore: number().optional(),
15574
+ /** Provenance of the tier-2 value. See {@link LabelAttributionSchema}. */
15575
+ subLabelMeta: LabelAttributionSchema.optional()
15576
+ };
15577
+ /** Per-camera slice of a training-export estimate. */
15578
+ var TrainingExportDeviceTotalsSchema = object({
15579
+ deviceId: number(),
15580
+ tracks: number().int(),
15581
+ files: number().int(),
15582
+ bytes: number().int()
15583
+ });
15584
+ /**
15585
+ * What a training export WOULD contain. Computed from media index rows only —
15586
+ * no blob is read to produce this.
15587
+ */
15588
+ var TrainingExportSummarySchema = object({
15589
+ generatedAt: number(),
15590
+ trackCount: number().int(),
15591
+ fileCount: number().int(),
15592
+ byteCount: number().int(),
15593
+ /** More marked tracks exist than a single pass carries. */
15594
+ truncated: boolean(),
15595
+ devices: array(TrainingExportDeviceTotalsSchema).readonly()
15221
15596
  });
15222
15597
  var TrackSchema = object({
15223
15598
  trackId: string(),
15224
15599
  deviceId: number(),
15225
15600
  className: string(),
15226
- label: string().optional(),
15601
+ ...TieredLabelFields,
15227
15602
  producingDeviceName: string().optional(),
15228
15603
  /** Track provenance. Absent ⇒ `pipeline` (legacy rows). */
15229
15604
  source: TrackSourceSchema.optional(),
@@ -15262,7 +15637,8 @@ var TrackSchema = object({
15262
15637
  * Populated from the persisted envelope columns on historical reads;
15263
15638
  * absent on legacy rows, dims-less tracks and active (in-RAM) tracks. */
15264
15639
  envelope: TrackEnvelopeSchema.optional(),
15265
- ...TrackFlagFields
15640
+ ...TrackFlagFields,
15641
+ ...TrackRetrainFields
15266
15642
  });
15267
15643
  var BaseEventFields = {
15268
15644
  id: string(),
@@ -15335,7 +15711,7 @@ var ObjectEventSchema = object({
15335
15711
  /** Omitted in slim projection. */
15336
15712
  trackId: string().optional(),
15337
15713
  className: string(),
15338
- label: string().optional(),
15714
+ ...TieredLabelFields,
15339
15715
  /** Omitted in slim projection. */
15340
15716
  confidence: number().optional(),
15341
15717
  /** Heavy JSON — omitted in slim projection. */
@@ -15416,6 +15792,173 @@ var MediaFileSchema = object({
15416
15792
  * stored blob and a `?variant=thumb` rendering without fetching either.
15417
15793
  */
15418
15794
  var MediaFileInfoSchema = MediaFileSchema.omit({ base64: true });
15795
+ /**
15796
+ * The MACRO tier of an annotation — a CLOSED set.
15797
+ *
15798
+ * This is what the exported detector predicts, so a typo here is a new class
15799
+ * with one example in it. `label` and `subLabel` are open strings by contrast:
15800
+ * the whole point of the page is teaching the model things it does not know
15801
+ * yet, and constraining that vocabulary would make it useless.
15802
+ *
15803
+ * A macro class is NEVER a label. The provider refuses a write whose `label` or
15804
+ * `subLabel` is one of these values, in any casing, because once `person`
15805
+ * exists in both tiers "every person box" stops being answerable without
15806
+ * knowing every string anyone ever typed — and the damage is retroactive.
15807
+ */
15808
+ var RetrainMacroClassSchema = _enum([
15809
+ "person",
15810
+ "vehicle",
15811
+ "animal",
15812
+ "package",
15813
+ "face",
15814
+ "plate"
15815
+ ]);
15816
+ /** A subject to learn, or a phantom to unlearn (taught by OMISSION). */
15817
+ var RetrainAnnotationKindSchema = _enum(["subject", "model_error"]);
15818
+ /** Did a human draw this box, or did the assist propose it? */
15819
+ var RetrainAnnotationSourceSchema = _enum(["operator", "assist"]);
15820
+ /** Normalised `[0,1]` rectangle against the FULL frame — the canonical form. */
15821
+ var RetrainBboxSchema = object({
15822
+ x: number(),
15823
+ y: number(),
15824
+ w: number(),
15825
+ h: number()
15826
+ });
15827
+ /**
15828
+ * One annotated subject.
15829
+ *
15830
+ * `bbox` is normalised against the full frame, ALWAYS. The per-model shapes
15831
+ * (letterboxed root / zone-cropped package / subject-cropped classifier) are
15832
+ * derived from it at export and never stored — storing them is how one feature
15833
+ * space ends up holding two crops of the same subject (D52).
15834
+ */
15835
+ var RetrainAnnotationSchema = object({
15836
+ id: string(),
15837
+ trackId: string(),
15838
+ deviceId: number(),
15839
+ /** The COPY in retrain storage — never the source track's media key. */
15840
+ mediaKey: string(),
15841
+ bbox: RetrainBboxSchema,
15842
+ macroClass: RetrainMacroClassSchema,
15843
+ label: string().optional(),
15844
+ subLabel: string().optional(),
15845
+ kind: RetrainAnnotationKindSchema,
15846
+ source: RetrainAnnotationSourceSchema,
15847
+ /** Which model proposed this box — or, on a `model_error`, drew the phantom. */
15848
+ assistModelId: string().optional(),
15849
+ assistScore: number().optional(),
15850
+ exportedInBatch: string().optional(),
15851
+ createdAt: number()
15852
+ });
15853
+ /** The write form — the server owns `id`, `createdAt` and the frame binding. */
15854
+ var RetrainAnnotationDraftSchema = RetrainAnnotationSchema.omit({
15855
+ id: true,
15856
+ trackId: true,
15857
+ deviceId: true,
15858
+ mediaKey: true,
15859
+ createdAt: true,
15860
+ exportedInBatch: true
15861
+ });
15862
+ /** A track sitting in `staging`, with everything the worklist needs to rank it. */
15863
+ var RetrainTrackSchema = object({
15864
+ trackId: string(),
15865
+ deviceId: number(),
15866
+ className: string(),
15867
+ label: string().optional(),
15868
+ firstSeen: number(),
15869
+ lastSeen: number(),
15870
+ /** How many frames the dataset already holds from this track. */
15871
+ frameCount: number().int(),
15872
+ /** How many subjects have been annotated on those frames. `0` with
15873
+ * `frameCount: 0` is exactly "staging, still to work". */
15874
+ annotationCount: number().int()
15875
+ });
15876
+ /** A frame the picker may offer — an index row, no blob was read to produce it. */
15877
+ var RetrainFrameCandidateSchema = object({
15878
+ mediaKey: string(),
15879
+ kind: MediaFileKindEnum,
15880
+ timestamp: number(),
15881
+ sizeBytes: number().int(),
15882
+ /** A copy of this original already exists — selecting it is free and cannot
15883
+ * fail, whatever became of the original. */
15884
+ copied: boolean()
15885
+ });
15886
+ /** A frame the dataset OWNS: bytes copied at selection time. */
15887
+ var RetrainFrameSchema = object({
15888
+ frameId: string(),
15889
+ deviceId: number(),
15890
+ trackId: string(),
15891
+ /** Provenance only. It may already point at nothing — that is expected. */
15892
+ sourceMediaKey: string(),
15893
+ sourceKind: MediaFileKindEnum,
15894
+ sizeBytes: number().int(),
15895
+ width: number().int(),
15896
+ height: number().int(),
15897
+ copiedAt: number()
15898
+ });
15899
+ /** Why a copy-on-select could not be honoured — named, never a silent skip. */
15900
+ var RetrainCopyRefusalSchema = _enum([
15901
+ "source-missing",
15902
+ "unreadable-image",
15903
+ "write-failed"
15904
+ ]);
15905
+ var RetrainFrameSelectionSchema = object({
15906
+ copied: array(RetrainFrameSchema).readonly(),
15907
+ refused: array(object({
15908
+ sourceMediaKey: string(),
15909
+ reason: RetrainCopyRefusalSchema
15910
+ })).readonly()
15911
+ });
15912
+ var RetrainFrameListSchema = object({
15913
+ candidates: array(RetrainFrameCandidateSchema).readonly(),
15914
+ copies: array(RetrainFrameSchema).readonly(),
15915
+ /** What the page pre-selects — the native key frame when one survives. */
15916
+ autoPickMediaKey: string().optional()
15917
+ });
15918
+ /** What the operator asked the assist to look for. */
15919
+ var RetrainAssistSubjectSchema = discriminatedUnion("kind", [object({
15920
+ kind: literal("package"),
15921
+ zone: RetrainBboxSchema.optional()
15922
+ }), object({
15923
+ kind: literal("objects"),
15924
+ modelId: string(),
15925
+ minScore: number().optional()
15926
+ })]);
15927
+ /**
15928
+ * The assist's answer — a discriminated union, because "the model saw nothing"
15929
+ * and "this node cannot run that model" lead to different next moves and a
15930
+ * nullable result cannot tell them apart.
15931
+ */
15932
+ var RetrainAssistResultSchema = discriminatedUnion("kind", [object({
15933
+ kind: literal("proposed"),
15934
+ modelId: string(),
15935
+ stepId: string(),
15936
+ minScore: number(),
15937
+ /** Drafts, ready to edit. `source: 'assist'` until the operator touches one. */
15938
+ proposals: array(RetrainAnnotationDraftSchema).readonly(),
15939
+ /** Returned by the runner but removed by the threshold. */
15940
+ belowThreshold: number().int()
15941
+ }), object({
15942
+ kind: literal("refused"),
15943
+ /** `no-zone` is ours; the rest are the runner's own refusal vocabulary. */
15944
+ reason: string(),
15945
+ detail: string().optional()
15946
+ })]);
15947
+ /** The outcome of a lifecycle move owned by the retrain page. */
15948
+ var RetrainTransitionResultSchema = object({
15949
+ trackId: string(),
15950
+ /** Where the track ended up, whatever happened. */
15951
+ retrainStatus: RetrainStatusSchema,
15952
+ /** `false` ⇒ the move was refused or was a no-op; `reason` says which. */
15953
+ changed: boolean(),
15954
+ reason: _enum([
15955
+ "unknown-track",
15956
+ "no-frames-copied",
15957
+ "not-staging",
15958
+ "not-trained",
15959
+ "unchanged"
15960
+ ]).optional()
15961
+ });
15419
15962
  var DEFAULT_EVENT_QUERY_LIMIT = 1e3;
15420
15963
  var MAX_EVENT_QUERY_LIMIT = 5e3;
15421
15964
  var DeviceEventQueryInput = object({
@@ -15470,13 +16013,14 @@ var KeyEventSchema = object({
15470
16013
  /** Track start time (firstSeen). */
15471
16014
  timestamp: number(),
15472
16015
  className: string(),
15473
- label: string().optional(),
16016
+ ...TieredLabelFields,
15474
16017
  importance: number(),
15475
16018
  /** Highest-confidence ObjectEvent id for the track (empty when none). */
15476
16019
  bestEventId: string(),
15477
16020
  /** Track lifetime in ms (lastSeen - firstSeen). */
15478
16021
  windowMs: number().optional(),
15479
- ...TrackFlagFields
16022
+ ...TrackFlagFields,
16023
+ ...TrackRetrainFields
15480
16024
  });
15481
16025
  object({
15482
16026
  trackId: string(),
@@ -15737,6 +16281,85 @@ DeviceType.Camera, method(object({ deviceId: number() }), array(TrackSchema).rea
15737
16281
  }), method(OpsLogQueryInputSchema, array(OpsLogEntrySchema).readonly(), {
15738
16282
  kind: "query",
15739
16283
  auth: "admin"
16284
+ }), method(object({ deviceIds: array(number()).optional() }), TrainingExportSummarySchema, {
16285
+ kind: "query",
16286
+ auth: "admin"
16287
+ }), method(object({ deviceIds: array(number()).optional() }), object({ url: string() }), {
16288
+ kind: "query",
16289
+ auth: "admin"
16290
+ }), method(object({
16291
+ /** Empty ⇒ every camera that has staging tracks. A LIST, not a single
16292
+ * `deviceId`, deliberately: `deviceId` would make this device-bound and
16293
+ * route it at one camera's owner, and "every camera" would stop being
16294
+ * expressible at all. */
16295
+ deviceIds: array(number()).optional(),
16296
+ limit: number().int().min(1).max(500).optional()
16297
+ }), array(RetrainTrackSchema).readonly(), {
16298
+ kind: "query",
16299
+ auth: "admin"
16300
+ }), method(object({ trackId: string() }), RetrainFrameListSchema, {
16301
+ kind: "query",
16302
+ auth: "admin"
16303
+ }), method(object({
16304
+ deviceId: number(),
16305
+ trackId: string(),
16306
+ mediaKeys: array(string()).min(1)
16307
+ }), RetrainFrameSelectionSchema, {
16308
+ kind: "mutation",
16309
+ auth: "admin"
16310
+ }), method(object({
16311
+ deviceId: number(),
16312
+ trackId: string(),
16313
+ frameId: string()
16314
+ }), object({
16315
+ removed: boolean(),
16316
+ removedAnnotations: number().int()
16317
+ }), {
16318
+ kind: "mutation",
16319
+ auth: "admin"
16320
+ }), method(object({ frameId: string() }), object({
16321
+ base64: string(),
16322
+ width: number().int(),
16323
+ height: number().int()
16324
+ }), {
16325
+ kind: "query",
16326
+ auth: "admin"
16327
+ }), method(object({
16328
+ deviceId: number(),
16329
+ trackId: string(),
16330
+ frameId: string(),
16331
+ subject: RetrainAssistSubjectSchema,
16332
+ /** Which node runs it. Absent ⇒ wherever an unowned call lands. */
16333
+ nodeId: string().optional()
16334
+ }), RetrainAssistResultSchema, {
16335
+ kind: "mutation",
16336
+ auth: "admin"
16337
+ }), method(object({ trackId: string() }), array(RetrainAnnotationSchema).readonly(), {
16338
+ kind: "query",
16339
+ auth: "admin"
16340
+ }), method(object({
16341
+ deviceId: number(),
16342
+ trackId: string(),
16343
+ frameId: string(),
16344
+ annotations: array(RetrainAnnotationDraftSchema)
16345
+ }), array(RetrainAnnotationSchema).readonly(), {
16346
+ kind: "mutation",
16347
+ auth: "admin"
16348
+ }), method(object({
16349
+ deviceId: number(),
16350
+ trackId: string()
16351
+ }), RetrainTransitionResultSchema, {
16352
+ kind: "mutation",
16353
+ auth: "admin"
16354
+ }), method(object({
16355
+ deviceId: number(),
16356
+ trackId: string()
16357
+ }), RetrainTransitionResultSchema, {
16358
+ kind: "mutation",
16359
+ auth: "admin"
16360
+ }), method(object({ deviceIds: array(number()).optional() }), object({ url: string() }), {
16361
+ kind: "query",
16362
+ auth: "admin"
15740
16363
  }), method(object({
15741
16364
  eventId: string(),
15742
16365
  kind: MediaFileKindEnum.optional()
@@ -16316,6 +16939,22 @@ var DetailResultSchema = object({
16316
16939
  bbox: NativeCropBboxSchema.optional(),
16317
16940
  embedding: string().optional(),
16318
16941
  label: string().optional(),
16942
+ /**
16943
+ * The tier `label` occupies, copied VERBATIM from the producing step's
16944
+ * `StepDefinition.labelTier` (roadmap 4g). Present only when `label` is.
16945
+ *
16946
+ * It rides the wire rather than being resolved by the consumer because the
16947
+ * declaration lives with the step definition, which only the executing node
16948
+ * has: post-analysis holds no step registry, and re-deriving the tier from
16949
+ * `className` there would be exactly the inference this model exists to
16950
+ * forbid. A `label` that arrives WITHOUT this field is refused by the write
16951
+ * rule and logged (`label tier undeclared`) — an older runner therefore
16952
+ * stops enriching rather than guessing, which is why addon-pipeline is
16953
+ * deployed BEFORE addon-post-analysis.
16954
+ */
16955
+ labelTier: union([literal(1), literal(2)]).optional(),
16956
+ /** Model that produced `label` — carried into the tier's attribution. */
16957
+ labelModelId: string().optional(),
16319
16958
  alignedCropJpeg: string().optional(),
16320
16959
  /** Face short side (px) measured on the NATIVE crop surface. The `bbox`
16321
16960
  * above is detection-frame px (≈6× smaller on a 4K camera) — min-face-size
@@ -21397,6 +22036,173 @@ DeviceType.Camera, method(object({
21397
22036
  status: OsdStatusSchema
21398
22037
  });
21399
22038
  /**
22039
+ * `osd-manager` — the ORCHESTRATOR over the device-scope `osd` cap.
22040
+ *
22041
+ * The `osd` cap is the firmware contract: it probes a camera's overlay
22042
+ * SLOTS and writes literal text into one. It has no idea WHERE that text
22043
+ * comes from, and it must not — a driver that grew a "show the temperature
22044
+ * here" feature would grow it once per vendor.
22045
+ *
22046
+ * This cap owns the other half: a per-(camera, slot) BINDING that says
22047
+ * which value feeds the slot, how it is formatted, and under which
22048
+ * conditions it is shown at all. One addon renders every binding on every
22049
+ * camera, so a new source costs zero driver code.
22050
+ *
22051
+ * Three deliberate choices, each with a rejected alternative:
22052
+ *
22053
+ * 1. A source is `(capName, valuePath)` over the kernel's device
22054
+ * runtime-state mirror — NOT a closed enum of source kinds. Every
22055
+ * cap-keyed slice a device publishes is bindable the day the cap
22056
+ * ships. The rejected alternative (one enum member per source, with
22057
+ * a resolver branch each) is what makes "add the humidity too" a
22058
+ * code change.
22059
+ * 2. The display gate reuses `NcConditionsSchema` verbatim — the
22060
+ * notification centre's condition vocabulary — rather than a parallel
22061
+ * model. An operator who has learned one condition editor has learned
22062
+ * both.
22063
+ * 3. Because the renderer's facts are device STATE and not a detection
22064
+ * record, only a SUBSET of that vocabulary can be answered here.
22065
+ * `setSlotBinding` REJECTS the rest at write time (see
22066
+ * `getConditionSupport`). It does not accept-then-fail-closed: a
22067
+ * condition that can never be true renders a permanently blank
22068
+ * overlay, and a blank overlay looks exactly like a broken camera.
22069
+ */
22070
+ /** Where a slot's value comes from. */
22071
+ var OsdSourceSchema = discriminatedUnion("kind", [
22072
+ object({
22073
+ kind: literal("static"),
22074
+ text: string().max(64)
22075
+ }),
22076
+ object({
22077
+ kind: literal("clock"),
22078
+ /** Token pattern: `YYYY MM DD HH mm ss`. Everything else is literal. */
22079
+ pattern: string().min(1).max(32).default("HH:mm"),
22080
+ /** IANA zone. Omitted = the server's zone. */
22081
+ timezone: string().min(1).max(64).optional()
22082
+ }),
22083
+ object({
22084
+ kind: literal("device-state"),
22085
+ deviceId: number().int().optional(),
22086
+ capName: string().min(1).max(64),
22087
+ /** Dot path inside the slice, e.g. `detected`, `value`, `mode`. */
22088
+ valuePath: string().min(1).max(64)
22089
+ })
22090
+ ]);
22091
+ var OsdSlotBindingSchema = object({
22092
+ /** Off = the manager stops driving this slot. It does NOT clear it. */
22093
+ enabled: boolean().default(true),
22094
+ source: OsdSourceSchema,
22095
+ /** `${value}` and `${unit}` are substituted; every occurrence. */
22096
+ template: string().max(96).default("${value}"),
22097
+ /** Truncate with an ellipsis past this length. Absent = no limit. */
22098
+ maxCharacters: number().int().min(4).max(64).optional(),
22099
+ /**
22100
+ * Decimal places for a numeric value. `0` yields an integer — the
22101
+ * documented workaround for firmwares that reject `.` in overlay text.
22102
+ */
22103
+ maxDecimals: number().int().min(0).max(4).default(1),
22104
+ /** Appended via `${unit}`. The state mirror does not carry units. */
22105
+ unitLabel: string().max(8).optional(),
22106
+ /** Raw value → display text, e.g. `{"true":"MOTION","false":""}`. */
22107
+ valueMap: record(string(), string()).optional(),
22108
+ /** Time windows in which the slot is shown. Absent = always. */
22109
+ schedule: NcScheduleSchema.optional(),
22110
+ /**
22111
+ * Display gate, in the notification centre's condition vocabulary.
22112
+ * Only the keys reported by `getConditionSupport` are accepted.
22113
+ */
22114
+ conditions: NcConditionsSchema.optional(),
22115
+ /** Rendered when the gate is closed or the value unreadable. Empty = hide. */
22116
+ fallbackText: string().max(64).default("")
22117
+ });
22118
+ /** One camera slot, as the operator sees it: firmware truth + our binding. */
22119
+ var OsdSlotViewSchema = object({
22120
+ slotId: string(),
22121
+ kind: OsdOverlayKindEnum,
22122
+ /** Firmware refuses text edits (a timestamp, the channel name). */
22123
+ readOnly: boolean(),
22124
+ cameraEnabled: boolean(),
22125
+ cameraText: string().optional(),
22126
+ binding: OsdSlotBindingSchema.nullable()
22127
+ });
22128
+ /**
22129
+ * What happened to one slot on one render pass. `unchanged` exists so the
22130
+ * operator can tell "we are driving this and the value is steady" from
22131
+ * "we never got there" — and so the loop can prove it is not rewriting
22132
+ * identical text to the camera every tick.
22133
+ */
22134
+ var OsdRenderOutcomeEnum = _enum([
22135
+ "written",
22136
+ "unchanged",
22137
+ "gated",
22138
+ "unreadable",
22139
+ "disabled",
22140
+ "unbound",
22141
+ "failed"
22142
+ ]);
22143
+ var OsdRenderResultSchema = object({
22144
+ slotId: string(),
22145
+ outcome: OsdRenderOutcomeEnum,
22146
+ /** The text the slot should carry. Empty = the slot is switched off. */
22147
+ text: string(),
22148
+ /** Why, whenever the outcome is not a plain write. Never silent. */
22149
+ reason: string().optional()
22150
+ });
22151
+ var OsdSourceValueTypeEnum = _enum([
22152
+ "number",
22153
+ "boolean",
22154
+ "string",
22155
+ "enum"
22156
+ ]);
22157
+ /**
22158
+ * One bindable value, derived from a cap's `runtimeState` schema — never
22159
+ * hand-listed. The editor renders from this, so a cap that ships a new
22160
+ * state field becomes bindable with no UI change.
22161
+ */
22162
+ var OsdSourceOptionSchema = object({
22163
+ deviceId: number().int(),
22164
+ deviceName: string(),
22165
+ capName: string(),
22166
+ valuePath: string(),
22167
+ label: string(),
22168
+ valueType: OsdSourceValueTypeEnum,
22169
+ /** Present for `enum`; the editor offers these as `valueMap` keys. */
22170
+ enumValues: array(string()).readonly().optional()
22171
+ });
22172
+ method(object({ deviceId: number().int() }), object({
22173
+ supported: boolean(),
22174
+ slots: array(OsdSlotViewSchema)
22175
+ }), { auth: "admin" }), method(object({ deviceId: number().int() }), object({ sources: array(OsdSourceOptionSchema) }), { auth: "admin" }), method(object({}), object({
22176
+ supported: array(string()),
22177
+ catalog: array(NcConditionDescriptorSchema)
22178
+ }), { auth: "admin" }), method(object({
22179
+ deviceId: number().int(),
22180
+ slotId: string().min(1),
22181
+ binding: OsdSlotBindingSchema
22182
+ }), object({
22183
+ slot: OsdSlotViewSchema,
22184
+ render: OsdRenderResultSchema
22185
+ }), {
22186
+ kind: "mutation",
22187
+ auth: "admin"
22188
+ }), method(object({
22189
+ deviceId: number().int(),
22190
+ slotId: string().min(1)
22191
+ }), object({ success: literal(true) }), {
22192
+ kind: "mutation",
22193
+ auth: "admin"
22194
+ }), method(object({
22195
+ deviceId: number().int(),
22196
+ slotId: string().min(1),
22197
+ binding: OsdSlotBindingSchema.optional()
22198
+ }), OsdRenderResultSchema, {
22199
+ kind: "mutation",
22200
+ auth: "admin"
22201
+ }), method(object({ deviceId: number().int() }), object({ results: array(OsdRenderResultSchema) }), {
22202
+ kind: "mutation",
22203
+ auth: "admin"
22204
+ });
22205
+ /**
21400
22206
  * Feeder connectivity / power status — mirrors the HA petkit device-status
21401
22207
  * enum: `normal` (online, mains), `offline` (not reaching PetKit cloud),
21402
22208
  * `on_batteries` (running on battery backup). `null` until first reported.
@@ -26352,6 +27158,48 @@ Object.freeze({
26352
27158
  addonId: null,
26353
27159
  access: "create"
26354
27160
  },
27161
+ "osdManager.clearSlotBinding": {
27162
+ capName: "osd-manager",
27163
+ capScope: "system",
27164
+ addonId: null,
27165
+ access: "delete"
27166
+ },
27167
+ "osdManager.getConditionSupport": {
27168
+ capName: "osd-manager",
27169
+ capScope: "system",
27170
+ addonId: null,
27171
+ access: "view"
27172
+ },
27173
+ "osdManager.getDeviceOsd": {
27174
+ capName: "osd-manager",
27175
+ capScope: "system",
27176
+ addonId: null,
27177
+ access: "view"
27178
+ },
27179
+ "osdManager.getSourceCatalog": {
27180
+ capName: "osd-manager",
27181
+ capScope: "system",
27182
+ addonId: null,
27183
+ access: "view"
27184
+ },
27185
+ "osdManager.previewSlot": {
27186
+ capName: "osd-manager",
27187
+ capScope: "system",
27188
+ addonId: null,
27189
+ access: "create"
27190
+ },
27191
+ "osdManager.renderDevice": {
27192
+ capName: "osd-manager",
27193
+ capScope: "system",
27194
+ addonId: null,
27195
+ access: "create"
27196
+ },
27197
+ "osdManager.setSlotBinding": {
27198
+ capName: "osd-manager",
27199
+ capScope: "system",
27200
+ addonId: null,
27201
+ access: "create"
27202
+ },
26355
27203
  "petFeeder.callPet": {
26356
27204
  capName: "pet-feeder",
26357
27205
  capScope: "device",
@@ -26424,6 +27272,12 @@ Object.freeze({
26424
27272
  addonId: null,
26425
27273
  access: "delete"
26426
27274
  },
27275
+ "pipelineAnalytics.completeRetrainTrack": {
27276
+ capName: "pipeline-analytics",
27277
+ capScope: "device",
27278
+ addonId: null,
27279
+ access: "create"
27280
+ },
26427
27281
  "pipelineAnalytics.deleteDeviceEvents": {
26428
27282
  capName: "pipeline-analytics",
26429
27283
  capScope: "device",
@@ -26436,6 +27290,12 @@ Object.freeze({
26436
27290
  addonId: null,
26437
27291
  access: "delete"
26438
27292
  },
27293
+ "pipelineAnalytics.deselectRetrainFrame": {
27294
+ capName: "pipeline-analytics",
27295
+ capScope: "device",
27296
+ addonId: null,
27297
+ access: "create"
27298
+ },
26439
27299
  "pipelineAnalytics.getActiveTracks": {
26440
27300
  capName: "pipeline-analytics",
26441
27301
  capScope: "device",
@@ -26496,6 +27356,18 @@ Object.freeze({
26496
27356
  addonId: null,
26497
27357
  access: "view"
26498
27358
  },
27359
+ "pipelineAnalytics.getRetrainExportUrl": {
27360
+ capName: "pipeline-analytics",
27361
+ capScope: "device",
27362
+ addonId: null,
27363
+ access: "view"
27364
+ },
27365
+ "pipelineAnalytics.getRetrainFrameImage": {
27366
+ capName: "pipeline-analytics",
27367
+ capScope: "device",
27368
+ addonId: null,
27369
+ access: "view"
27370
+ },
26499
27371
  "pipelineAnalytics.getSensorEvents": {
26500
27372
  capName: "pipeline-analytics",
26501
27373
  capScope: "device",
@@ -26514,6 +27386,18 @@ Object.freeze({
26514
27386
  addonId: null,
26515
27387
  access: "view"
26516
27388
  },
27389
+ "pipelineAnalytics.getTrainingExportSummary": {
27390
+ capName: "pipeline-analytics",
27391
+ capScope: "device",
27392
+ addonId: null,
27393
+ access: "view"
27394
+ },
27395
+ "pipelineAnalytics.getTrainingExportUrl": {
27396
+ capName: "pipeline-analytics",
27397
+ capScope: "device",
27398
+ addonId: null,
27399
+ access: "view"
27400
+ },
26517
27401
  "pipelineAnalytics.listEventKinds": {
26518
27402
  capName: "pipeline-analytics",
26519
27403
  capScope: "device",
@@ -26538,6 +27422,24 @@ Object.freeze({
26538
27422
  addonId: null,
26539
27423
  access: "view"
26540
27424
  },
27425
+ "pipelineAnalytics.listRetrainAnnotations": {
27426
+ capName: "pipeline-analytics",
27427
+ capScope: "device",
27428
+ addonId: null,
27429
+ access: "view"
27430
+ },
27431
+ "pipelineAnalytics.listRetrainFrames": {
27432
+ capName: "pipeline-analytics",
27433
+ capScope: "device",
27434
+ addonId: null,
27435
+ access: "view"
27436
+ },
27437
+ "pipelineAnalytics.listRetrainStaging": {
27438
+ capName: "pipeline-analytics",
27439
+ capScope: "device",
27440
+ addonId: null,
27441
+ access: "view"
27442
+ },
26541
27443
  "pipelineAnalytics.listTrackMedia": {
26542
27444
  capName: "pipeline-analytics",
26543
27445
  capScope: "device",
@@ -26550,6 +27452,12 @@ Object.freeze({
26550
27452
  addonId: null,
26551
27453
  access: "view"
26552
27454
  },
27455
+ "pipelineAnalytics.proposeRetrainAnnotations": {
27456
+ capName: "pipeline-analytics",
27457
+ capScope: "device",
27458
+ addonId: null,
27459
+ access: "create"
27460
+ },
26553
27461
  "pipelineAnalytics.pruneEvents": {
26554
27462
  capName: "pipeline-analytics",
26555
27463
  capScope: "device",
@@ -26580,12 +27488,30 @@ Object.freeze({
26580
27488
  addonId: null,
26581
27489
  access: "create"
26582
27490
  },
27491
+ "pipelineAnalytics.restageRetrainTrack": {
27492
+ capName: "pipeline-analytics",
27493
+ capScope: "device",
27494
+ addonId: null,
27495
+ access: "create"
27496
+ },
27497
+ "pipelineAnalytics.saveRetrainAnnotations": {
27498
+ capName: "pipeline-analytics",
27499
+ capScope: "device",
27500
+ addonId: null,
27501
+ access: "create"
27502
+ },
26583
27503
  "pipelineAnalytics.searchObjectEvents": {
26584
27504
  capName: "pipeline-analytics",
26585
27505
  capScope: "device",
26586
27506
  addonId: null,
26587
27507
  access: "view"
26588
27508
  },
27509
+ "pipelineAnalytics.selectRetrainFrames": {
27510
+ capName: "pipeline-analytics",
27511
+ capScope: "device",
27512
+ addonId: null,
27513
+ access: "create"
27514
+ },
26589
27515
  "pipelineAnalytics.setTrackFlags": {
26590
27516
  capName: "pipeline-analytics",
26591
27517
  capScope: "device",
@@ -27990,6 +28916,12 @@ Object.freeze({
27990
28916
  addonId: null,
27991
28917
  access: "view"
27992
28918
  },
28919
+ "streamBroker.getDeviceAudioMute": {
28920
+ capName: "stream-broker",
28921
+ capScope: "system",
28922
+ addonId: null,
28923
+ access: "view"
28924
+ },
27993
28925
  "streamBroker.getPreBufferInfo": {
27994
28926
  capName: "stream-broker",
27995
28927
  capScope: "system",
@@ -28110,6 +29042,12 @@ Object.freeze({
28110
29042
  addonId: null,
28111
29043
  access: "create"
28112
29044
  },
29045
+ "streamBroker.setDeviceAudioMute": {
29046
+ capName: "stream-broker",
29047
+ capScope: "system",
29048
+ addonId: null,
29049
+ access: "create"
29050
+ },
28113
29051
  "streamBroker.setPreBufferDuration": {
28114
29052
  capName: "stream-broker",
28115
29053
  capScope: "system",
@@ -28978,6 +29916,506 @@ function resolveExportFingerprint(input) {
28978
29916
  if (input.ready) return input.fresh;
28979
29917
  return input.persisted ?? input.fresh;
28980
29918
  }
29919
+ /**
29920
+ * Fmp4FragmentPlane — a SUBSCRIBABLE fragmented-MP4 plane, fed by one
29921
+ * {@link import('./fmp4-box-splitter.js').Fmp4BoxSplitter}.
29922
+ *
29923
+ * ## Why a plane and not a callback
29924
+ *
29925
+ * The operator's requirement for HKSV was explicit: the live fMP4 source built
29926
+ * for it must be **dual-use**, so a HomeKit-triggered recording also lands in
29927
+ * CamStack as an additional videoclip source alongside the recorder and the NC
29928
+ * clip ring — *one fragmenter, two consumers; do not build an HKSV-only pipe*
29929
+ * (`docs/roadmap.md` item 4b). A single-callback pipe makes the second consumer
29930
+ * a second ffmpeg child of the same camera. So this is the same shape the
29931
+ * broker's other multi-consumer surfaces already have
29932
+ * (`AudioChunkPlane`, the push packet plane): N independent subscriptions over
29933
+ * one producer.
29934
+ *
29935
+ * **Nothing consumes it yet.** Phase 4 brings the HKSV delegate and phase 4b the
29936
+ * clip source; both are named here so the seam is not re-invented, and neither
29937
+ * is built.
29938
+ *
29939
+ * ## The init segment is RETAINED
29940
+ *
29941
+ * A subscriber that attaches mid-stream — the clip consumer joining an already
29942
+ * running HKSV session, which is the whole dual-use case — receives the
29943
+ * retained `ftyp`+`moov` as its first packet and then live fragments. Without
29944
+ * retention its fragments are undecodable and the failure looks like a codec
29945
+ * problem.
29946
+ *
29947
+ * ## A slow subscriber is CLOSED, never silently gapped
29948
+ *
29949
+ * `AudioChunkPlane` drops its oldest chunk on overflow, which for audio costs a
29950
+ * click. An fMP4 stream with a hole is not a shorter clip, it is a corrupt one:
29951
+ * `moof` sequence numbers jump, the consumer's demuxer desynchronises, and HKSV
29952
+ * shows a clip that fails to play with nothing anywhere saying why. So a
29953
+ * subscription whose queue overflows is ENDED with a reason, loudly, and the
29954
+ * other subscriptions are untouched.
29955
+ *
29956
+ * ## The PREBUFFER (phase 3)
29957
+ *
29958
+ * HKSV asks for context BEFORE the trigger — `CameraRecordingOptions.prebufferLength`
29959
+ * is a HAP-mandated minimum of 4000 ms — and a subscriber that attaches at the
29960
+ * motion edge has none. So the plane optionally retains the last few fragments
29961
+ * and replays them to a subscriber that asks for them.
29962
+ *
29963
+ * Three things this ring gets right, each of which is a measured fact rather
29964
+ * than a preference (see [D84](../../../../docs/decisions/adr-0084.md)):
29965
+ *
29966
+ * - **It is bounded by TIME *and* BYTES.** On the live fleet a 720p copy
29967
+ * fragment is ~255 KB and a 4K one is ~6.35 MB — a 25× spread over the same
29968
+ * window. A time-only bound is a per-camera RAM figure nobody can predict.
29969
+ * - **The window is measured on ARRIVAL, not parsed from `tfdt`.** The
29970
+ * splitter deliberately never computes a fragment's duration (a second
29971
+ * opinion about a fact the muxer owns), and a prebuffer cares about how long
29972
+ * ago the bytes turned up, which is exactly what arrival time answers.
29973
+ * - **A replay is not backlog.** A subscriber taking N retained fragments gets
29974
+ * its queue capacity raised by N for them, because closing a subscriber as a
29975
+ * slow consumer for the prebuffer it explicitly asked for would be the
29976
+ * stupidest possible failure — and, with `DEFAULT_QUEUE_CAPACITY` of 4 and a
29977
+ * ring of 4, the guaranteed one.
29978
+ *
29979
+ * ## `isLast`
29980
+ *
29981
+ * hap-nodejs requires the delegate to mark exactly one `RecordingPacket` with
29982
+ * `isLast` — a generator that finishes without it produces the twelve-second
29983
+ * timeout loop [D50](../../../../../docs/decisions/adr-0050.md) deleted. The
29984
+ * plane therefore computes it at DELIVERY time: a packet is last when the plane
29985
+ * has ended and nothing remains queued behind it. A subscription that ends
29986
+ * having delivered NOTHING says so through {@link Fmp4Subscription.delivered};
29987
+ * the future delegate must not open an HDS stream it cannot feed.
29988
+ */
29989
+ var DEFAULT_QUEUE_CAPACITY = 4;
29990
+ var Fmp4FragmentPlane = class {
29991
+ logger;
29992
+ prebuffer;
29993
+ now;
29994
+ subscriptions = /* @__PURE__ */ new Map();
29995
+ /** The last init unit seen, handed to every later subscriber. */
29996
+ retainedInit = null;
29997
+ ended = false;
29998
+ /** Oldest first. Empty unless {@link Fmp4PrebufferOptions} was supplied. */
29999
+ ring = [];
30000
+ ringBytes = 0;
30001
+ constructor(logger, prebuffer, now = Date.now) {
30002
+ this.logger = logger;
30003
+ this.prebuffer = prebuffer;
30004
+ this.now = now;
30005
+ }
30006
+ get subscriberCount() {
30007
+ return this.subscriptions.size;
30008
+ }
30009
+ /** True once {@link end} has been called — no further units are accepted. */
30010
+ get isEnded() {
30011
+ return this.ended;
30012
+ }
30013
+ /** What the prebuffer ring holds right now. All zeroes when disabled. */
30014
+ prebufferStats() {
30015
+ const oldest = this.ring[0];
30016
+ return {
30017
+ fragments: this.ring.length,
30018
+ bytes: this.ringBytes,
30019
+ spanMs: oldest === void 0 ? 0 : this.now() - oldest.arrivedAt
30020
+ };
30021
+ }
30022
+ subscribe(input) {
30023
+ const replay = input.withPrebuffer === true ? this.trimmedRing() : [];
30024
+ const requested = Math.max(1, input.queueCapacity ?? DEFAULT_QUEUE_CAPACITY);
30025
+ const sub = {
30026
+ id: `fmp4-${randomUUID()}`,
30027
+ tag: input.tag,
30028
+ subscribedAt: this.now(),
30029
+ capacity: requested + replay.length,
30030
+ queue: [],
30031
+ delivered: 0,
30032
+ closedReason: null,
30033
+ wake: null,
30034
+ iterating: false
30035
+ };
30036
+ this.subscriptions.set(sub.id, sub);
30037
+ if (this.retainedInit !== null) this.enqueue(sub, this.retainedInit);
30038
+ for (const retained of replay) this.enqueue(sub, retained.unit);
30039
+ if (this.ended) this.closeSubscription(sub, "ended");
30040
+ this.logger?.info("fmp4 plane: subscribed", { meta: {
30041
+ subscriptionId: sub.id,
30042
+ tag: sub.tag,
30043
+ hasRetainedInit: this.retainedInit !== null,
30044
+ prebufferFragments: replay.length,
30045
+ prebufferBytes: replay.reduce((n, r) => n + r.unit.data.length, 0)
30046
+ } });
30047
+ return this.facade(sub);
30048
+ }
30049
+ /**
30050
+ * Fan one splitter unit out. An `init` REPLACES the retained one — ffmpeg
30051
+ * emits exactly one per child, and a second means the child was respawned, in
30052
+ * which case the old one describes a stream that no longer exists.
30053
+ */
30054
+ publish(unit) {
30055
+ if (this.ended) return;
30056
+ if (unit.kind === "init") {
30057
+ this.retainedInit = unit;
30058
+ this.ring.length = 0;
30059
+ this.ringBytes = 0;
30060
+ } else this.retain(unit);
30061
+ for (const sub of this.subscriptions.values()) {
30062
+ if (sub.closedReason !== null) continue;
30063
+ this.enqueue(sub, unit);
30064
+ }
30065
+ }
30066
+ /**
30067
+ * The producer stopped. Every subscriber drains what it holds; its final
30068
+ * packet carries `isLast`, and its generator then completes.
30069
+ */
30070
+ end(reason = "producer ended") {
30071
+ if (this.ended) return;
30072
+ this.ended = true;
30073
+ this.logger?.info("fmp4 plane: ended", { meta: {
30074
+ reason,
30075
+ subscribers: this.subscriptions.size
30076
+ } });
30077
+ for (const sub of this.subscriptions.values()) if (sub.closedReason === null) this.closeSubscription(sub, "ended");
30078
+ }
30079
+ listSubscribers() {
30080
+ return [...this.subscriptions.values()].map((s) => ({
30081
+ tag: s.tag,
30082
+ subscribedAt: s.subscribedAt,
30083
+ delivered: s.delivered,
30084
+ closedReason: s.closedReason
30085
+ }));
30086
+ }
30087
+ /** End and forget everything. Idempotent. */
30088
+ dispose() {
30089
+ this.end("disposed");
30090
+ this.subscriptions.clear();
30091
+ this.retainedInit = null;
30092
+ this.ring.length = 0;
30093
+ this.ringBytes = 0;
30094
+ }
30095
+ /**
30096
+ * Add one fragment to the ring and evict from the front until BOTH bounds
30097
+ * hold. Eviction is oldest-first, which is the one place in this file where
30098
+ * dropping is correct: the ring is context, not stream — nobody is mid-decode
30099
+ * on it, and a subscriber only ever receives a contiguous tail of it.
30100
+ */
30101
+ retain(unit) {
30102
+ const prebuffer = this.prebuffer;
30103
+ if (prebuffer === void 0) return;
30104
+ const arrivedAt = this.now();
30105
+ this.ring.push({
30106
+ unit,
30107
+ arrivedAt
30108
+ });
30109
+ this.ringBytes += unit.data.length;
30110
+ const cutoff = arrivedAt - prebuffer.windowMs;
30111
+ while (this.ring.length > 0) {
30112
+ const oldest = this.ring[0];
30113
+ if (oldest === void 0) break;
30114
+ const tooOld = oldest.arrivedAt < cutoff;
30115
+ const tooBig = this.ringBytes > prebuffer.maxBytes;
30116
+ if (!tooOld && !tooBig || this.ring.length === 1) break;
30117
+ this.ring.shift();
30118
+ this.ringBytes -= oldest.unit.data.length;
30119
+ }
30120
+ }
30121
+ /**
30122
+ * The ring as a subscriber should receive it — window applied AT SUBSCRIBE
30123
+ * time, not only at publish time. A camera that went quiet keeps its last
30124
+ * fragment in the ring indefinitely (see the never-evict-the-newest rule),
30125
+ * and replaying a 40-second-old fragment as "prebuffer" would put stale video
30126
+ * at the head of a clip iOS presents as the moment of the event.
30127
+ */
30128
+ trimmedRing() {
30129
+ const prebuffer = this.prebuffer;
30130
+ if (prebuffer === void 0) return [];
30131
+ const cutoff = this.now() - prebuffer.windowMs;
30132
+ return this.ring.filter((r) => r.arrivedAt >= cutoff);
30133
+ }
30134
+ enqueue(sub, unit) {
30135
+ if (sub.queue.length >= sub.capacity) {
30136
+ this.logger?.warn("fmp4 plane: subscriber fell behind — CLOSING it rather than gapping it", { meta: {
30137
+ subscriptionId: sub.id,
30138
+ tag: sub.tag,
30139
+ capacity: sub.capacity,
30140
+ delivered: sub.delivered
30141
+ } });
30142
+ this.closeSubscription(sub, "slow-consumer");
30143
+ return;
30144
+ }
30145
+ sub.queue.push({
30146
+ kind: unit.kind,
30147
+ data: unit.data,
30148
+ sequence: unit.sequence,
30149
+ isLast: false
30150
+ });
30151
+ this.wake(sub);
30152
+ }
30153
+ closeSubscription(sub, reason) {
30154
+ if (sub.closedReason !== null) return;
30155
+ sub.closedReason = reason;
30156
+ if (reason === "slow-consumer") sub.queue.length = 0;
30157
+ this.wake(sub);
30158
+ }
30159
+ wake(sub) {
30160
+ const resume = sub.wake;
30161
+ sub.wake = null;
30162
+ resume?.();
30163
+ }
30164
+ facade(sub) {
30165
+ const plane = this;
30166
+ return {
30167
+ id: sub.id,
30168
+ tag: sub.tag,
30169
+ get delivered() {
30170
+ return sub.delivered;
30171
+ },
30172
+ get closedReason() {
30173
+ return sub.closedReason;
30174
+ },
30175
+ packets: () => plane.iterate(sub),
30176
+ release: () => {
30177
+ plane.closeSubscription(sub, "released");
30178
+ plane.subscriptions.delete(sub.id);
30179
+ }
30180
+ };
30181
+ }
30182
+ async *iterate(sub) {
30183
+ if (sub.iterating) throw new Error(`fmp4 plane: subscription ${sub.tag} is already being consumed — take a second subscription`);
30184
+ sub.iterating = true;
30185
+ for (;;) {
30186
+ const next = sub.queue.shift();
30187
+ if (next === void 0) {
30188
+ if (sub.closedReason !== null) return;
30189
+ await new Promise((resolve) => {
30190
+ sub.wake = resolve;
30191
+ });
30192
+ continue;
30193
+ }
30194
+ const isLast = sub.closedReason === "ended" && sub.queue.length === 0;
30195
+ sub.delivered += 1;
30196
+ yield {
30197
+ ...next,
30198
+ isLast
30199
+ };
30200
+ if (isLast) return;
30201
+ }
30202
+ }
30203
+ };
30204
+ var DEFAULT_FIRST_UNIT_TIMEOUT_MS = 12e3;
30205
+ /** Heartbeat cadence — ~2 minutes of 4 s fragments. */
30206
+ var FRAGMENT_LOG_EVERY = 30;
30207
+ var Fmp4FragmentChild = class {
30208
+ deps;
30209
+ args;
30210
+ child = null;
30211
+ splitter = new Fmp4BoxSplitter();
30212
+ stopped = false;
30213
+ unitsOut = 0;
30214
+ activeHwAccel = null;
30215
+ constructor(deps, args) {
30216
+ this.deps = deps;
30217
+ this.args = args;
30218
+ }
30219
+ /** Spawn, and resolve once the INIT segment has been cut out of stdout. */
30220
+ async start() {
30221
+ const requested = this.args.invocation.decodeHwAccel;
30222
+ this.activeHwAccel = requested;
30223
+ try {
30224
+ await this.spawnAttempt(requested);
30225
+ return;
30226
+ } catch (err) {
30227
+ if (this.stopped) throw err;
30228
+ if (requested === null || isSoftwareDecode(requested)) throw err;
30229
+ this.deps.logger.warn("fmp4 fragment child: hardware decode produced NO fragment — retrying in SOFTWARE", {
30230
+ tags: { deviceId: this.args.deviceId },
30231
+ meta: {
30232
+ sourceId: this.args.sourceId,
30233
+ decodeHwAccel: requested,
30234
+ error: errMsg$12(err)
30235
+ }
30236
+ });
30237
+ this.killChild();
30238
+ this.splitter = new Fmp4BoxSplitter();
30239
+ this.activeHwAccel = null;
30240
+ await this.spawnAttempt(null);
30241
+ }
30242
+ }
30243
+ /** The backend the child ACTUALLY ran with — `null` for software. */
30244
+ activeDecodeHwAccel() {
30245
+ const value = this.activeHwAccel;
30246
+ return value === null || value === "none" || value === "copy" ? null : value;
30247
+ }
30248
+ /** Kill ffmpeg and end the plane. Idempotent. */
30249
+ async stop() {
30250
+ if (this.stopped) return;
30251
+ this.stopped = true;
30252
+ this.killChild();
30253
+ this.args.plane.end("the fragment child stopped");
30254
+ }
30255
+ spawnAttempt(decodeHwAccel) {
30256
+ const args = buildFfmpegArgs({
30257
+ ...this.args.invocation,
30258
+ decodeHwAccel,
30259
+ sink: {
30260
+ kind: "stdout",
30261
+ container: "mp4",
30262
+ fragmentMs: this.args.fragmentMs
30263
+ }
30264
+ });
30265
+ this.deps.logger.info("fmp4 fragment child: spawning ffmpeg", {
30266
+ tags: { deviceId: this.args.deviceId },
30267
+ meta: {
30268
+ sourceId: this.args.sourceId,
30269
+ fragmentMs: this.args.fragmentMs,
30270
+ decodeHwAccel: decodeHwAccel ?? "software",
30271
+ argv: args.join(" ")
30272
+ }
30273
+ });
30274
+ return new Promise((resolve, reject) => {
30275
+ const child = this.deps.spawnFn(this.deps.ffmpegBinaryPath, args, { stdio: [
30276
+ "ignore",
30277
+ "pipe",
30278
+ "pipe"
30279
+ ] });
30280
+ this.child = child;
30281
+ let settled = false;
30282
+ /**
30283
+ * This attempt FAILED. Set before the kill, because SIGTERM makes the
30284
+ * child exit and that exit must not be reported as a death: the retry —
30285
+ * or the caller's rejection — already owns what happens next. Without it
30286
+ * the timeout path ends the plane the software retry is about to fill,
30287
+ * and the consumer sees a stream that stopped for no reason. A "which
30288
+ * spawn is current" counter does NOT cover this: the retry has not been
30289
+ * spawned when the kill's exit arrives.
30290
+ */
30291
+ let failed = false;
30292
+ /**
30293
+ * This attempt is still the live producer: it has not failed (a failure
30294
+ * hands ownership to the retry, or to the caller's rejection) and nothing
30295
+ * has stopped the child. Those two cover every way an attempt stops being
30296
+ * current — `start` only respawns after a rejection.
30297
+ */
30298
+ const isCurrent = () => !this.stopped && !failed;
30299
+ const timeoutMs = this.deps.firstUnitTimeoutMs ?? DEFAULT_FIRST_UNIT_TIMEOUT_MS;
30300
+ const settle = (fail) => {
30301
+ if (settled) return;
30302
+ settled = true;
30303
+ clearTimeout(timer);
30304
+ if (fail) {
30305
+ failed = true;
30306
+ reject(fail);
30307
+ } else resolve();
30308
+ };
30309
+ const timer = setTimeout(() => {
30310
+ settle(/* @__PURE__ */ new Error(`fmp4 fragment child: no fragment within ${timeoutMs}ms`));
30311
+ this.killChild();
30312
+ }, timeoutMs);
30313
+ timer.unref?.();
30314
+ child.stdout?.on("data", (chunk) => {
30315
+ for (const unit of this.splitter.push(chunk)) {
30316
+ this.unitsOut += 1;
30317
+ this.args.plane.publish(unit);
30318
+ if (unit.kind === "init") {
30319
+ this.deps.logger.info("fmp4 fragment child: INIT segment cut", {
30320
+ tags: { deviceId: this.args.deviceId },
30321
+ meta: {
30322
+ sourceId: this.args.sourceId,
30323
+ bytes: unit.data.length
30324
+ }
30325
+ });
30326
+ settle();
30327
+ } else if (this.unitsOut % FRAGMENT_LOG_EVERY === 0) this.deps.logger.info("fmp4 fragment child: fragments still flowing", {
30328
+ tags: { deviceId: this.args.deviceId },
30329
+ meta: {
30330
+ sourceId: this.args.sourceId,
30331
+ unitsOut: this.unitsOut,
30332
+ bytes: unit.data.length,
30333
+ subscribers: this.args.plane.subscriberCount
30334
+ }
30335
+ });
30336
+ }
30337
+ const fault = this.splitter.fault;
30338
+ if (fault !== null) this.onFault(fault, settled, isCurrent(), settle);
30339
+ });
30340
+ child.stderr?.setEncoding("utf8");
30341
+ child.stderr?.on("data", (line) => {
30342
+ this.deps.logger.debug("fmp4 fragment child ffmpeg", {
30343
+ tags: { deviceId: this.args.deviceId },
30344
+ meta: {
30345
+ sourceId: this.args.sourceId,
30346
+ line: line.trim()
30347
+ }
30348
+ });
30349
+ });
30350
+ child.once("error", (err) => {
30351
+ if (!settled) {
30352
+ settle(err);
30353
+ return;
30354
+ }
30355
+ if (!isCurrent()) return;
30356
+ this.args.plane.end("the fragment child errored");
30357
+ this.deps.onChildExit?.(err);
30358
+ });
30359
+ child.once("exit", (code, signal) => {
30360
+ if (!settled) {
30361
+ settle(/* @__PURE__ */ new Error(`fmp4 fragment child: ffmpeg exited before any fragment (code=${code} signal=${signal})`));
30362
+ return;
30363
+ }
30364
+ if (!isCurrent()) return;
30365
+ const error = /* @__PURE__ */ new Error(`fmp4 fragment child: ffmpeg exited while live (code=${code} signal=${signal})`);
30366
+ this.deps.logger.warn("fmp4 fragment child: ffmpeg exited while live", {
30367
+ tags: { deviceId: this.args.deviceId },
30368
+ meta: {
30369
+ sourceId: this.args.sourceId,
30370
+ code,
30371
+ signal,
30372
+ unitsOut: this.unitsOut
30373
+ }
30374
+ });
30375
+ this.args.plane.end("the fragment child exited");
30376
+ this.deps.onChildExit?.(error);
30377
+ });
30378
+ });
30379
+ }
30380
+ /**
30381
+ * The byte stream stopped being splittable. Not recoverable — the splitter
30382
+ * cannot resynchronise mid-box — so the child is a corpse and every consumer
30383
+ * has to be told, loudly, with the reason.
30384
+ */
30385
+ onFault(reason, wasLive, current, settle) {
30386
+ const error = /* @__PURE__ */ new Error(`fmp4 fragment child: ${reason}`);
30387
+ this.deps.logger.error("fmp4 fragment child: the ffmpeg output stopped parsing as fMP4", {
30388
+ tags: { deviceId: this.args.deviceId },
30389
+ meta: {
30390
+ sourceId: this.args.sourceId,
30391
+ unitsOut: this.unitsOut,
30392
+ interstitial: this.splitter.discardedInterstitialTypes,
30393
+ reason
30394
+ }
30395
+ });
30396
+ this.killChild();
30397
+ settle(error);
30398
+ if (wasLive && current) {
30399
+ this.args.plane.end("the fragment child produced unsplittable output");
30400
+ this.deps.onChildExit?.(error);
30401
+ }
30402
+ }
30403
+ killChild() {
30404
+ const child = this.child;
30405
+ this.child = null;
30406
+ if (child && !child.killed) try {
30407
+ child.kill("SIGTERM");
30408
+ } catch (err) {
30409
+ this.deps.logger.warn("fmp4 fragment child: kill error", {
30410
+ tags: { deviceId: this.args.deviceId },
30411
+ meta: {
30412
+ sourceId: this.args.sourceId,
30413
+ error: errMsg$12(err)
30414
+ }
30415
+ });
30416
+ }
30417
+ }
30418
+ };
28981
30419
  //#endregion
28982
30420
  //#region src/accessory-publisher.ts
28983
30421
  /**
@@ -42634,9 +44072,18 @@ async function buildIntercom(input) {
42634
44072
  * (`proxy.motion.isDetected({})`) when the motion cap is bound.
42635
44073
  */
42636
44074
  var RESET_DEBOUNCE_MS = 5e3;
42637
- async function buildMotionSensor(bctx) {
44075
+ /**
44076
+ * @param existing - The controller's OWN `MotionSensor`, when HomeKit Secure
44077
+ * Video is advertised. HKSV derives its `EventTriggerOption.MOTION` from the
44078
+ * service the `CameraController` created (`sensors: { motion: true }`) and is
44079
+ * blind to any other one — a second MotionSensor added here would keep working
44080
+ * as a sensor in the Home app while silently triggering no recording at all.
44081
+ * `null` when recording is off, in which case this builder owns the service as
44082
+ * it always has.
44083
+ */
44084
+ async function buildMotionSensor(bctx, existing = null) {
42638
44085
  const { ctx, accessory, proxy, numericDeviceId, displayName } = bctx;
42639
- const motionService = accessory.addService(Service.MotionSensor, hapServiceName([displayName], `Camera ${numericDeviceId}`));
44086
+ const motionService = existing ?? accessory.addService(Service.MotionSensor, hapServiceName([displayName], `Camera ${numericDeviceId}`));
42640
44087
  motionService.setCharacteristic(Characteristic.MotionDetected, false);
42641
44088
  try {
42642
44089
  const detected = await proxy.motion?.isDetected({});
@@ -43032,6 +44479,814 @@ async function probe(call, label, log) {
43032
44479
  }
43033
44480
  }
43034
44481
  //#endregion
44482
+ //#region src/hksv/recording-options.ts
44483
+ /**
44484
+ * The HomeKit Secure Video ADVERTISEMENT — `CameraRecordingOptions`, derived
44485
+ * from what the fMP4 sink will actually produce for THIS camera.
44486
+ *
44487
+ * ## The rule this file exists to enforce
44488
+ *
44489
+ * Never advertise something we cannot serve. That is not a slogan here: it is
44490
+ * the diagnosis of [D50](../../../../docs/decisions/adr-0050.md) — an
44491
+ * advertised `recording` whose delegate yielded nothing put every motion-capable
44492
+ * camera into a ~12 s timeout loop every 20-60 s, all day. So every number below
44493
+ * is derived from the picked source (`recording-source.ts`) or from a measured
44494
+ * property of the sink, and none of them is a plausible-looking constant.
44495
+ *
44496
+ * ## The fragment length is the subtle one
44497
+ *
44498
+ * HKSV requires every media fragment to be **no longer** than the length the
44499
+ * controller selected. On the copy branch the fragment length is the SOURCE's
44500
+ * key-frame cadence ([D80](../../../../docs/decisions/adr-0080.md)) — we do not
44501
+ * get to choose it, we can only be honest about it. So:
44502
+ *
44503
+ * - when the camera reports its GOP (`stream-params`), the advertised length is
44504
+ * the smallest offered value that COVERS it;
44505
+ * - when it does not, we advertise the 4000 ms every HKSV camera uses and the
44506
+ * delegate warns at `warn` with `tags: { deviceId }` if the fragments that
44507
+ * actually arrive are longer.
44508
+ *
44509
+ * A camera whose GOP exceeds the longest value we offer does not advertise
44510
+ * recording at all. See {@link deriveFragmentLengthMs}.
44511
+ */
44512
+ /**
44513
+ * The prebuffer we promise. HAP's floor is 4000 ms and its documented sensible
44514
+ * range is [4000, 8000]; the plane's ring is sized from this, so the two cannot
44515
+ * disagree. Asking for more than we retain would be the same lie in the other
44516
+ * direction.
44517
+ */
44518
+ var HKSV_PREBUFFER_MS = 4e3;
44519
+ /**
44520
+ * The fragment lengths we are willing to advertise, shortest first. 4000 ms is
44521
+ * what every shipping HKSV camera uses; 8000 exists for a camera whose GOP is
44522
+ * 8 s, which is common enough on this fleet's defaults to be worth covering
44523
+ * rather than refusing.
44524
+ */
44525
+ var HKSV_FRAGMENT_LENGTHS_MS = [4e3, 8e3];
44526
+ /**
44527
+ * AAC-LC at 24 kHz mono. Fixed rather than negotiated: an fMP4 fragment carries
44528
+ * its audio in-band, so unlike the live SRTP path there is no second plane on
44529
+ * which to answer a different sample rate, and D80 records that HKSV takes AAC
44530
+ * and nothing else.
44531
+ */
44532
+ var HKSV_AUDIO_SAMPLE_RATE_HZ = 24e3;
44533
+ /**
44534
+ * The advertised fragment length for a camera whose key-frame cadence is
44535
+ * `sourceGopMs`, or `null` when no offered length covers it.
44536
+ *
44537
+ * `undefined` — the camera does not report a GOP — takes the shortest offered
44538
+ * length. That is a guess, and it is the RIGHT guess (4 s is the near-universal
44539
+ * default), but it is a guess: the delegate measures the arriving cadence and
44540
+ * says so when reality disagrees.
44541
+ */
44542
+ function deriveFragmentLengthMs(sourceGopMs) {
44543
+ const shortest = HKSV_FRAGMENT_LENGTHS_MS[0];
44544
+ if (shortest === void 0) return null;
44545
+ if (sourceGopMs === void 0 || sourceGopMs <= 0) return shortest;
44546
+ return HKSV_FRAGMENT_LENGTHS_MS.find((ms) => ms >= sourceGopMs) ?? null;
44547
+ }
44548
+ /**
44549
+ * Build the advertisement.
44550
+ *
44551
+ * ONE resolution is advertised — the one slot the recording child pulls. HAP's
44552
+ * documentation lists 1920×1080 and 1280×720 as "required to be supported", and
44553
+ * listing both when the source is only one of them is precisely the D50 failure
44554
+ * in miniature: iOS would select a configuration we then cannot deliver, on the
44555
+ * copy branch, with no encoder to resize with.
44556
+ */
44557
+ function buildRecordingOptions(input) {
44558
+ const resolution = [
44559
+ input.width,
44560
+ input.height,
44561
+ Math.max(1, Math.round(input.fps))
44562
+ ];
44563
+ return {
44564
+ prebufferLength: HKSV_PREBUFFER_MS,
44565
+ mediaContainerConfiguration: {
44566
+ type: MediaContainerType.FRAGMENTED_MP4,
44567
+ fragmentLength: input.fragmentLengthMs
44568
+ },
44569
+ video: {
44570
+ type: VideoCodecType.H264,
44571
+ parameters: {
44572
+ profiles: [
44573
+ H264Profile.BASELINE,
44574
+ H264Profile.MAIN,
44575
+ H264Profile.HIGH
44576
+ ],
44577
+ levels: [
44578
+ H264Level.LEVEL3_1,
44579
+ H264Level.LEVEL3_2,
44580
+ H264Level.LEVEL4_0
44581
+ ]
44582
+ },
44583
+ resolutions: [resolution]
44584
+ },
44585
+ audio: { codecs: [{
44586
+ type: AudioRecordingCodecType.AAC_LC,
44587
+ audioChannels: 1,
44588
+ bitrateMode: AudioBitrate.VARIABLE,
44589
+ samplerate: [AudioRecordingSamplerate.KHZ_24]
44590
+ }] }
44591
+ };
44592
+ }
44593
+ //#endregion
44594
+ //#region src/hksv/fragment-source.ts
44595
+ /**
44596
+ * How far back the prebuffer ring reaches.
44597
+ *
44598
+ * Twice {@link HKSV_PREBUFFER_MS}, and the factor is structural rather than
44599
+ * generous: the ring holds WHOLE fragments, so a window of exactly 4 s can hold
44600
+ * a single 4 s fragment that is about to age out — a trigger landing a moment
44601
+ * later would replay nothing. Two fragment lengths guarantee at least one
44602
+ * covering fragment at every instant.
44603
+ */
44604
+ var PREBUFFER_WINDOW_MS = HKSV_PREBUFFER_MS * 2;
44605
+ /**
44606
+ * The ring's hard byte ceiling, per camera.
44607
+ *
44608
+ * Measured fragment sizes on this fleet: ~145 KB for 4 s at 720p, ~3.6 MB for
44609
+ * 4 s at 4K. 16 MB covers two 4K fragments with room and bounds the exporter's
44610
+ * heap at a figure an operator can multiply by the camera count — which is the
44611
+ * number a time-only bound refuses to give.
44612
+ */
44613
+ var PREBUFFER_MAX_BYTES = 16 * 1024 * 1024;
44614
+ /** Backoff after a child that died while live. Bounded, never a tight loop. */
44615
+ var RESPAWN_BACKOFF_MS = [
44616
+ 2e3,
44617
+ 5e3,
44618
+ 15e3,
44619
+ 3e4
44620
+ ];
44621
+ var HksvFragmentSource = class {
44622
+ input;
44623
+ plane = null;
44624
+ child = null;
44625
+ stopped = false;
44626
+ starting = null;
44627
+ respawnAttempt = 0;
44628
+ respawnTimer = null;
44629
+ audioActive;
44630
+ log;
44631
+ constructor(input) {
44632
+ this.input = input;
44633
+ this.audioActive = input.audioActive;
44634
+ this.log = input.logger;
44635
+ }
44636
+ /** True once a child has produced its initialisation segment. */
44637
+ get isRunning() {
44638
+ return this.child !== null && this.plane !== null && !this.plane.isEnded;
44639
+ }
44640
+ /**
44641
+ * Spawn the child and start filling the ring. Idempotent, and concurrent
44642
+ * calls share one attempt — `updateRecordingActive(true)` and a stream
44643
+ * request can arrive in either order.
44644
+ */
44645
+ async start() {
44646
+ if (this.stopped) throw new Error("hksv fragment source: already stopped");
44647
+ if (this.isRunning) return;
44648
+ const inflight = this.starting;
44649
+ if (inflight !== null) return inflight;
44650
+ const attempt = this.spawn();
44651
+ this.starting = attempt;
44652
+ try {
44653
+ await attempt;
44654
+ } finally {
44655
+ this.starting = null;
44656
+ }
44657
+ }
44658
+ /** Stop the child, end the plane, forget the ring. Idempotent. */
44659
+ async stop(reason) {
44660
+ if (this.stopped) return;
44661
+ this.stopped = true;
44662
+ this.clearRespawn();
44663
+ this.log.info("hksv fragment source: stopping", {
44664
+ tags: { deviceId: this.input.deviceId },
44665
+ meta: {
44666
+ reason,
44667
+ brokerId: this.input.source.brokerId
44668
+ }
44669
+ });
44670
+ const child = this.child;
44671
+ this.child = null;
44672
+ if (child) await child.stop();
44673
+ this.plane?.dispose();
44674
+ this.plane = null;
44675
+ }
44676
+ /**
44677
+ * iOS turned recording audio on or off. Respawns onto the other url when it
44678
+ * genuinely changed — the fragments themselves must carry or omit the track,
44679
+ * there is nothing to strip downstream.
44680
+ */
44681
+ async setAudioActive(active) {
44682
+ if (active === this.audioActive) return;
44683
+ this.audioActive = active;
44684
+ this.log.info("hksv fragment source: RecordingAudioActive changed — respawning the child", {
44685
+ tags: { deviceId: this.input.deviceId },
44686
+ meta: {
44687
+ audioActive: active,
44688
+ brokerId: this.input.source.brokerId
44689
+ }
44690
+ });
44691
+ if (!this.isRunning) return;
44692
+ const child = this.child;
44693
+ this.child = null;
44694
+ if (child) await child.stop();
44695
+ this.plane?.dispose();
44696
+ this.plane = null;
44697
+ await this.start();
44698
+ }
44699
+ /**
44700
+ * Subscribe to the live fragments, replaying the prebuffer first.
44701
+ *
44702
+ * Returns `null` when there is no plane — the caller MUST treat that as
44703
+ * "cannot serve this recording" rather than opening an HDS stream it cannot
44704
+ * feed, which is the D50 failure exactly.
44705
+ */
44706
+ subscribe(tag) {
44707
+ const plane = this.plane;
44708
+ if (plane === null || plane.isEnded) return null;
44709
+ const stats = plane.prebufferStats();
44710
+ this.log.info("hksv fragment source: subscribing a recording stream", {
44711
+ tags: { deviceId: this.input.deviceId },
44712
+ meta: {
44713
+ tag,
44714
+ prebufferFragments: stats.fragments,
44715
+ prebufferBytes: stats.bytes,
44716
+ prebufferSpanMs: stats.spanMs
44717
+ }
44718
+ });
44719
+ return plane.subscribe({
44720
+ tag,
44721
+ withPrebuffer: true
44722
+ });
44723
+ }
44724
+ /** What the ring holds — surfaced so the delegate can log what it served. */
44725
+ prebufferSpanMs() {
44726
+ return this.plane?.prebufferStats().spanMs ?? 0;
44727
+ }
44728
+ async spawn() {
44729
+ const plane = new Fmp4FragmentPlane(this.log.child("hksv-plane"), {
44730
+ windowMs: PREBUFFER_WINDOW_MS,
44731
+ maxBytes: PREBUFFER_MAX_BYTES
44732
+ }, this.input.now ?? Date.now);
44733
+ const child = new Fmp4FragmentChild({
44734
+ logger: this.log.child("hksv-fmp4"),
44735
+ ffmpegBinaryPath: this.input.ffmpegBinaryPath,
44736
+ spawnFn: this.input.spawnFn,
44737
+ onChildExit: (error) => this.onChildExit(error)
44738
+ }, {
44739
+ sourceId: `hksv/${this.input.deviceId}`,
44740
+ deviceId: this.input.deviceId,
44741
+ fragmentMs: this.input.fragmentMs,
44742
+ invocation: this.buildInvocation(),
44743
+ plane
44744
+ });
44745
+ this.plane = plane;
44746
+ this.child = child;
44747
+ try {
44748
+ await child.start();
44749
+ this.respawnAttempt = 0;
44750
+ this.log.info("hksv fragment source: prebuffer running", {
44751
+ tags: { deviceId: this.input.deviceId },
44752
+ meta: {
44753
+ brokerId: this.input.source.brokerId,
44754
+ resolution: `${this.input.source.width}x${this.input.source.height}`,
44755
+ fragmentMs: this.input.fragmentMs,
44756
+ audioActive: this.audioActive,
44757
+ windowMs: PREBUFFER_WINDOW_MS
44758
+ }
44759
+ });
44760
+ } catch (err) {
44761
+ this.plane = null;
44762
+ this.child = null;
44763
+ plane.dispose();
44764
+ throw err;
44765
+ }
44766
+ }
44767
+ /**
44768
+ * The child died while live. The prebuffer is gone with it — and saying so is
44769
+ * the point: a source that silently stopped filling reads, from the delegate,
44770
+ * exactly like a camera nothing ever happens on.
44771
+ */
44772
+ onChildExit(error) {
44773
+ if (this.stopped) return;
44774
+ this.child = null;
44775
+ this.plane = null;
44776
+ const delay = RESPAWN_BACKOFF_MS[Math.min(this.respawnAttempt, RESPAWN_BACKOFF_MS.length - 1)];
44777
+ this.respawnAttempt += 1;
44778
+ this.log.warn("hksv fragment source: the child DIED — the prebuffer is empty until it respawns", {
44779
+ tags: { deviceId: this.input.deviceId },
44780
+ meta: {
44781
+ brokerId: this.input.source.brokerId,
44782
+ attempt: this.respawnAttempt,
44783
+ respawnInMs: delay,
44784
+ error: error.message
44785
+ }
44786
+ });
44787
+ this.clearRespawn();
44788
+ const schedule = this.input.setTimeoutFn ?? setTimeout;
44789
+ this.respawnTimer = schedule(() => {
44790
+ this.respawnTimer = null;
44791
+ if (this.stopped) return;
44792
+ this.start().catch((err) => {
44793
+ this.log.warn("hksv fragment source: respawn failed", {
44794
+ tags: { deviceId: this.input.deviceId },
44795
+ meta: {
44796
+ brokerId: this.input.source.brokerId,
44797
+ error: err instanceof Error ? err.message : String(err)
44798
+ }
44799
+ });
44800
+ });
44801
+ }, delay ?? 3e4);
44802
+ this.respawnTimer?.unref?.();
44803
+ }
44804
+ clearRespawn() {
44805
+ if (this.respawnTimer !== null) {
44806
+ clearTimeout(this.respawnTimer);
44807
+ this.respawnTimer = null;
44808
+ }
44809
+ }
44810
+ /**
44811
+ * The invocation, minus the sink the child owns.
44812
+ *
44813
+ * `kind: 'copy'` is not a preference: it is the 128× measurement, and it is
44814
+ * why `pickRecordingSource` refuses a camera whose only slots are H.265. The
44815
+ * audio IS encoded — the source mic is G.711/PCM depending on vendor and HKSV
44816
+ * takes AAC only — which the same measurement priced at 0.4 % of a core.
44817
+ */
44818
+ buildInvocation() {
44819
+ const audio = this.audioActive ? {
44820
+ kind: "encode",
44821
+ codec: "aac",
44822
+ bitrateKbps: 32,
44823
+ sampleRateHz: HKSV_AUDIO_SAMPLE_RATE_HZ,
44824
+ channels: 1
44825
+ } : { kind: "none" };
44826
+ return {
44827
+ logLevel: "error",
44828
+ decodeHwAccel: null,
44829
+ input: {
44830
+ url: this.audioActive ? this.input.source.url : this.input.source.mutedUrl,
44831
+ rtspTransport: "tcp",
44832
+ analyzeDurationUs: 1e6,
44833
+ probeSizeBytes: 1e6
44834
+ },
44835
+ video: { kind: "copy" },
44836
+ audio,
44837
+ threadCount: 0,
44838
+ outputArgs: []
44839
+ };
44840
+ }
44841
+ };
44842
+ //#endregion
44843
+ //#region src/hksv/recording-delegate.ts
44844
+ /**
44845
+ * `HDSProtocolSpecificErrorReason` is a `const enum`, so there is no reverse
44846
+ * map to index — and a bare number in the log is the difference between "iOS
44847
+ * closed it normally" and "iOS rejected our data", which is the whole reason
44848
+ * this line exists.
44849
+ */
44850
+ var HDS_REASON_NAMES = {
44851
+ [HDSProtocolSpecificErrorReason.NORMAL]: "normal",
44852
+ [HDSProtocolSpecificErrorReason.NOT_ALLOWED]: "not-allowed",
44853
+ [HDSProtocolSpecificErrorReason.BUSY]: "busy",
44854
+ [HDSProtocolSpecificErrorReason.CANCELLED]: "cancelled",
44855
+ [HDSProtocolSpecificErrorReason.UNSUPPORTED]: "unsupported",
44856
+ [HDSProtocolSpecificErrorReason.UNEXPECTED_FAILURE]: "unexpected-failure",
44857
+ [HDSProtocolSpecificErrorReason.TIMEOUT]: "timeout",
44858
+ [HDSProtocolSpecificErrorReason.BAD_DATA]: "bad-data",
44859
+ [HDSProtocolSpecificErrorReason.PROTOCOL_ERROR]: "protocol-error",
44860
+ [HDSProtocolSpecificErrorReason.INVALID_CONFIGURATION]: "invalid-configuration"
44861
+ };
44862
+ function hdsReasonName(reason) {
44863
+ return HDS_REASON_NAMES[reason] ?? `unknown(${String(reason)})`;
44864
+ }
44865
+ var HksvRecordingDelegate = class {
44866
+ input;
44867
+ active = false;
44868
+ configuration = void 0;
44869
+ source = null;
44870
+ log;
44871
+ /** The stream currently being yielded, so `closeRecordingStream` can end it. */
44872
+ open = null;
44873
+ constructor(input) {
44874
+ this.input = input;
44875
+ this.log = input.logger;
44876
+ }
44877
+ /** Test/diagnostic view — the prebuffer is running for this camera. */
44878
+ get prebufferRunning() {
44879
+ return this.source?.isRunning === true;
44880
+ }
44881
+ updateRecordingActive(active) {
44882
+ if (active === this.active) return;
44883
+ this.active = active;
44884
+ this.log.info("hksv: recording active changed", {
44885
+ tags: { deviceId: this.input.deviceId },
44886
+ meta: {
44887
+ active,
44888
+ hasConfiguration: this.configuration !== void 0
44889
+ }
44890
+ });
44891
+ this.reconcile("recording-active");
44892
+ }
44893
+ updateRecordingConfiguration(configuration) {
44894
+ this.configuration = configuration;
44895
+ if (configuration === void 0) {
44896
+ this.log.info("hksv: the selected configuration was CLEARED — stopping the prebuffer", { tags: { deviceId: this.input.deviceId } });
44897
+ this.reconcile("configuration-cleared");
44898
+ return;
44899
+ }
44900
+ const selectedMs = configuration.mediaContainerConfiguration.fragmentLength;
44901
+ this.log.info("hksv: iOS selected a recording configuration", {
44902
+ tags: { deviceId: this.input.deviceId },
44903
+ meta: {
44904
+ fragmentLengthMs: selectedMs,
44905
+ prebufferLengthMs: configuration.prebufferLength,
44906
+ resolution: configuration.videoCodec.resolution.join("x"),
44907
+ audioCodec: configuration.audioCodec.type,
44908
+ eventTriggers: configuration.eventTriggerTypes
44909
+ }
44910
+ });
44911
+ if (selectedMs < this.input.advertisedFragmentMs) this.log.warn("hksv: iOS selected a SHORTER fragment length than the source can cut", {
44912
+ tags: { deviceId: this.input.deviceId },
44913
+ meta: {
44914
+ selectedMs,
44915
+ advertisedMs: this.input.advertisedFragmentMs
44916
+ }
44917
+ });
44918
+ this.reconcile("configuration-selected");
44919
+ }
44920
+ async *handleRecordingStreamRequest(streamId, signal) {
44921
+ const source = this.source;
44922
+ if (source === null || !source.isRunning) {
44923
+ this.log.warn("hksv: recording stream requested with NO prebuffer running — refusing", {
44924
+ tags: { deviceId: this.input.deviceId },
44925
+ meta: {
44926
+ streamId,
44927
+ active: this.active,
44928
+ hasConfiguration: this.configuration !== void 0
44929
+ }
44930
+ });
44931
+ throw new HDSProtocolError(HDSProtocolSpecificErrorReason.NOT_ALLOWED);
44932
+ }
44933
+ const subscription = source.subscribe(`hksv/${this.input.deviceId}#${streamId}`);
44934
+ if (subscription === null) {
44935
+ this.log.warn("hksv: the fragment plane refused a subscription — refusing the stream", {
44936
+ tags: { deviceId: this.input.deviceId },
44937
+ meta: { streamId }
44938
+ });
44939
+ throw new HDSProtocolError(HDSProtocolSpecificErrorReason.NOT_ALLOWED);
44940
+ }
44941
+ this.open = {
44942
+ streamId,
44943
+ subscription
44944
+ };
44945
+ const startedAt = Date.now();
44946
+ const prebufferSpanMs = source.prebufferSpanMs();
44947
+ let packets = 0;
44948
+ let bytes = 0;
44949
+ let markedLast = false;
44950
+ let longestFragmentGapMs = 0;
44951
+ let lastPacketAt = startedAt;
44952
+ try {
44953
+ for await (const packet of subscription.packets()) {
44954
+ if (signal?.aborted === true) {
44955
+ this.log.info("hksv: the recording stream was aborted — ending the generator", {
44956
+ tags: { deviceId: this.input.deviceId },
44957
+ meta: {
44958
+ streamId,
44959
+ packets
44960
+ }
44961
+ });
44962
+ return;
44963
+ }
44964
+ packets += 1;
44965
+ bytes += packet.data.length;
44966
+ if (packet.kind === "fragment") {
44967
+ const now = Date.now();
44968
+ longestFragmentGapMs = Math.max(longestFragmentGapMs, now - lastPacketAt);
44969
+ lastPacketAt = now;
44970
+ }
44971
+ markedLast = markedLast || packet.isLast;
44972
+ yield {
44973
+ data: Buffer.from(packet.data),
44974
+ isLast: packet.isLast
44975
+ };
44976
+ if (packet.isLast) return;
44977
+ }
44978
+ if (!markedLast && subscription.closedReason !== "released") {
44979
+ this.log.warn("hksv: the fragment stream ended without a final packet", {
44980
+ tags: { deviceId: this.input.deviceId },
44981
+ meta: {
44982
+ streamId,
44983
+ packets,
44984
+ closedReason: subscription.closedReason,
44985
+ truncated: subscription.closedReason === "slow-consumer"
44986
+ }
44987
+ });
44988
+ if (packets === 0) throw new HDSProtocolError(HDSProtocolSpecificErrorReason.UNEXPECTED_FAILURE);
44989
+ yield {
44990
+ data: Buffer.alloc(0),
44991
+ isLast: true
44992
+ };
44993
+ }
44994
+ } finally {
44995
+ subscription.release();
44996
+ if (this.open?.streamId === streamId) this.open = null;
44997
+ const fragmentOverrun = longestFragmentGapMs > this.input.advertisedFragmentMs * 1.5;
44998
+ this.log.info("hksv: recording stream finished", {
44999
+ tags: { deviceId: this.input.deviceId },
45000
+ meta: {
45001
+ streamId,
45002
+ packets,
45003
+ bytes,
45004
+ durationMs: Date.now() - startedAt,
45005
+ prebufferSpanMs,
45006
+ longestFragmentGapMs,
45007
+ closedReason: subscription.closedReason,
45008
+ markedLast
45009
+ }
45010
+ });
45011
+ if (fragmentOverrun) this.log.warn("hksv: fragments arrived LONGER than the advertised length", {
45012
+ tags: { deviceId: this.input.deviceId },
45013
+ meta: {
45014
+ longestFragmentGapMs,
45015
+ advertisedMs: this.input.advertisedFragmentMs
45016
+ }
45017
+ });
45018
+ }
45019
+ }
45020
+ acknowledgeStream(streamId) {
45021
+ this.log.info("hksv: iOS acknowledged the end of stream — the clip landed", {
45022
+ tags: { deviceId: this.input.deviceId },
45023
+ meta: { streamId }
45024
+ });
45025
+ }
45026
+ closeRecordingStream(streamId, reason) {
45027
+ this.log.info("hksv: the recording stream was closed by the controller", {
45028
+ tags: { deviceId: this.input.deviceId },
45029
+ meta: {
45030
+ streamId,
45031
+ reason: reason === void 0 ? "connection-closed" : hdsReasonName(reason)
45032
+ }
45033
+ });
45034
+ const open = this.open;
45035
+ if (open?.streamId === streamId) {
45036
+ open.subscription.release();
45037
+ this.open = null;
45038
+ }
45039
+ }
45040
+ /** Tear the prebuffer down — the accessory is being unexposed. */
45041
+ async dispose() {
45042
+ this.open?.subscription.release();
45043
+ this.open = null;
45044
+ const source = this.source;
45045
+ this.source = null;
45046
+ if (source) await source.stop("accessory disposed");
45047
+ }
45048
+ /**
45049
+ * Start the prebuffer when iOS wants recording AND has chosen how, stop it
45050
+ * otherwise. Called from every state edge rather than each edge deciding for
45051
+ * itself: the two characteristics arrive in an order hap-nodejs explicitly
45052
+ * does not guarantee, and a per-edge decision has to re-derive the same
45053
+ * conjunction in two places.
45054
+ */
45055
+ async reconcile(trigger) {
45056
+ if (!(this.active && this.configuration !== void 0)) {
45057
+ const source = this.source;
45058
+ this.source = null;
45059
+ if (source) await source.stop(`recording no longer wanted (${trigger})`);
45060
+ return;
45061
+ }
45062
+ const configuration = this.configuration;
45063
+ if (configuration === void 0) return;
45064
+ const audioActive = this.input.isAudioActive();
45065
+ const existing = this.source;
45066
+ if (existing !== null) {
45067
+ await existing.setAudioActive(audioActive);
45068
+ if (!existing.isRunning) await existing.start();
45069
+ return;
45070
+ }
45071
+ const source = this.input.createSource({
45072
+ fragmentMs: configuration.mediaContainerConfiguration.fragmentLength,
45073
+ audioActive
45074
+ });
45075
+ this.source = source;
45076
+ try {
45077
+ await source.start();
45078
+ } catch (err) {
45079
+ this.source = null;
45080
+ this.log.error("hksv: the prebuffer FAILED to start — this camera will record nothing", {
45081
+ tags: { deviceId: this.input.deviceId },
45082
+ meta: {
45083
+ trigger,
45084
+ error: err instanceof Error ? err.message : String(err)
45085
+ }
45086
+ });
45087
+ }
45088
+ }
45089
+ };
45090
+ //#endregion
45091
+ //#region src/hksv/recording-source.ts
45092
+ /** The tallest frame the recording path will hold in its prebuffer. */
45093
+ var MAX_RECORDING_HEIGHT = 1080;
45094
+ /** HKSV takes H.264 only — AAC audio and H.264 video, no negotiation. */
45095
+ var RECORDABLE_CODEC = "h264";
45096
+ /**
45097
+ * Pick the slot the recording child pulls.
45098
+ *
45099
+ * Deliberately NOT `pickPreferredRtspEntry`: that picker resolves the operator's
45100
+ * LIVE preference and, on `auto`, steers by the resolution iOS negotiated for a
45101
+ * live session — neither is a fact about recording, and on 615 it selects `mid`,
45102
+ * a 10 fps slot. Recording has one criterion, applied here and nowhere else:
45103
+ * the largest copyable frame that does not exceed {@link MAX_RECORDING_HEIGHT}.
45104
+ */
45105
+ function pickRecordingSource(entries) {
45106
+ const enabled = entries.filter((e) => e.enabled);
45107
+ if (enabled.length === 0) return {
45108
+ ok: false,
45109
+ refusal: "no-enabled-stream"
45110
+ };
45111
+ const h264 = enabled.filter((e) => normaliseCodec(e.codec) === RECORDABLE_CODEC);
45112
+ if (h264.length === 0) return {
45113
+ ok: false,
45114
+ refusal: "no-h264-stream"
45115
+ };
45116
+ const sized = h264.filter(hasUsableResolution);
45117
+ if (sized.length === 0) return {
45118
+ ok: false,
45119
+ refusal: "no-resolution"
45120
+ };
45121
+ const withinCeiling = sized.filter((e) => height(e) <= MAX_RECORDING_HEIGHT);
45122
+ const best = [...withinCeiling.length > 0 ? withinCeiling : sized].sort((a, b) => withinCeiling.length > 0 ? height(b) - height(a) : height(a) - height(b))[0];
45123
+ if (best === void 0 || best.resolution === void 0) return {
45124
+ ok: false,
45125
+ refusal: "no-resolution"
45126
+ };
45127
+ return {
45128
+ ok: true,
45129
+ source: {
45130
+ brokerId: best.brokerId,
45131
+ profile: best.profile ?? best.brokerId,
45132
+ url: best.url,
45133
+ mutedUrl: best.mutedUrl,
45134
+ width: best.resolution.width,
45135
+ height: best.resolution.height
45136
+ }
45137
+ };
45138
+ }
45139
+ /** A one-line reason for the log — silence about a withdrawn service reads as a bug. */
45140
+ function refusalReason(refusal) {
45141
+ switch (refusal) {
45142
+ case "no-enabled-stream": return "the camera has no enabled RTSP profile";
45143
+ case "no-h264-stream": return "every enabled profile is H.265 — HKSV takes H.264 only, and a permanent transcode costs 128x a copy";
45144
+ case "no-resolution": return "no enabled profile declares a resolution, so nothing honest could be advertised";
45145
+ }
45146
+ }
45147
+ function height(entry) {
45148
+ return entry.resolution?.height ?? 0;
45149
+ }
45150
+ function hasUsableResolution(entry) {
45151
+ const r = entry.resolution;
45152
+ return r !== void 0 && r.width > 0 && r.height > 0;
45153
+ }
45154
+ /** Publishers spell H.265 four ways; the same normalisation the broker uses. */
45155
+ function normaliseCodec(codec) {
45156
+ return (codec ?? "").toLowerCase().replace(/[.\s-]/g, "");
45157
+ }
45158
+ //#endregion
45159
+ //#region src/hksv/build-recording.ts
45160
+ /**
45161
+ * Assemble HomeKit Secure Video for one camera — the ADVERTISEMENT and the
45162
+ * DELEGATE, together, or neither.
45163
+ *
45164
+ * That pairing is the whole rule and it is why nothing shipped for HKSV before
45165
+ * this: `recording` is optional on `CameraControllerOptions`, and passing it IS
45166
+ * the entire user-visible change. There is no "phase 1 behind a flag" for an
45167
+ * advertisement — either iOS is offered a recording toggle backed by a delegate
45168
+ * that yields real fragments, or the services are not on the accessory at all
45169
+ * ([D50](../../../../docs/decisions/adr-0050.md)).
45170
+ *
45171
+ * So this returns `null` for every reason a camera cannot record, and each of
45172
+ * them is logged at `info`/`warn` with `tags: { deviceId }`. A withdrawn
45173
+ * capability that says nothing is indistinguishable from a bug — and on this
45174
+ * surface the operator's first question is always "why does 617 have it and 615
45175
+ * not?".
45176
+ */
45177
+ /**
45178
+ * The ffmpeg on `PATH`, exactly as the live streaming path resolves it
45179
+ * (`camera-streams.ts` spawns `'ffmpeg'`). One resolution per addon, not two.
45180
+ */
45181
+ var FFMPEG_BINARY = "ffmpeg";
45182
+ /** Fallback when nothing measured a rate for the picked slot. */
45183
+ var ASSUMED_RECORDING_FPS = 15;
45184
+ async function buildHksvRecording(input) {
45185
+ const { bctx } = input;
45186
+ const { ctx, numericDeviceId } = bctx;
45187
+ const log = ctx.logger.withTags({ deviceId: numericDeviceId });
45188
+ const entries = await readProfileEntries(bctx);
45189
+ if (entries === null) {
45190
+ log.warn("export-hap: HKSV withheld — could not read the camera profiles", {});
45191
+ return null;
45192
+ }
45193
+ const choice = pickRecordingSource(entries);
45194
+ if (!choice.ok) {
45195
+ log.info("export-hap: HKSV withheld — no recordable stream", { meta: {
45196
+ refusal: choice.refusal,
45197
+ reason: refusalReason(choice.refusal)
45198
+ } });
45199
+ return null;
45200
+ }
45201
+ const source = choice.source;
45202
+ const gopMs = await readSourceGopMs(bctx, source.width, source.height);
45203
+ const fragmentLengthMs = deriveFragmentLengthMs(gopMs);
45204
+ if (fragmentLengthMs === null) {
45205
+ log.warn("export-hap: HKSV withheld — the camera key-frame interval is longer than any fragment length we advertise", { meta: {
45206
+ gopMs,
45207
+ brokerId: source.brokerId
45208
+ } });
45209
+ return null;
45210
+ }
45211
+ const fps = resolveFps(input.fpsByProfile, source.profile);
45212
+ const options = buildRecordingOptions({
45213
+ width: source.width,
45214
+ height: source.height,
45215
+ fps,
45216
+ fragmentLengthMs
45217
+ });
45218
+ const delegate = new HksvRecordingDelegate({
45219
+ logger: log,
45220
+ deviceId: numericDeviceId,
45221
+ isAudioActive: input.isAudioActive,
45222
+ advertisedFragmentMs: fragmentLengthMs,
45223
+ createSource: ({ fragmentMs, audioActive }) => new HksvFragmentSource({
45224
+ logger: log,
45225
+ deviceId: numericDeviceId,
45226
+ ffmpegBinaryPath: FFMPEG_BINARY,
45227
+ spawnFn: spawn,
45228
+ source,
45229
+ fragmentMs,
45230
+ audioActive
45231
+ })
45232
+ });
45233
+ log.info("export-hap: HKSV ADVERTISED — recording is offered for this camera", { meta: {
45234
+ brokerId: source.brokerId,
45235
+ profile: source.profile,
45236
+ resolution: `${source.width}x${source.height}`,
45237
+ fps,
45238
+ fragmentLengthMs,
45239
+ sourceGopMs: gopMs ?? "unknown"
45240
+ } });
45241
+ return {
45242
+ options,
45243
+ delegate,
45244
+ dispose: () => delegate.dispose()
45245
+ };
45246
+ }
45247
+ /** `cameraStreams.getProfileRtspEntries`, or `null` when the cap is unreachable. */
45248
+ async function readProfileEntries(bctx) {
45249
+ try {
45250
+ return await bctx.proxy.cameraStreams?.getProfileRtspEntries({}) ?? null;
45251
+ } catch {
45252
+ return null;
45253
+ }
45254
+ }
45255
+ /**
45256
+ * The camera's own key-frame interval in ms, from `stream-params`, matched to
45257
+ * the picked slot BY RESOLUTION.
45258
+ *
45259
+ * By resolution and not by name on purpose: `stream-params` names its profiles
45260
+ * `main`/`sub`/`ext` while the broker names its slots `high`/`mid`/`low`, and
45261
+ * on 615 `ext` is the 1280×720 slot the broker calls `mid` — a name-based match
45262
+ * would silently read the 4K slot's GOP for a 720p recording.
45263
+ *
45264
+ * `undefined` when the cap is not bound, which is most non-Hikvision providers.
45265
+ */
45266
+ async function readSourceGopMs(bctx, width, height) {
45267
+ try {
45268
+ const status = await bctx.proxy.streamParams?.getStatus({});
45269
+ if (!status) return void 0;
45270
+ for (const profile of [
45271
+ status.main,
45272
+ status.sub,
45273
+ status.ext
45274
+ ]) {
45275
+ if (!profile) continue;
45276
+ if (profile.width !== width || profile.height !== height) continue;
45277
+ const { gop, framerate } = profile;
45278
+ if (gop === void 0 || gop <= 0 || framerate <= 0) return void 0;
45279
+ return Math.round(gop / framerate * 1e3);
45280
+ }
45281
+ return;
45282
+ } catch {
45283
+ return;
45284
+ }
45285
+ }
45286
+ function resolveFps(fpsByProfile, profile) {
45287
+ return fpsByProfile.get(profile)?.fps ?? ASSUMED_RECORDING_FPS;
45288
+ }
45289
+ //#endregion
43035
45290
  //#region src/mappers/builders/child-switch.ts
43036
45291
  /**
43037
45292
  * Child-switch builder — turns a camstack accessory child device (siren,
@@ -43237,19 +45492,39 @@ async function buildCameraAccessory(input) {
43237
45492
  displayName,
43238
45493
  options
43239
45494
  };
43240
- const streams = buildCameraStreamingDelegate(bctx, await probeAdvertisedVideoProfile(bctx));
45495
+ const advertisedVideo = await probeAdvertisedVideoProfile(bctx);
45496
+ const streams = buildCameraStreamingDelegate(bctx, advertisedVideo);
43241
45497
  const handles = [];
43242
45498
  if (capNames.has("intercom")) handles.push(await buildIntercom({
43243
45499
  bctx,
43244
45500
  streamingOptions: streams.streamingOptions
43245
45501
  }));
45502
+ const recordingEnabled = options.hapDeviceSettings.hksvRecording === true;
45503
+ let recordingAudioActive = true;
45504
+ const recording = recordingEnabled ? await buildHksvRecording({
45505
+ bctx,
45506
+ fpsByProfile: advertisedVideo.fpsByProfile,
45507
+ isAudioActive: () => recordingAudioActive
45508
+ }) : null;
43246
45509
  const controller = new (isDoorbell ? DoorbellController : CameraController)({
43247
45510
  delegate: streams.delegate,
43248
45511
  streamingOptions: streams.streamingOptions,
43249
- cameraStreamCount: 2
45512
+ cameraStreamCount: 2,
45513
+ ...recording === null ? {} : { recording },
45514
+ ...recording === null || !capNames.has("motion-detection") ? {} : { sensors: { motion: true } }
43250
45515
  });
43251
45516
  accessory.configureController(controller);
43252
- if (capNames.has("motion-detection")) handles.push(await buildMotionSensor(bctx));
45517
+ if (recording !== null) {
45518
+ handles.push({ dispose: () => recording.dispose() });
45519
+ const audioCharacteristic = (controller.recordingManagement?.operatingModeService)?.getCharacteristic(Characteristic.RecordingAudioActive);
45520
+ if (audioCharacteristic) {
45521
+ recordingAudioActive = audioCharacteristic.value !== 0 && audioCharacteristic.value !== false;
45522
+ audioCharacteristic.on("change", ({ newValue }) => {
45523
+ recordingAudioActive = newValue !== 0 && newValue !== false;
45524
+ });
45525
+ }
45526
+ }
45527
+ if (capNames.has("motion-detection")) handles.push(await buildMotionSensor(bctx, recording === null ? null : controller.motionService ?? null));
43253
45528
  if (isDoorbell && controller instanceof DoorbellController) handles.push(await buildDoorbell({
43254
45529
  bctx,
43255
45530
  controller
@@ -43494,7 +45769,23 @@ function syncStateToJson(map) {
43494
45769
  * intercom upload bridge, HomeKit Secure Video, recording, native
43495
45770
  * H.264 stream tap (currently uses RTSP + ffmpeg copy).
43496
45771
  */
43497
- var DEFAULT_DEVICE_SETTINGS = { streamPreference: "auto" };
45772
+ var DEFAULT_DEVICE_SETTINGS = {
45773
+ streamPreference: "auto",
45774
+ hksvRecording: true
45775
+ };
45776
+ /**
45777
+ * ON unless explicitly switched off — operator decision 2026-08-08 (flipped
45778
+ * from the launch default of off). ABSENT must resolve to ON or the flip is a
45779
+ * lie for every entry persisted before the field existed, so every read goes
45780
+ * through this one resolver (`!== false`), never a scattered `=== true`. The
45781
+ * cost that made off-by-default look prudent is measured and small on the only
45782
+ * branch the recorder accepts (copy: 0.7 % of a core / ~30 MB RSS, D84), and a
45783
+ * camera the recorder cannot copy refuses recording with a logged reason
45784
+ * rather than paying for a transcode.
45785
+ */
45786
+ function resolveHksvRecording(settings) {
45787
+ return settings?.hksvRecording !== false;
45788
+ }
43498
45789
  var HAP_STREAM_PREFERENCE_OPTIONS = [
43499
45790
  {
43500
45791
  value: "auto",
@@ -43769,7 +46060,10 @@ var ExportHapAddon = class extends BaseAddon {
43769
46060
  options: {
43770
46061
  ptzPulseMs: this.config.ptzPulseMs,
43771
46062
  decodeMemos: this.decodeMemos,
43772
- hapDeviceSettings: { streamPreference: entrySettings.streamPreference ?? "auto" }
46063
+ hapDeviceSettings: {
46064
+ streamPreference: entrySettings.streamPreference ?? "auto",
46065
+ hksvRecording: resolveHksvRecording(entrySettings)
46066
+ }
43773
46067
  }
43774
46068
  });
43775
46069
  for (const accessory of mapper.accessories) await publishStandalone(accessory, {
@@ -44044,6 +46338,7 @@ var ExportHapAddon = class extends BaseAddon {
44044
46338
  const enabled = entry !== null;
44045
46339
  const enabledKey = `hap:${deviceId}:enabled`;
44046
46340
  const streamPreferenceKey = `hap:${deviceId}:streamPreference`;
46341
+ const hksvKey = `hap:${deviceId}:hksvRecording`;
44047
46342
  const mapper = this.exposed.get(String(deviceId)) ?? null;
44048
46343
  const paired = mapper ? accessoryPaired(mapper.accessory) : false;
44049
46344
  const name = entry?.displayName ?? `Device ${deviceId}`;
@@ -44111,6 +46406,19 @@ var ExportHapAddon = class extends BaseAddon {
44111
46406
  equals: true
44112
46407
  },
44113
46408
  immediate: true
46409
+ },
46410
+ {
46411
+ type: "boolean",
46412
+ key: hksvKey,
46413
+ label: "HomeKit recording (Secure Video)",
46414
+ 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).",
46415
+ style: "switch",
46416
+ value: resolveHksvRecording(settings),
46417
+ showWhen: {
46418
+ field: enabledKey,
46419
+ equals: true
46420
+ },
46421
+ immediate: true
44114
46422
  }
44115
46423
  ]
44116
46424
  }]
@@ -44135,12 +46443,15 @@ var ExportHapAddon = class extends BaseAddon {
44135
46443
  const wasEnabled = this.exposed.has(deviceIdStr);
44136
46444
  const enabledKey = `hap:${deviceId}:enabled`;
44137
46445
  const streamPreferenceKey = `hap:${deviceId}:streamPreference`;
46446
+ const hksvKey = `hap:${deviceId}:hksvRecording`;
44138
46447
  const enabledValue = enabledKey in patch ? Boolean(patch[enabledKey]) : wasEnabled;
44139
46448
  const streamPreferenceRaw = streamPreferenceKey in patch ? patch[streamPreferenceKey] : current?.settings?.streamPreference;
44140
46449
  const streamPreference = typeof streamPreferenceRaw === "string" && streamPreferenceRaw.trim().length > 0 ? streamPreferenceRaw : "auto";
46450
+ const hksvRecording = hksvKey in patch ? Boolean(patch[hksvKey]) : resolveHksvRecording(current?.settings);
44141
46451
  const nextSettings = {
44142
46452
  ...current?.settings ?? DEFAULT_DEVICE_SETTINGS,
44143
- streamPreference
46453
+ streamPreference,
46454
+ hksvRecording
44144
46455
  };
44145
46456
  if (!enabledValue) {
44146
46457
  if (wasEnabled) await this.unexposeDevice(deviceIdStr);
@@ -44152,17 +46463,24 @@ var ExportHapAddon = class extends BaseAddon {
44152
46463
  return { success: true };
44153
46464
  }
44154
46465
  const currentPref = current?.settings?.streamPreference ?? "auto";
46466
+ const currentHksv = resolveHksvRecording(current?.settings);
44155
46467
  await this.updateEntrySettings(deviceIdStr, nextSettings);
44156
- if (currentPref !== streamPreference) {
44157
- log.info("export-hap: streamPreference changed — refreshing accessory", { meta: {
44158
- from: currentPref,
44159
- to: streamPreference
46468
+ if (currentPref !== streamPreference || currentHksv !== hksvRecording) {
46469
+ log.info("export-hap: per-camera export settings changed — refreshing accessory", { meta: {
46470
+ streamPreference: {
46471
+ from: currentPref,
46472
+ to: streamPreference
46473
+ },
46474
+ hksvRecording: {
46475
+ from: currentHksv,
46476
+ to: hksvRecording
46477
+ }
44160
46478
  } });
44161
46479
  try {
44162
46480
  await this.unexposeDevice(deviceIdStr, { clearPairing: false });
44163
46481
  await this.exposeDevice(deviceIdStr);
44164
46482
  } catch (err) {
44165
- log.warn("export-hap: failed to refresh accessory after streamPreference change", { meta: { error: errMsg(err) } });
46483
+ log.warn("export-hap: failed to refresh accessory after a settings change", { meta: { error: errMsg(err) } });
44166
46484
  }
44167
46485
  }
44168
46486
  return { success: true };
@@ -44200,4 +46518,4 @@ function errMsg(err) {
44200
46518
  return err instanceof Error ? err.message : String(err);
44201
46519
  }
44202
46520
  //#endregion
44203
- export { ExportHapAddon, ExportHapAddon as default, unpublishAccessory as i, initHapStorage as n, publishStandalone as r, deriveUsername as t };
46521
+ export { ExportHapAddon, ExportHapAddon as default, unpublishAccessory as i, initHapStorage as n, publishStandalone as r, resolveHksvRecording, deriveUsername as t };