@camstack/addon-export-hap 1.2.126 → 1.2.128

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.
@@ -36,54 +36,18 @@ var __toESM = (mod, isNodeMode, target) => (target = mod != null ? __create(__ge
36
36
  }) : target, mod));
37
37
  var __toCommonJS = (mod) => __hasOwnProp.call(mod, "module.exports") ? mod["module.exports"] : __copyProps(__defProp({}, "__esModule", { value: true }), mod);
38
38
  //#endregion
39
+ let node_child_process = require("node:child_process");
39
40
  let node_crypto = require("node:crypto");
40
41
  let node_path = require("node:path");
41
42
  __toESM(node_path, 1);
42
43
  node_path = __toESM(node_path);
43
44
  let node_fs = require("node:fs");
44
45
  node_fs = __toESM(node_fs, 1);
45
- let node_child_process = require("node:child_process");
46
46
  let node_fs_promises = require("node:fs/promises");
47
47
  node_fs_promises = __toESM(node_fs_promises, 1);
48
+ let node_events = require("node:events");
48
49
  let node_dgram = require("node:dgram");
49
50
  let node_os = require("node:os");
50
- //#region src/exposed-entry.ts
51
- /**
52
- * Carrying an exposed-device entry across a re-expose.
53
- *
54
- * `exposeDevice` rebuilds its entry from scratch — display name, mapper kind,
55
- * timestamp — and then REPLACES the stored one. Anything the rebuilt object
56
- * does not mention is therefore destroyed, and two things it never mentioned
57
- * were the per-camera settings and the capability list.
58
- *
59
- * The visible cost: the operator's "Source stream (HomeKit)" selector writes
60
- * `low`, the addon logs `streamPreference changed — refreshing accessory
61
- * {from=auto to=low}`, and the accessory that comes back derives its
62
- * advertisement from DEFAULTS — `streamPreference=auto` — because the settings
63
- * were dropped between the write and the rebuild. The selector only ever took
64
- * effect after a full addon restart, when the settings were loaded first. A
65
- * second write after the re-expose hid this: the store ended up correct, so
66
- * nothing looked wrong except the stream nobody could explain.
67
- */
68
- /**
69
- * Fill `base` from `existing` for the given keys, letting `base` win wherever
70
- * it actually says something.
71
- *
72
- * That asymmetry is the point: a caller who passes `capabilities` is stating a
73
- * new truth and must not be overruled by the stored copy, while a caller who
74
- * says nothing about `settings` is not asking for them to be erased.
75
- */
76
- function carryForward(base, existing, keys) {
77
- if (existing === void 0) return base;
78
- const out = { ...base };
79
- for (const key of keys) {
80
- if (out[key] !== void 0) continue;
81
- const carried = existing[key];
82
- if (carried !== void 0) out[key] = carried;
83
- }
84
- return out;
85
- }
86
- //#endregion
87
51
  //#region ../types/dist/event-category-BVDXG4tB.mjs
88
52
  var EventCategory = /* @__PURE__ */ function(EventCategory) {
89
53
  EventCategory["SystemBoot"] = "system.boot";
@@ -5445,7 +5409,7 @@ var ZodIssueCode = {
5445
5409
  var ZodFirstPartyTypeKind;
5446
5410
  ZodFirstPartyTypeKind || (ZodFirstPartyTypeKind = {});
5447
5411
  //#endregion
5448
- //#region ../types/dist/sleep-Bs3xPtSh.mjs
5412
+ //#region ../types/dist/sleep-CiR4z7r9.mjs
5449
5413
  /**
5450
5414
  * The audio chunk plane's byte format, and the ONE expansion from a coded
5451
5415
  * window to float samples (D455).
@@ -6587,6 +6551,17 @@ var CamProfileSchema = _enum([
6587
6551
  "mid",
6588
6552
  "low"
6589
6553
  ]);
6554
+ /**
6555
+ * Which rung a per-camera consumer listens on: an explicit profile, or `auto`.
6556
+ *
6557
+ * `auto` is a REAL value, not an absent one — it means "resolve it by the rule
6558
+ * that consumer owns". It lives beside `CamProfile` because a rung is the only
6559
+ * operator-facing way to name a stream: a cam-stream id is broker-internal and
6560
+ * moves when a slot is reassigned, so a stored id silently stops meaning what
6561
+ * the operator picked. Today's one user is the audio analyser, whose rule is
6562
+ * "the lowest AVAILABLE native rung" (D537).
6563
+ */
6564
+ var AudioStreamPickSchema = union([CamProfileSchema, literal("auto")]);
6590
6565
  var CamStreamKindSchema = _enum([
6591
6566
  "pull-rtsp",
6592
6567
  "pull-rtmp",
@@ -7446,7 +7421,7 @@ method(object({ deviceId: number() }), array(StreamSourceEntrySchema)), method(o
7446
7421
  input: unknown()
7447
7422
  }), unknown(), { kind: "mutation" }), method(object({ deviceId: number() }), _void(), { kind: "mutation" }), method(object({ deviceId: number() }), unknown().nullable()), method(object({ deviceId: number() }), RawStateResultSchema.nullable(), { auth: "protected" });
7448
7423
  //#endregion
7449
- //#region ../types/dist/canonical-hash-DBkl8q6V.mjs
7424
+ //#region ../types/dist/canonical-hash-CPK2Dy60.mjs
7450
7425
  var AUDIO_ENCODER_BY_CODEC = {
7451
7426
  opus: "libopus",
7452
7427
  aac: "aac",
@@ -7504,10 +7479,17 @@ function buildInputArgs(input, decodeHwAccel) {
7504
7479
  const args = [];
7505
7480
  if (!isSoftwareDecode(decodeHwAccel)) args.push("-hwaccel", String(decodeHwAccel));
7506
7481
  if (input.extraArgs?.length) args.push(...input.extraArgs);
7482
+ if (input.decodeThreadCount !== void 0 && input.decodeThreadCount > 0) args.push("-threads", String(input.decodeThreadCount));
7507
7483
  if (input.analyzeDurationUs !== void 0) args.push("-analyzeduration", String(input.analyzeDurationUs));
7508
7484
  if (input.probeSizeBytes !== void 0) args.push("-probesize", String(input.probeSizeBytes));
7485
+ if (input.lowDelay === true) args.push("-flags", "low_delay");
7486
+ if (input.useWallclockTimestamps === true) args.push("-use_wallclock_as_timestamps", "1");
7509
7487
  if (input.fflags?.length) for (const flag of input.fflags) args.push("-fflags", flag);
7510
7488
  if (input.rtspTransport) args.push("-rtsp_transport", input.rtspTransport);
7489
+ if (input.rawVideo !== void 0) {
7490
+ const raw = input.rawVideo;
7491
+ args.push("-f", "rawvideo", "-pix_fmt", raw.pixelFormat, "-video_size", `${String(raw.width)}x${String(raw.height)}`, "-framerate", String(raw.framerate));
7492
+ }
7511
7493
  args.push("-i", input.url);
7512
7494
  return args;
7513
7495
  }
@@ -7540,16 +7522,29 @@ function resolveStreamMaps(selection) {
7540
7522
  };
7541
7523
  }
7542
7524
  /** The `-vf` filter args, or `[]` when a consumer `-vf` already claims the slot. */
7543
- function buildVideoFilterArgs(scale, outputArgs, filterGraph) {
7525
+ function buildVideoFilterArgs(scale, outputArgs, filterGraph, hwUpload) {
7544
7526
  if (filterGraph) {
7545
7527
  if (scale) throw new Error("FfmpegInvocation: video.scale cannot be combined with a filter graph — scale inside the graph instead (the -vf would feed an output the graph already feeds)");
7546
7528
  if (outputArgs.some((a) => a === "-vf")) throw new Error("FfmpegInvocation: outputArgs carries a -vf while a filter graph feeds the same output — fold the filter into the graph");
7547
7529
  return [];
7548
7530
  }
7549
- if (!scale) return [];
7550
7531
  if (outputArgs.some((a) => a === "-vf")) return [];
7551
- if (scale.mode === "exact") return ["-vf", `scale=${scale.width}:${scale.height}`];
7552
- return ["-vf", `scale='min(${scale.width},iw)':'min(${scale.height},ih)':force_original_aspect_ratio=decrease:force_divisible_by=2`];
7532
+ if (!scale) return hwUpload.length > 0 ? ["-vf", hwUpload] : [];
7533
+ const suffix = hwUpload.length > 0 ? `,${hwUpload}` : "";
7534
+ if (scale.mode === "exact") return ["-vf", `scale=${scale.width}:${scale.height}${suffix}`];
7535
+ return ["-vf", `scale='min(${scale.width},iw)':'min(${scale.height},ih)':force_original_aspect_ratio=decrease:force_divisible_by=2${suffix}`];
7536
+ }
7537
+ /**
7538
+ * The upload filter a hardware encode needs, or `''`.
7539
+ *
7540
+ * Nothing is uploaded when the DECODE already left the frames on the GPU
7541
+ * (`-hwaccel vaapi`): they are surfaces already, and uploading a surface is an
7542
+ * error rather than a no-op.
7543
+ */
7544
+ function resolveHwUpload(device, decodeHwAccel) {
7545
+ if (device === void 0) return "";
7546
+ if (!isSoftwareDecode(decodeHwAccel)) return "";
7547
+ return "format=nv12,hwupload";
7553
7548
  }
7554
7549
  /** Rate-control args for an encode plan. */
7555
7550
  function buildRateControlArgs(video) {
@@ -7569,7 +7564,18 @@ function buildRateControlArgs(video) {
7569
7564
  ];
7570
7565
  }
7571
7566
  /** The whole video block (`-vf` … `-c:v` … knobs), after `-i`. */
7572
- function buildVideoArgs(video, outputArgs, filterGraph) {
7567
+ function buildVideoArgs(video, outputArgs, filterGraph, hwEncode) {
7568
+ if (video.kind === "raw") {
7569
+ if (filterGraph && video.filter !== void 0) throw new Error("FfmpegInvocation: video.filter cannot be combined with a filter graph — fold the filter into the graph");
7570
+ return [
7571
+ ...video.filter !== void 0 ? ["-vf", video.filter] : [],
7572
+ "-c:v",
7573
+ "rawvideo",
7574
+ "-pix_fmt",
7575
+ video.pixelFormat,
7576
+ ...video.fps !== void 0 ? ["-r", String(video.fps)] : []
7577
+ ];
7578
+ }
7573
7579
  if (video.kind === "copy") {
7574
7580
  if (filterGraph) throw new Error("FfmpegInvocation: a filter graph produces a NEW picture, so video.kind must be `encode`");
7575
7581
  return [
@@ -7578,8 +7584,10 @@ function buildVideoArgs(video, outputArgs, filterGraph) {
7578
7584
  ...video.bitstreamFilter ? ["-bsf:v", video.bitstreamFilter] : []
7579
7585
  ];
7580
7586
  }
7587
+ const hwUpload = hwEncode === void 0 ? "" : resolveHwUpload(hwEncode.device, hwEncode.decodeHwAccel);
7588
+ if (hwEncode !== void 0 && filterGraph) throw new Error("FfmpegInvocation: a hardware encode fed by a filter graph must upload INSIDE the graph — hwEncodeDevice cannot be combined with filterGraph");
7581
7589
  const args = [
7582
- ...buildVideoFilterArgs(video.scale, outputArgs, filterGraph),
7590
+ ...buildVideoFilterArgs(video.scale, outputArgs, filterGraph, hwUpload),
7583
7591
  "-c:v",
7584
7592
  video.encoder
7585
7593
  ];
@@ -7749,15 +7757,23 @@ function buildAudioSidecarArgs(sidecar) {
7749
7757
  * (`0:a:0?`): the sidecar is defined as the SOURCE's microphone on its own RTP
7750
7758
  * output, not as whatever the main output happens to carry.
7751
7759
  */
7760
+ /** The hardware-encode context for this invocation, or `undefined`. */
7761
+ function hwEncodeFor(inv) {
7762
+ return inv.hwEncodeDevice === void 0 ? void 0 : {
7763
+ device: inv.hwEncodeDevice,
7764
+ decodeHwAccel: inv.decodeHwAccel
7765
+ };
7766
+ }
7752
7767
  function buildFfmpegArgs(inv) {
7753
7768
  const graph = inv.filterGraph ?? null;
7754
7769
  const head = [
7755
7770
  ...logBannerArgs(inv.logLevel),
7771
+ ...inv.hwEncodeDevice !== void 0 ? ["-vaapi_device", inv.hwEncodeDevice.renderNode] : [],
7756
7772
  ...buildInputArgs(inv.input, inv.decodeHwAccel),
7757
7773
  ...(inv.extraInputs ?? []).flatMap((extra) => buildInputArgs(extra, inv.decodeHwAccel)),
7758
7774
  ...graph ? ["-filter_complex", graph.graph] : []
7759
7775
  ];
7760
- const threadArgs = inv.threadCount > 0 ? ["-threads", String(inv.threadCount)] : [];
7776
+ const threadArgs = [...inv.threadCount > 0 ? ["-threads", String(inv.threadCount)] : [], ...inv.filterThreadCount !== void 0 && inv.filterThreadCount > 0 ? ["-filter_threads", String(inv.filterThreadCount)] : []];
7761
7777
  const maps = resolveStreamMaps(inv);
7762
7778
  const videoMapArgs = maps.video ? ["-map", maps.video] : [];
7763
7779
  const audioMapArgs = maps.audio ? ["-map", maps.audio] : [];
@@ -7766,7 +7782,7 @@ function buildFfmpegArgs(inv) {
7766
7782
  "-an",
7767
7783
  "-map",
7768
7784
  maps.video ?? "0:v:0",
7769
- ...buildVideoArgs(inv.video, inv.outputArgs, graph),
7785
+ ...buildVideoArgs(inv.video, inv.outputArgs, graph, hwEncodeFor(inv)),
7770
7786
  ...threadArgs,
7771
7787
  ...inv.outputArgs,
7772
7788
  ...buildRtpOutputArgs(inv.sink.video)
@@ -7789,7 +7805,7 @@ function buildFfmpegArgs(inv) {
7789
7805
  return [
7790
7806
  ...head,
7791
7807
  ...videoMapArgs,
7792
- ...buildVideoArgs(inv.video, inv.outputArgs, graph),
7808
+ ...buildVideoArgs(inv.video, inv.outputArgs, graph, hwEncodeFor(inv)),
7793
7809
  ...threadArgs,
7794
7810
  ...mutedAudio ? [] : audioMapArgs,
7795
7811
  ...audioArgs,
@@ -8468,6 +8484,7 @@ var CameraSwitchIdSchema = _enum([
8468
8484
  "device-audio",
8469
8485
  "broker-audio",
8470
8486
  "audio-analysis",
8487
+ "event-saving",
8471
8488
  "recording",
8472
8489
  "notifications"
8473
8490
  ]);
@@ -8942,11 +8959,31 @@ var RecordingBandTriggersSchema = RecordingTriggersSchema;
8942
8959
  * band engine's `applies` rule). `start`/`end` are `HH:MM`; an `end <= start`
8943
8960
  * span wraps past midnight (handled by the band engine).
8944
8961
  */
8962
+ /**
8963
+ * Whether a band's footage carries sound.
8964
+ *
8965
+ * The recorder has always attached with `audio: 'aac'`, so every band of every
8966
+ * camera recorded sound whether anyone wanted it or not. The broker already
8967
+ * serves the other answer — `audio: 'none'` takes the video-only `/muted`
8968
+ * restream — so this was a choice the product could always have offered.
8969
+ *
8970
+ * It belongs on the BAND rather than the camera because one camera can want
8971
+ * both: silent across the shop floor during opening hours, sound overnight.
8972
+ * And it applies to BOTH modes — an events band firing on motion has the same
8973
+ * question to answer as a continuous one.
8974
+ */
8975
+ var RecordingBandAudioSchema = _enum(["on", "muted"]);
8945
8976
  var RecordingBandSchema = object({
8946
8977
  days: array(RecordingWeekdaySchema),
8947
8978
  start: string().regex(HHMM),
8948
8979
  end: string().regex(HHMM),
8949
8980
  mode: RecordingBandModeSchema,
8981
+ /**
8982
+ * Sound, or silence. OPTIONAL and absent-means-`on`: every recording
8983
+ * written before this existed carried sound, and a band whose key is
8984
+ * missing must keep meaning what it meant. See {@link RecordingBandAudioSchema}.
8985
+ */
8986
+ audio: RecordingBandAudioSchema.optional(),
8950
8987
  triggers: RecordingBandTriggersSchema.optional(),
8951
8988
  /**
8952
8989
  * RETIRED (D381). A band no longer carries a pre-buffer of its own.
@@ -20768,6 +20805,7 @@ var MediaFileKindEnum = _enum([
20768
20805
  "keyFrameSmall",
20769
20806
  "thumbnailSmall",
20770
20807
  "confirmationCrop",
20808
+ "debugCrop",
20771
20809
  "sceneFrame"
20772
20810
  ]);
20773
20811
  /**
@@ -22432,6 +22470,18 @@ var RunnerCameraConfigSchema = object({
22432
22470
  motionStreamId: string(),
22433
22471
  detectionStreamId: string(),
22434
22472
  /**
22473
+ * Which rung this camera's audio analysis listens on — `auto` (the lowest
22474
+ * AVAILABLE native rung) or an explicit one.
22475
+ *
22476
+ * Not consumed by the runner: carried here so it travels with the camera's
22477
+ * config the way `audioMotionWindowMs` does, and so the orchestrator's
22478
+ * watchdog resolves the same stream the audio subscription did. OPTIONAL
22479
+ * with no default — absent means `auto`, which is what every camera written
22480
+ * before this field had, and an absent optional adds nothing to the
22481
+ * generated cap-input defaults (D537).
22482
+ */
22483
+ audioStreamPick: AudioStreamPickSchema.optional(),
22484
+ /**
22435
22485
  * Motion sources that can activate the pipeline. At least one must
22436
22486
  * be present. Multiple sources OR-ed: any one firing arms the
22437
22487
  * cooldown timer and transitions watching → active. The same
@@ -23502,10 +23552,9 @@ var ServerBootModeSchema = _enum([
23502
23552
  * - `idle` / `checking` / `staging` — steady / in-flight registry work.
23503
23553
  * - `pending-restart` — a version is staged and the node has NOT yet
23504
23554
  * restarted onto it (still running the OLD version).
23505
- * - `awaiting-confirmation` — the node HAS restarted onto the staged version
23506
- * (it is the active probation boot) and is waiting to confirm boot-health.
23507
- * Apply/rollback are refused in this state and the node must NOT be
23508
- * manually restarted, or the probation boot auto-rolls-back.
23555
+ * - `awaiting-confirmation` — RETIRED with the probation boot (single-copy
23556
+ * collapse, 2026-07-18). Kept in the enum so an older client still parses;
23557
+ * no provider emits it.
23509
23558
  */
23510
23559
  var ServerUpdateStateSchema = _enum([
23511
23560
  "idle",
@@ -23594,12 +23643,35 @@ var ServerRollbackInfoSchema = object({
23594
23643
  var ServerPackageStatusSchema = object({
23595
23644
  /** Root package name (`@camstack/server` on the hub). */
23596
23645
  packageName: string(),
23597
- /** Version of the code the running process ACTUALLY loaded. */
23646
+ /**
23647
+ * Version of the code THIS PROCESS loaded — captured once, at boot, from the
23648
+ * package.json of the copy the code was loaded from, and never re-read. A
23649
+ * copy replaced on disk under a live process must not move this number;
23650
+ * that divergence is what `installedVersion` is for. Before 2026-09-20 this
23651
+ * was a lazy disk read that would have followed such a swap silently.
23652
+ */
23598
23653
  runningVersion: string().nullable(),
23599
23654
  /** Node.js runtime version the node's process runs on (`process.versions.node`). */
23600
23655
  nodeRuntimeVersion: string().nullable(),
23601
23656
  /** Active data-dir root version; null when booted from seed/workspace. */
23602
23657
  activeVersion: string().nullable(),
23658
+ /**
23659
+ * Version of the root package in the data-root `current/` copy RIGHT NOW —
23660
+ * what the next boot would load if nothing is staged. Normally equal to
23661
+ * `runningVersion`; differs only when the copy on disk changed under the
23662
+ * live process. `null` when there is no data-root copy (baked seed or
23663
+ * workspace boot). Optional for version skew: an older provider omits it.
23664
+ */
23665
+ installedVersion: string().nullable().optional(),
23666
+ /**
23667
+ * True when the code that would run after a restart is NOT the code running
23668
+ * now: a version is staged (`pendingVersion`), or `installedVersion` differs
23669
+ * from `runningVersion`. Derived on every read, never stored. This is the
23670
+ * one field that lets an operator or a script say "1.2.353 is installed and
23671
+ * will run after a restart" instead of reading it off a number that means
23672
+ * "installed". Optional for version skew.
23673
+ */
23674
+ restartRequired: boolean().optional(),
23603
23675
  /** N-1 version kept for rollback; null when no previous version exists. */
23604
23676
  previousVersion: string().nullable(),
23605
23677
  /** Version of the immutable baked seed closure (image fallback). */
@@ -23634,19 +23706,71 @@ var ServerPackageStatusSchema = object({
23634
23706
  });
23635
23707
  var ServerUpdateCheckResultSchema = object({
23636
23708
  packageName: string(),
23709
+ /** Same meaning as on the status payload: what THIS process loaded. */
23637
23710
  runningVersion: string().nullable(),
23638
23711
  latestVersion: string().nullable(),
23712
+ /**
23713
+ * `latestVersion` is newer than `runningVersion` AND is not the version
23714
+ * already staged on this node. A staged-and-waiting version is not "an
23715
+ * update available" — applying it again is refused (`already-staged`), and
23716
+ * a button that announced "updating…" on this boolean then got refused
23717
+ * would be lying. It is reported through `pendingVersion` instead.
23718
+ */
23639
23719
  updateAvailable: boolean(),
23640
23720
  checkedAtMs: number(),
23641
23721
  /** Non-null when the registry lookup failed (offline, bad registry, …). */
23642
- error: string().nullable()
23722
+ error: string().nullable(),
23723
+ /**
23724
+ * Version staged on this node and awaiting its restart, when one is. Before
23725
+ * 2026-09-20 a check answered `{runningVersion, updateAvailable:false}` for a
23726
+ * node whose staged closure had never booted, and the caller had no way to
23727
+ * see the difference. Optional for version skew.
23728
+ */
23729
+ pendingVersion: string().nullable().optional(),
23730
+ /** See the status payload: staged, or installed-on-disk ≠ running. Optional for skew. */
23731
+ restartRequired: boolean().optional()
23643
23732
  });
23733
+ /**
23734
+ * What an action DID — the discriminant `accepted` cannot carry. Before
23735
+ * 2026-09-20 `applyServerUpdate` answered `accepted:false` both for a refusal
23736
+ * where nothing was wrong (already current, already staged) and for a staging
23737
+ * install that died on `npm error code ECONNRESET`; the only difference was
23738
+ * prose in `message`, and an operator restarted a container on the strength of
23739
+ * it, booting the previous version.
23740
+ *
23741
+ * - `staged` — the closure is installed and validated; the node
23742
+ * restarts to apply it (`restarting:true`). NOTHING
23743
+ * runs the new code until that restart completes.
23744
+ * - `restart-scheduled` — a plain, version-preserving restart was accepted.
23745
+ * - `already-current` — the target IS the running version; nothing done.
23746
+ * - `refused` — dropped by a gate; `reason` names which.
23747
+ * - `failed` — attempted and did not land; `error` says why.
23748
+ */
23749
+ var ServerUpdateOutcomeSchema = _enum([
23750
+ "staged",
23751
+ "restart-scheduled",
23752
+ "already-current",
23753
+ "refused",
23754
+ "failed"
23755
+ ]);
23644
23756
  var ServerUpdateActionResultSchema = object({
23645
23757
  accepted: boolean(),
23646
23758
  targetVersion: string().nullable(),
23647
23759
  /** True when a graceful restart was scheduled to apply the change. */
23648
23760
  restarting: boolean(),
23649
- message: string()
23761
+ message: string(),
23762
+ /** See {@link ServerUpdateOutcomeSchema}. Optional for version skew. */
23763
+ outcome: ServerUpdateOutcomeSchema.optional(),
23764
+ /**
23765
+ * Stable, greppable cause for `refused` / `failed` (the engine's
23766
+ * `UpdateRefusalReason`, `staging-failed`, …). `null` on success.
23767
+ */
23768
+ reason: string().nullable().optional(),
23769
+ /**
23770
+ * The failure itself, verbatim enough to act on (the npm output for a
23771
+ * staging install). Non-null only for `failed`.
23772
+ */
23773
+ error: string().nullable().optional()
23650
23774
  });
23651
23775
  method(_void(), ServerPackageStatusSchema, { auth: "admin" }), method(_void(), ServerUpdateCheckResultSchema, {
23652
23776
  kind: "mutation",
@@ -24923,6 +25047,104 @@ var WebrtcStreamChoiceSchema = object({
24923
25047
  /** Bitrate in kbps (null for adaptive). */
24924
25048
  bitrateKbps: number().nullable()
24925
25049
  });
25050
+ /**
25051
+ * The last move the adaptive ladder made on this session, and the numbers it
25052
+ * rested on. Mirrors `LadderDecision` in the stream-broker's
25053
+ * `adaptive-controller.ts` — the log line's evidence, made readable.
25054
+ *
25055
+ * Nullability is load-bearing: `lossRatio: null` means the loss tracker had
25056
+ * no window to report, which is NOT a link with zero loss.
25057
+ */
25058
+ var WebrtcLadderDecisionSchema = object({
25059
+ /** `loss` — RTCP / client-reported packet loss; `stall` — the picture
25060
+ * stopped moving; `healthy` — a promotion. */
25061
+ cause: _enum([
25062
+ "loss",
25063
+ "stall",
25064
+ "healthy"
25065
+ ]),
25066
+ /** The rung the ladder moved TO. Null for a move that changed no rung
25067
+ * (an MTU reduction, a forced transcode-down at the bottom). */
25068
+ tier: CamProfileSchema.nullable(),
25069
+ /** Whether that move forced the egress transcode-down rung. */
25070
+ forcedTranscodeDown: boolean(),
25071
+ /** Loss ratio over the tracker's window at the moment of the decision. */
25072
+ lossRatio: number().nullable(),
25073
+ /** The client's reported downlink at that moment, in kbps. */
25074
+ clientKbps: number().nullable(),
25075
+ /** How long the stall detector had been seeing nothing arrive. */
25076
+ stalledForMs: number().nullable(),
25077
+ /** Whether the broker WAS sending while that was measured — the half that
25078
+ * separates "the camera stopped" from "the link stopped". */
25079
+ serverSending: boolean().nullable(),
25080
+ /** Epoch ms. The overlay prints its age; a decision from 40 minutes ago
25081
+ * must not read like one from this second. */
25082
+ at: number()
25083
+ });
25084
+ /** Per-tier count of MEASURED downgrades this viewer session has suffered. */
25085
+ var WebrtcTierFailureSchema = object({
25086
+ tier: CamProfileSchema,
25087
+ failures: number().int().nonnegative()
25088
+ });
25089
+ /**
25090
+ * What the SERVER decided for one WebRTC session — the half the client cannot
25091
+ * measure. See `getSessionDebug`.
25092
+ */
25093
+ var WebrtcSessionDebugSchema = object({
25094
+ sessionId: string(),
25095
+ /** Whether the session still exists here. Everything below is null when
25096
+ * it does not — a dead session has no measurements, not zero ones. */
25097
+ alive: boolean(),
25098
+ /** Epoch ms this snapshot was taken, so the overlay can show its age. */
25099
+ observedAt: number(),
25100
+ /** The broker actually feeding this session (`<deviceId>/<camStreamId>`). */
25101
+ brokerId: string().nullable(),
25102
+ camStreamId: string().nullable(),
25103
+ /** `derived` = this hub BUILT the rung being served (a transcode/scale of
25104
+ * another stream); `native` = the camera's own encode. */
25105
+ sourceKind: _enum([
25106
+ "native",
25107
+ "derived",
25108
+ "unknown"
25109
+ ]),
25110
+ /** Whether the ladder is running at all (an `adaptive` target). */
25111
+ adaptive: boolean(),
25112
+ /** The rung `auto` ACTUALLY resolved to right now. Null when the broker
25113
+ * is bound to no profile slot (a manually-activated stream). */
25114
+ servedTier: CamProfileSchema.nullable(),
25115
+ /** Which node ingests the source. Null when unresolved. */
25116
+ ingestNodeId: string().nullable(),
25117
+ /** Whether that node is a remote one (the media crosses a node boundary
25118
+ * before it reaches this session). Null when unresolved. */
25119
+ ingestIsRemote: boolean().nullable(),
25120
+ /** Codec off the source, and the one negotiated with this client. */
25121
+ sourceCodec: string().nullable(),
25122
+ negotiatedCodec: string().nullable(),
25123
+ /** Whether the EGRESS leg re-encodes for this client or forwards the
25124
+ * source bytes untouched. */
25125
+ egressLeg: _enum([
25126
+ "copy",
25127
+ "transcode",
25128
+ "unknown"
25129
+ ]),
25130
+ /** What the broker itself reports about the rung it is serving. */
25131
+ brokerStatus: string().nullable(),
25132
+ brokerCodec: string().nullable(),
25133
+ brokerBitrateKbps: number().nullable(),
25134
+ brokerInputFps: number().nullable(),
25135
+ brokerUptimeMs: number().nullable(),
25136
+ /** RTP packets this session has sent — the server's own "am I sending?". */
25137
+ rtpPacketsSent: number().nullable(),
25138
+ /** D42: the track may be carrying the recorded feeder, not live. The ladder
25139
+ * is suspended there, so a frozen picture in `recorded` mode means
25140
+ * something entirely different. */
25141
+ trackMode: _enum(["live", "recorded"]).nullable(),
25142
+ lastDecision: WebrtcLadderDecisionSchema.nullable(),
25143
+ tierFailures: array(WebrtcTierFailureSchema),
25144
+ /** Successful promotions in this viewer session (drives the progressive
25145
+ * healthy timer). Null when no ladder is running. */
25146
+ upgradeCount: number().nullable()
25147
+ });
24926
25148
  DeviceType.Camera, method(object({ deviceId: number().int().nonnegative() }), array(WebrtcStreamChoiceSchema)), method(object({
24927
25149
  deviceId: number().int().nonnegative(),
24928
25150
  target: WebrtcStreamTargetSchema,
@@ -25087,7 +25309,10 @@ DeviceType.Camera, method(object({ deviceId: number().int().nonnegative() }), ar
25087
25309
  * the same answer as "it ended": either way nothing is holding it up.
25088
25310
  */
25089
25311
  alive: boolean()
25090
- }));
25312
+ })), method(object({
25313
+ deviceId: number().int().nonnegative(),
25314
+ sessionId: string()
25315
+ }), WebrtcSessionDebugSchema, { kind: "query" });
25091
25316
  object({
25092
25317
  /** All accessory children of the parent. */
25093
25318
  childDeviceIds: array(number()).readonly(),
@@ -28147,11 +28372,15 @@ DeviceType.Light, DeviceType.Siren, DeviceType.Switch, method(object({
28147
28372
  *
28148
28373
  * Adding a fifth name to that list would have been the wrong fix twice over:
28149
28374
  * that page is per-camera DETECTION tuning, and a grid's geometry belongs
28150
- * beside PTZ and motion zones on the camera itself — which is exactly where the
28151
- * framework already puts a widget, when the widget is declared on a CAPABILITY.
28152
- * `deviceConfig.ui` is the mechanism (`motion-zones`, `ptz`, `ptz-autotrack`
28153
- * all use it): the framework emits the structural section and the widget
28154
- * self-persists through the cap's own mutations.
28375
+ * beside PTZ and motion zones on the camera itself. The device page is
28376
+ * BINDING-driven (D12), so the way in is a capability bound to the device —
28377
+ * and this cap carries its section the way `recording` does, by RETURNING it
28378
+ * from `getDeviceSettingsContribution`.
28379
+ *
28380
+ * Seven other widgets are still declared the other way, through a
28381
+ * `deviceConfig.ui` block the framework derives a section from. That route
28382
+ * gives the addon no say in where its own panel lands and no way to decline
28383
+ * for a device the panel does not suit, which is why this one does not use it.
28155
28384
  *
28156
28385
  * ## Why one addon may implement it
28157
28386
  *
@@ -28207,16 +28436,37 @@ var GridProfileOfferSchema = object({
28207
28436
  ]),
28208
28437
  offered: boolean(),
28209
28438
  /** Sources that cannot serve it. Empty when it is offered, or when there are no cells. */
28210
- missingSources: array(number().int().positive())
28439
+ missingSources: array(number().int().positive()),
28440
+ /**
28441
+ * The canvas this profile composes onto, `WxH`, or empty when it is not
28442
+ * offered. DERIVED from the cells and the sources' own size at this profile —
28443
+ * it is reported because nothing else in the system would ever say what the
28444
+ * grid came out as, and because it is the number an operator would otherwise
28445
+ * expect to type.
28446
+ */
28447
+ canvas: string(),
28448
+ /**
28449
+ * Whether this profile is PUBLISHED, of the ones the grid could serve.
28450
+ *
28451
+ * A grid's `high` is composed of its sources' `high`, so on a 4K fleet it is
28452
+ * a 4K canvas built from 4K decodes — something to opt into, not something a
28453
+ * viewer's adaptive should be handed by climbing to the top rung it can see.
28454
+ * Default is `mid` + `low`.
28455
+ */
28456
+ published: boolean()
28211
28457
  });
28212
28458
  var GridLayoutViewSchema = object({
28213
28459
  /** The persisted grid row this camera was declared from. */
28214
28460
  instanceId: string(),
28215
28461
  deviceId: number().int().nonnegative(),
28216
28462
  name: string(),
28217
- /** The canvas the operator laid out — this grid's `high`. mid/low are scaled from it. */
28218
- width: number().int(),
28219
- height: number().int(),
28463
+ /**
28464
+ * NO canvas size. A grid's resolution is not authored: each profile derives
28465
+ * its own from the cells and its sources' dimensions. The two numbers that
28466
+ * used to be here were a text field that silently decided both how much the
28467
+ * composite cost and how sharp it was — see `profiles[].canvas` for what it
28468
+ * came out as.
28469
+ */
28220
28470
  fps: number().int(),
28221
28471
  cells: array(GridLayoutCellSchema),
28222
28472
  /** What the catalog will publish, and what it refuses to. Read-only. */
@@ -28225,9 +28475,13 @@ var GridLayoutViewSchema = object({
28225
28475
  var GridLayoutPatchSchema = object({
28226
28476
  deviceId: number().int().nonnegative(),
28227
28477
  name: string().min(1).max(160).optional(),
28228
- width: number().int().min(160).max(7680).optional(),
28229
- height: number().int().min(120).max(4320).optional(),
28230
28478
  fps: number().int().min(1).max(60).optional(),
28479
+ /** Which profiles to publish. See `GridProfileOffer.published`. */
28480
+ publishedProfiles: array(_enum([
28481
+ "high",
28482
+ "mid",
28483
+ "low"
28484
+ ])).max(3).optional(),
28231
28485
  /**
28232
28486
  * The whole cell list at once. A per-cell patch would need an ordering the
28233
28487
  * editor does not have, and a half-applied layout is a picture nobody asked
@@ -38807,6 +39061,12 @@ Object.freeze({
38807
39061
  addonId: null,
38808
39062
  access: "view"
38809
39063
  },
39064
+ "webrtcSession.getSessionDebug": {
39065
+ capName: "webrtc-session",
39066
+ capScope: "device",
39067
+ addonId: null,
39068
+ access: "view"
39069
+ },
38810
39070
  "webrtcSession.getSessionState": {
38811
39071
  capName: "webrtc-session",
38812
39072
  capScope: "device",
@@ -40790,6 +41050,11 @@ Object.freeze({
40790
41050
  form: "single",
40791
41051
  optional: false
40792
41052
  }],
41053
+ "webrtcSession.getSessionDebug": [{
41054
+ name: "deviceId",
41055
+ form: "single",
41056
+ optional: false
41057
+ }],
40793
41058
  "webrtcSession.getSessionState": [{
40794
41059
  name: "deviceId",
40795
41060
  form: "single",
@@ -41408,6 +41673,60 @@ DEFAULT_NATIVE_LEASE_SETTINGS.sceneBudgetMb;
41408
41673
  DEFAULT_NATIVE_LEASE_SETTINGS.admission;
41409
41674
  var MB = 1024 * 1024;
41410
41675
  1024 * MB, 3072 * MB;
41676
+ //#endregion
41677
+ //#region ../types/dist/node.mjs
41678
+ /**
41679
+ * The ffmpeg build every CamStack node downloads, per platform and arch.
41680
+ *
41681
+ * ## Why a download, on every node, including the container
41682
+ *
41683
+ * The version of ffmpeg used to be CODE: baked into the image, changeable only
41684
+ * by rebuilding and rolling it. Now it is DATA — this file states the release,
41685
+ * and a node that boots with a different one downloads it. Changing ffmpeg is a
41686
+ * framework bump, not an image rebuild.
41687
+ *
41688
+ * It also settles the licence question by removing it. FFmpeg's licence is
41689
+ * chosen at compile time: the core is LGPL 2.1+, `--enable-gpl` makes it GPL
41690
+ * (for x264/x265), `--enable-version3` makes that v3, and `--enable-nonfree`
41691
+ * makes the result undistributable. These are GPL v3 builds, and we do not
41692
+ * distribute them: the user's own node fetches the archive from the project
41693
+ * that publishes it, so we convey no GPL work and inherit none of the
41694
+ * obligations that come with conveying one. That is the whole reason the image
41695
+ * no longer installs ffmpeg at all — not a technical preference.
41696
+ *
41697
+ * ## Why jellyfin-ffmpeg
41698
+ *
41699
+ * One vendor covering linux-amd64, linux-arm64, darwin-arm64 and darwin-x64
41700
+ * from one release, with the widest hardware surface of anything maintained:
41701
+ * VAAPI, QSV, NVENC/NVDEC, AMF, Vulkan, OpenCL and DRM on amd64, plus Rockchip
41702
+ * MPP on arm64. It reaches the vendor libraries through dlopen trampolines,
41703
+ * so the binary is not linked against a driver stack it may not find.
41704
+ *
41705
+ * **What it still needs from the host, measured 2026-09-19.** On the Unraid
41706
+ * HOST, which has no libva, `-vaapi_device` aborts the process outright —
41707
+ * `implib-gen: libva-drm.so.2: failed to load library ... Assertion '0 &&
41708
+ * "Assertion in generated code"' failed`. Inside our container, which installs
41709
+ * libva plus the iHD driver, the same command encodes: verified end to end with
41710
+ * `testsrc → format=nv12,hwupload → h264_vaapi`. So the image drops ffmpeg and
41711
+ * KEEPS the Intel media stack; the drivers are the part a binary cannot bring.
41712
+ *
41713
+ * ## Why each artifact carries a checksum and a claim
41714
+ *
41715
+ * The download is now the only source, so `sha256` is verified before anything
41716
+ * is extracted — jellyfin publishes no checksums on its release assets, so
41717
+ * these are ours, taken at the version bump. And `expectedHwaccels` is what the
41718
+ * build is supposed to be able to do: a binary that probes short of its own
41719
+ * claim is a source that changed under us, which is precisely what went
41720
+ * unnoticed for months when a static build turned out to carry no VAAPI at all
41721
+ * (D536).
41722
+ */
41723
+ /** The pinned jellyfin-ffmpeg release. Changing this is the version change. */
41724
+ var FFMPEG_RELEASE = "8.1.2-5";
41725
+ var BASE = `https://github.com/jellyfin/jellyfin-ffmpeg/releases/download/v${FFMPEG_RELEASE}`;
41726
+ function portable(target) {
41727
+ return `${BASE}/jellyfin-ffmpeg_${FFMPEG_RELEASE}_portable_${target}-gpl.tar.xz`;
41728
+ }
41729
+ portable("linux64"), portable("linuxarm64"), portable("macarm64"), portable("mac64");
41411
41730
  var DEFAULT_FIRST_UNIT_TIMEOUT_MS = 12e3;
41412
41731
  /** Heartbeat cadence — ~2 minutes of 4 s fragments. */
41413
41732
  var FRAGMENT_LOG_EVERY = 30;
@@ -88389,6 +88708,43 @@ var BridgeHost = class {
88389
88708
  }
88390
88709
  };
88391
88710
  //#endregion
88711
+ //#region src/exposed-entry.ts
88712
+ /**
88713
+ * Carrying an exposed-device entry across a re-expose.
88714
+ *
88715
+ * `exposeDevice` rebuilds its entry from scratch — display name, mapper kind,
88716
+ * timestamp — and then REPLACES the stored one. Anything the rebuilt object
88717
+ * does not mention is therefore destroyed, and two things it never mentioned
88718
+ * were the per-camera settings and the capability list.
88719
+ *
88720
+ * The visible cost: the operator's "Source stream (HomeKit)" selector writes
88721
+ * `low`, the addon logs `streamPreference changed — refreshing accessory
88722
+ * {from=auto to=low}`, and the accessory that comes back derives its
88723
+ * advertisement from DEFAULTS — `streamPreference=auto` — because the settings
88724
+ * were dropped between the write and the rebuild. The selector only ever took
88725
+ * effect after a full addon restart, when the settings were loaded first. A
88726
+ * second write after the re-expose hid this: the store ended up correct, so
88727
+ * nothing looked wrong except the stream nobody could explain.
88728
+ */
88729
+ /**
88730
+ * Fill `base` from `existing` for the given keys, letting `base` win wherever
88731
+ * it actually says something.
88732
+ *
88733
+ * That asymmetry is the point: a caller who passes `capabilities` is stating a
88734
+ * new truth and must not be overruled by the stored copy, while a caller who
88735
+ * says nothing about `settings` is not asking for them to be erased.
88736
+ */
88737
+ function carryForward(base, existing, keys) {
88738
+ if (existing === void 0) return base;
88739
+ const out = { ...base };
88740
+ for (const key of keys) {
88741
+ if (out[key] !== void 0) continue;
88742
+ const carried = existing[key];
88743
+ if (carried !== void 0) out[key] = carried;
88744
+ }
88745
+ return out;
88746
+ }
88747
+ //#endregion
88392
88748
  //#region src/hap-setup-uri.ts
88393
88749
  function errMsg$15(e) {
88394
88750
  return e instanceof Error ? e.message : String(e);
@@ -88423,47 +88779,610 @@ function firstExposedAccessorySetupUri(exposed, logger) {
88423
88779
  }
88424
88780
  }
88425
88781
  //#endregion
88426
- //#region src/mappers/builders/accessory-info.ts
88782
+ //#region src/hksv/clip-record.ts
88783
+ /**
88784
+ * What ONE teed HomeKit clip is, on disk.
88785
+ *
88786
+ * ## Why a sidecar at all
88787
+ *
88788
+ * HomeKit Secure Video is a SINK. HAP's `dataSend` is `hello/whoami/open/data/
88789
+ * ack/close` and `CameraRecordingDelegate` is push-only, so a clip "recorded by
88790
+ * HomeKit" can only ever be ours if we kept our own copy of what we SENT.
88791
+ * There is no read-back to reconcile against later, and there never will be.
88792
+ * Everything a later reader can know about a teed clip therefore has to be
88793
+ * written down at the moment it was written — by the only process that ever saw
88794
+ * it — or it is lost with the session.
88795
+ *
88796
+ * ## The two coverage limits, stated on the record rather than discovered
88797
+ *
88798
+ * - **Only some cameras record at all.** `pickRecordingSource` refuses anything
88799
+ * without an H.264 slot, `MAX_RECORDING_HEIGHT` prefers ≤ 1080p, and
88800
+ * `deriveFragmentLengthMs` refuses a GOP longer than 8 s. A camera the tee
88801
+ * never writes for is not a gap in this store, it is a camera HKSV never
88802
+ * recorded; {@link HksvClipRecord} exists only for the ones it did.
88803
+ * - **The duration is whatever iOS PULLED.** `durationMs` is the wall-clock
88804
+ * span of the HDS stream (prebuffer replay plus however long the hub kept it
88805
+ * open), not a window CamStack chose. A short clip is not a truncated one —
88806
+ * {@link HksvClipRecord.truncated} is the only thing that says truncated.
88807
+ *
88808
+ * ## `thumbnailFile` is VOUCHED
88809
+ *
88810
+ * It is written only after the JPEG has been stat'd on disk at non-zero size.
88811
+ * A minter that reports success and produced nothing leaves
88812
+ * {@link HksvClipRecord.thumbnailUnavailable} instead — the whole point of
88813
+ * vouching is that the promise and the file are not the same claim.
88814
+ */
88815
+ /** The `videoclips` source id a later provider will list these under. */
88816
+ var HKSV_CLIP_SOURCE = "hksv";
88817
+ /** Why no still could be minted from the saved fMP4. Named, never blank. */
88818
+ var HksvThumbnailUnavailableReasonSchema = _enum([
88819
+ "decode-failed",
88820
+ "timed-out",
88821
+ "ffmpeg-missing",
88822
+ "no-init-segment",
88823
+ "not-attempted"
88824
+ ]);
88825
+ /** Why a clip stopped short of the stream that fed it. */
88826
+ var HksvClipTruncationSchema = _enum([
88827
+ "size-cap",
88828
+ "slow-consumer",
88829
+ "write-failed"
88830
+ ]);
88831
+ var HksvClipRecordSchema = object({
88832
+ /** Bumped when a field's MEANING changes; a reader refuses what it cannot read. */
88833
+ schemaVersion: literal(1),
88834
+ /** `hksv:<deviceId>:<startedAtMs>-<streamId>` — self-contained, so a later
88835
+ * provider addresses bytes and stills without re-listing. */
88836
+ clipId: string().min(1),
88837
+ source: literal(HKSV_CLIP_SOURCE),
88838
+ deviceId: number().int(),
88839
+ /** The HDS stream this clip was teed from — the join to the delegate's logs. */
88840
+ streamId: number().int(),
88841
+ startedAtMs: number().int(),
88842
+ endedAtMs: number().int(),
88843
+ /** `endedAtMs - startedAtMs`. What iOS pulled, never a window we chose. */
88844
+ durationMs: number().int().nonnegative(),
88845
+ /** How much of the clip's head came from the prebuffer ring, by ARRIVAL age. */
88846
+ prebufferSpanMs: number().int().nonnegative(),
88847
+ bytes: number().int().nonnegative(),
88848
+ fragments: number().int().nonnegative(),
88849
+ /** Basenames, relative to the clip's own device directory. */
88850
+ file: string().min(1),
88851
+ thumbnailFile: string().min(1).optional(),
88852
+ thumbnailUnavailable: object({ reason: HksvThumbnailUnavailableReasonSchema }).optional(),
88853
+ truncated: HksvClipTruncationSchema.optional(),
88854
+ /** iOS sent `ack` for this stream: HomeKit itself kept the clip. */
88855
+ acknowledgedByHomeKit: boolean(),
88856
+ width: number().int().positive(),
88857
+ height: number().int().positive(),
88858
+ /** The advertised fragment length the source was cutting at. */
88859
+ fragmentMs: number().int().positive()
88860
+ });
88427
88861
  /**
88428
- * `Service.AccessoryInformation` — the manufacturer / model / firmware /
88429
- * serial block every HomeKit accessory carries.
88430
- *
88431
- * One implementation for both accessory shapes (camera and generic): the
88432
- * fields come from the device's own metadata either way, and a second copy
88433
- * would be a second answer to "what serial does this device publish".
88434
- * Metadata is best-effort — a device that cannot answer still publishes.
88862
+ * One stem per clip, three extensions. Derived from the id and from nothing
88863
+ * else, so a pruner deleting a clip never has to guess which JPEG was its.
88435
88864
  */
88436
- async function populateAccessoryInfo(accessory, proxy, displayName, modelFallback) {
88437
- const info = accessory.getService(import_dist.Service.AccessoryInformation);
88438
- if (!info) return;
88865
+ function clipFileNames(clipId) {
88866
+ const stem = clipStem(clipId);
88867
+ return {
88868
+ clip: `${stem}.mp4`,
88869
+ thumbnail: `${stem}.jpg`,
88870
+ record: `${stem}.json`
88871
+ };
88872
+ }
88873
+ function clipIdFor(deviceId, startedAtMs, streamId) {
88874
+ return `${HKSV_CLIP_SOURCE}:${deviceId}:${startedAtMs}-${streamId}`;
88875
+ }
88876
+ /** The filesystem-safe tail of an id. `:` is legal on ext4 and hostile everywhere else. */
88877
+ function clipStem(clipId) {
88878
+ return clipId.replace(/[^A-Za-z0-9_-]/g, "_");
88879
+ }
88880
+ //#endregion
88881
+ //#region src/hksv/clip-store.ts
88882
+ /**
88883
+ * `HksvClipStore` — who owns the teed clips, and for how long.
88884
+ *
88885
+ * ## The owner
88886
+ *
88887
+ * The **export-hap addon**, in its OWN data dir, and nothing else. Not the
88888
+ * recorder's storage locations and not the `eventMedia` class:
88889
+ *
88890
+ * - `export-hap` is hub-only and runs in its own runner. The recorder's
88891
+ * location machinery (D385/D386/D389) is another addon's, in another
88892
+ * process, and reaching it would be a cross-runner write on the frame path
88893
+ * of a live HomeKit session — exactly what D9/D18 forbid.
88894
+ * - A teed clip is a DERIVED artifact of a HomeKit session, with no track, no
88895
+ * detection and no event behind it. Filing it under `eventMedia` would put
88896
+ * it inside the post-analysis retention sweep, where its lifetime would be
88897
+ * decided by a policy written for a different question.
88898
+ * - The precedent is D549 decision 13: Reolink's clip thumbnails live in the
88899
+ * vendor addon's data dir under a per-device LRU, outside `eventMedia` and
88900
+ * outside the sweep, for the same reason.
88901
+ *
88902
+ * Layout: `<dataDir>/hksv-clips/<deviceId>/<stem>.{mp4,jpg,json}`. One stem per
88903
+ * clip, three extensions, so a pruner deleting a clip never has to guess which
88904
+ * JPEG was its.
88905
+ *
88906
+ * ## The retention, and why these numbers
88907
+ *
88908
+ * HomeKit's own retention is unreadable — HAP has no read-back, so CamStack can
88909
+ * never ask "does iOS still have this one?" and reconcile. The store therefore
88910
+ * cannot mirror HomeKit's lifetime and does not pretend to: it keeps a bounded
88911
+ * recent window and says, per clip, exactly when it started. Four bounds, all
88912
+ * enforced on every completion:
88913
+ *
88914
+ * - **{@link DEFAULT_CLIP_BOUNDS.maxClipBytes} per clip (256 MB).** A clip's
88915
+ * duration is whatever iOS pulled — there is no window we choose — so this
88916
+ * is the only ceiling on a session that never closes. At the ~64 KB/s a
88917
+ * 1080p copy branch produces it is over an hour of video.
88918
+ * - **{@link DEFAULT_CLIP_BOUNDS.maxClipsPerDevice} (200)** and
88919
+ * **{@link DEFAULT_CLIP_BOUNDS.maxBytesPerDevice} (512 MB)**, oldest first.
88920
+ * Measured fragment sizes (D84): ~255 KB per 8 s at 720p, ~500 KB at 1080p,
88921
+ * so a typical 20-40 s HKSV clip is 1.5-3 MB and 200 of them is ~400 MB.
88922
+ * Both bounds exist for the same reason the prebuffer ring has both: at 4K
88923
+ * a fragment is 6.35 MB, a 25x spread, and a count-only bound is a
88924
+ * per-camera disk figure nobody can predict.
88925
+ * - **{@link DEFAULT_CLIP_BOUNDS.maxAgeMs} (14 days)**, so a camera that
88926
+ * stopped recording in March does not hold half a gigabyte forever.
88927
+ * - **{@link DEFAULT_CLIP_BOUNDS.maxStoreBytes} (4 GB)** across every camera,
88928
+ * applied after the per-device pass. Per-device bounds alone multiply by a
88929
+ * camera count the operator changes without thinking about this store.
88930
+ *
88931
+ * A file nobody prunes is a disk that fills, and the pruner is the only thing
88932
+ * standing between a permanent prebuffer and that.
88933
+ *
88934
+ * ## The thumbnail is VOUCHED, never promised
88935
+ *
88936
+ * The minter is injected and its verdict is not believed: after it returns the
88937
+ * store stats the JPEG, and `thumbnailFile` is written only if a non-empty file
88938
+ * is really there. A minter that reports success and produced nothing gets
88939
+ * `thumbnailUnavailable.reason: 'decode-failed'` and its zero-byte leftover
88940
+ * deleted — this repo has twice shipped a green test because the fake supplied
88941
+ * what production forgot, and a promise is not a file.
88942
+ */
88943
+ /** The directory under the addon's data dir. Ours, and only ours. */
88944
+ var CLIP_DIR_NAME = "hksv-clips";
88945
+ /** See the class docblock for what each number is, and where it comes from. */
88946
+ var DEFAULT_CLIP_BOUNDS = {
88947
+ maxClipBytes: 256 * 1024 * 1024,
88948
+ maxClipsPerDevice: 200,
88949
+ maxBytesPerDevice: 512 * 1024 * 1024,
88950
+ maxAgeMs: 336 * 60 * 60 * 1e3,
88951
+ maxStoreBytes: 4 * 1024 * 1024 * 1024
88952
+ };
88953
+ var HksvClipStore = class {
88954
+ input;
88955
+ log;
88956
+ bounds;
88957
+ now;
88958
+ inFlight = /* @__PURE__ */ new Map();
88959
+ constructor(input) {
88960
+ this.input = input;
88961
+ this.log = input.logger;
88962
+ this.bounds = {
88963
+ ...DEFAULT_CLIP_BOUNDS,
88964
+ ...input.bounds
88965
+ };
88966
+ this.now = input.now ?? Date.now;
88967
+ }
88968
+ /** The per-clip ceiling the tee enforces while writing. */
88969
+ get maxClipBytes() {
88970
+ return this.bounds.maxClipBytes;
88971
+ }
88972
+ /**
88973
+ * Open a file for one HDS stream. `null` when the directory cannot be made —
88974
+ * the caller must then skip the tee entirely and leave HKSV untouched, which
88975
+ * is why this returns rather than throws.
88976
+ */
88977
+ async open(input) {
88978
+ const clipId = clipIdFor(input.deviceId, input.startedAtMs, input.streamId);
88979
+ const names = clipFileNames(clipId);
88980
+ const dir = this.deviceDir(input.deviceId);
88981
+ const tags = { deviceId: input.deviceId };
88982
+ try {
88983
+ await (0, node_fs_promises.mkdir)(dir, { recursive: true });
88984
+ const sink = await createFileSink((0, node_path.join)(dir, names.clip));
88985
+ this.inFlight.set(clipId, {
88986
+ deviceId: input.deviceId,
88987
+ stem: stemOf(names.clip)
88988
+ });
88989
+ return {
88990
+ clipId,
88991
+ sink,
88992
+ complete: (outcome) => this.complete(clipId, input, names, outcome)
88993
+ };
88994
+ } catch (err) {
88995
+ this.inFlight.delete(clipId);
88996
+ this.log.warn("hksv clip store: could NOT open a clip file — nothing is teed for this stream", {
88997
+ tags,
88998
+ meta: {
88999
+ streamId: input.streamId,
89000
+ error: err instanceof Error ? err.message : String(err)
89001
+ }
89002
+ });
89003
+ return null;
89004
+ }
89005
+ }
89006
+ /** Every sidecar for a camera, newest first. What a later provider lists. */
89007
+ async listRecords(deviceId) {
89008
+ const dir = this.deviceDir(deviceId);
89009
+ let names;
89010
+ try {
89011
+ names = await (0, node_fs_promises.readdir)(dir);
89012
+ } catch {
89013
+ return [];
89014
+ }
89015
+ const records = [];
89016
+ for (const name of names) {
89017
+ if (!name.endsWith(".json")) continue;
89018
+ const record = await this.readRecord((0, node_path.join)(dir, name), deviceId);
89019
+ if (record !== null) records.push(record);
89020
+ }
89021
+ return records.sort((a, b) => b.startedAtMs - a.startedAtMs);
89022
+ }
89023
+ /** iOS sent `ack`: HomeKit itself kept this clip. Recorded on the sidecar. */
89024
+ async acknowledge(deviceId, clipId) {
89025
+ const path = (0, node_path.join)(this.deviceDir(deviceId), clipFileNames(clipId).record);
89026
+ const record = await this.readRecord(path, deviceId);
89027
+ if (record === null) return;
89028
+ await this.writeRecord(path, {
89029
+ ...record,
89030
+ acknowledgedByHomeKit: true
89031
+ });
89032
+ }
89033
+ deviceDir(deviceId) {
89034
+ return (0, node_path.join)(this.input.rootDir, String(deviceId));
89035
+ }
89036
+ async complete(clipId, input, names, outcome) {
89037
+ this.inFlight.delete(clipId);
89038
+ const dir = this.deviceDir(input.deviceId);
89039
+ const tags = { deviceId: input.deviceId };
89040
+ const clipPath = (0, node_path.join)(dir, names.clip);
89041
+ if (!outcome.sawInit || outcome.bytes === 0) {
89042
+ await (0, node_fs_promises.rm)(clipPath, { force: true });
89043
+ this.log.warn("hksv clip tee: DROPPED the clip — it carries no init segment, so nothing in it is decodable", {
89044
+ tags,
89045
+ meta: {
89046
+ clipId,
89047
+ streamId: input.streamId,
89048
+ bytes: outcome.bytes,
89049
+ fragments: outcome.fragments
89050
+ }
89051
+ });
89052
+ return null;
89053
+ }
89054
+ const thumbnail = await this.mintAndVouch({
89055
+ deviceId: input.deviceId,
89056
+ clipPath,
89057
+ jpegPath: (0, node_path.join)(dir, names.thumbnail),
89058
+ triggerOffsetSec: input.prebufferSpanMs / 1e3
89059
+ });
89060
+ const record = {
89061
+ schemaVersion: 1,
89062
+ clipId,
89063
+ source: HKSV_CLIP_SOURCE,
89064
+ deviceId: input.deviceId,
89065
+ streamId: input.streamId,
89066
+ startedAtMs: outcome.startedAtMs,
89067
+ endedAtMs: outcome.endedAtMs,
89068
+ durationMs: Math.max(0, outcome.endedAtMs - outcome.startedAtMs),
89069
+ prebufferSpanMs: input.prebufferSpanMs,
89070
+ bytes: outcome.bytes,
89071
+ fragments: outcome.fragments,
89072
+ file: names.clip,
89073
+ ...thumbnail.ok ? { thumbnailFile: names.thumbnail } : { thumbnailUnavailable: { reason: thumbnail.reason } },
89074
+ ...outcome.truncated === null ? {} : { truncated: outcome.truncated },
89075
+ acknowledgedByHomeKit: false,
89076
+ width: input.width,
89077
+ height: input.height,
89078
+ fragmentMs: input.fragmentMs
89079
+ };
89080
+ await this.writeRecord((0, node_path.join)(dir, names.record), record);
89081
+ this.log.info("hksv clip tee: a HomeKit clip was KEPT", {
89082
+ tags,
89083
+ meta: {
89084
+ clipId,
89085
+ streamId: input.streamId,
89086
+ bytes: record.bytes,
89087
+ fragments: record.fragments,
89088
+ durationMs: record.durationMs,
89089
+ prebufferSpanMs: record.prebufferSpanMs,
89090
+ truncated: record.truncated ?? null,
89091
+ thumbnail: record.thumbnailFile ?? null,
89092
+ thumbnailUnavailable: record.thumbnailUnavailable?.reason ?? null
89093
+ }
89094
+ });
89095
+ await this.prune(input.deviceId);
89096
+ return record;
89097
+ }
89098
+ /**
89099
+ * Mint, then CHECK. The minter's own verdict decides nothing on its own: a
89100
+ * JPEG is vouched by `stat`, never by a return value.
89101
+ */
89102
+ async mintAndVouch(mint) {
89103
+ const { deviceId, jpegPath } = mint;
89104
+ const minter = this.input.mintThumbnail;
89105
+ if (minter === void 0) return {
89106
+ ok: false,
89107
+ reason: "not-attempted"
89108
+ };
89109
+ let declared;
89110
+ try {
89111
+ declared = await minter(mint);
89112
+ } catch (err) {
89113
+ this.log.warn("hksv clip tee: the thumbnail minter threw — the clip is kept WITHOUT a still", {
89114
+ tags: { deviceId },
89115
+ meta: { error: err instanceof Error ? err.message : String(err) }
89116
+ });
89117
+ declared = {
89118
+ ok: false,
89119
+ reason: "decode-failed"
89120
+ };
89121
+ }
89122
+ if (await fileSize(jpegPath) > 0) return { ok: true };
89123
+ await (0, node_fs_promises.rm)(jpegPath, { force: true });
89124
+ const reason = declared.ok ? "decode-failed" : declared.reason;
89125
+ this.log.warn("hksv clip tee: no thumbnail for this clip", {
89126
+ tags: { deviceId },
89127
+ meta: {
89128
+ reason,
89129
+ declaredOk: declared.ok
89130
+ }
89131
+ });
89132
+ return {
89133
+ ok: false,
89134
+ reason
89135
+ };
89136
+ }
89137
+ /**
89138
+ * Enforce every bound, oldest first. Enumerated from the MP4s rather than the
89139
+ * sidecars, so a stem whose sidecar never landed — a crash mid-write — is
89140
+ * seen and removed instead of occupying the disk invisibly forever.
89141
+ */
89142
+ async prune(deviceId) {
89143
+ const cutoff = this.now() - this.bounds.maxAgeMs;
89144
+ const perDevice = await this.scanDevice(deviceId);
89145
+ await this.enforce(perDevice, cutoff, this.bounds.maxClipsPerDevice, this.bounds.maxBytesPerDevice);
89146
+ let deviceIds;
89147
+ try {
89148
+ deviceIds = (await (0, node_fs_promises.readdir)(this.input.rootDir, { withFileTypes: true })).filter((e) => e.isDirectory()).map((e) => Number.parseInt(e.name, 10)).filter((id) => Number.isFinite(id));
89149
+ } catch {
89150
+ return;
89151
+ }
89152
+ const all = [];
89153
+ for (const id of deviceIds) all.push(...await this.scanDevice(id));
89154
+ await this.enforce(all, cutoff, Number.POSITIVE_INFINITY, this.bounds.maxStoreBytes);
89155
+ }
89156
+ async enforce(clips, ageCutoff, maxCount, maxBytes) {
89157
+ const ordered = [...clips].sort((a, b) => b.startedAtMs - a.startedAtMs);
89158
+ let kept = 0;
89159
+ let bytes = 0;
89160
+ for (const clip of ordered) {
89161
+ const tooOld = clip.startedAtMs < ageCutoff;
89162
+ const overCount = kept >= maxCount;
89163
+ const overBytes = bytes + clip.bytes > maxBytes;
89164
+ if (!tooOld && !overCount && !overBytes) {
89165
+ kept += 1;
89166
+ bytes += clip.bytes;
89167
+ continue;
89168
+ }
89169
+ await this.deleteClip(clip, tooOld ? "age" : overCount ? "count" : "bytes");
89170
+ }
89171
+ }
89172
+ async deleteClip(clip, reason) {
89173
+ for (const name of [
89174
+ `${clip.stem}.mp4`,
89175
+ `${clip.stem}.jpg`,
89176
+ `${clip.stem}.json`
89177
+ ]) await (0, node_fs_promises.rm)((0, node_path.join)(clip.dir, name), { force: true });
89178
+ this.log.info("hksv clip store: pruned a clip", {
89179
+ tags: { deviceId: clip.deviceId },
89180
+ meta: {
89181
+ stem: clip.stem,
89182
+ bytes: clip.bytes,
89183
+ reason,
89184
+ startedAtMs: clip.startedAtMs
89185
+ }
89186
+ });
89187
+ }
89188
+ async scanDevice(deviceId) {
89189
+ const dir = this.deviceDir(deviceId);
89190
+ let names;
89191
+ try {
89192
+ names = await (0, node_fs_promises.readdir)(dir);
89193
+ } catch {
89194
+ return [];
89195
+ }
89196
+ const clips = [];
89197
+ for (const name of names) {
89198
+ if (!name.endsWith(".mp4")) continue;
89199
+ const stem = stemOf(name);
89200
+ if (this.isInFlight(deviceId, stem)) continue;
89201
+ const bytes = await fileSize((0, node_path.join)(dir, name));
89202
+ const record = await this.readRecord((0, node_path.join)(dir, `${stem}.json`), deviceId);
89203
+ if (record === null) {
89204
+ await (0, node_fs_promises.rm)((0, node_path.join)(dir, name), { force: true });
89205
+ this.log.warn("hksv clip store: removed an orphaned clip file (no sidecar, not being written)", {
89206
+ tags: { deviceId },
89207
+ meta: {
89208
+ stem,
89209
+ bytes
89210
+ }
89211
+ });
89212
+ continue;
89213
+ }
89214
+ clips.push({
89215
+ deviceId,
89216
+ dir,
89217
+ stem,
89218
+ bytes,
89219
+ startedAtMs: record.startedAtMs
89220
+ });
89221
+ }
89222
+ return clips;
89223
+ }
89224
+ isInFlight(deviceId, stem) {
89225
+ for (const entry of this.inFlight.values()) if (entry.deviceId === deviceId && entry.stem === stem) return true;
89226
+ return false;
89227
+ }
89228
+ async readRecord(path, deviceId) {
89229
+ let raw;
89230
+ try {
89231
+ raw = await (0, node_fs_promises.readFile)(path, "utf8");
89232
+ } catch {
89233
+ return null;
89234
+ }
89235
+ const parsed = HksvClipRecordSchema.safeParse(JSON.parse(raw));
89236
+ if (parsed.success) return parsed.data;
89237
+ this.log.warn("hksv clip store: a sidecar could not be read — the clip is not listed", {
89238
+ tags: { deviceId },
89239
+ meta: {
89240
+ path,
89241
+ error: parsed.error.message
89242
+ }
89243
+ });
89244
+ return null;
89245
+ }
89246
+ async writeRecord(path, record) {
89247
+ await (0, node_fs_promises.writeFile)(path, JSON.stringify(record, null, 2), "utf8");
89248
+ }
89249
+ };
89250
+ function stemOf(fileName) {
89251
+ return fileName.replace(/\.[^.]+$/, "");
89252
+ }
89253
+ async function fileSize(path) {
88439
89254
  try {
88440
- const device = await proxy.deviceManager?.getDevice({});
88441
- const metadata = readMetadata(device);
88442
- info.setCharacteristic(import_dist.Characteristic.Name, device?.name ?? displayName);
88443
- info.setCharacteristic(import_dist.Characteristic.Manufacturer, stringOr(metadata?.manufacturer, "CamStack"));
88444
- info.setCharacteristic(import_dist.Characteristic.Model, stringOr(metadata?.model, modelFallback));
88445
- info.setCharacteristic(import_dist.Characteristic.FirmwareRevision, stringOr(metadata?.firmware, "0.0.0"));
88446
- info.setCharacteristic(import_dist.Characteristic.SerialNumber, stringOr(metadata?.sn, `camstack-${proxy.deviceId}`));
88447
- } catch {}
89255
+ return (await (0, node_fs_promises.stat)(path)).size;
89256
+ } catch {
89257
+ return 0;
89258
+ }
88448
89259
  }
88449
- /** The four fields this module reads, or `null` — the device record's
88450
- * `metadata` is an open bag and nothing else here depends on its shape. */
88451
- function readMetadata(device) {
88452
- const metadata = device?.metadata;
88453
- if (metadata === null || typeof metadata !== "object") return {};
88454
- const entries = new Map(Object.entries(metadata));
89260
+ /**
89261
+ * A sink over a real file that HONOURS backpressure: `write()` returning false
89262
+ * means the kernel has not taken the bytes, and ignoring it is how a stream
89263
+ * grows a queue nobody bounded (D447).
89264
+ */
89265
+ async function createFileSink(path) {
89266
+ const stream = (0, node_fs.createWriteStream)(path);
89267
+ await (0, node_events.once)(stream, "open");
88455
89268
  return {
88456
- model: stringOrNull(entries.get("model")),
88457
- manufacturer: stringOrNull(entries.get("manufacturer")),
88458
- firmware: stringOrNull(entries.get("firmware")),
88459
- sn: stringOrNull(entries.get("sn"))
89269
+ async write(chunk) {
89270
+ if (!stream.write(chunk)) await (0, node_events.once)(stream, "drain");
89271
+ },
89272
+ async close() {
89273
+ await new Promise((resolve, reject) => {
89274
+ stream.end((err) => err ? reject(err) : resolve());
89275
+ });
89276
+ }
88460
89277
  };
88461
89278
  }
88462
- function stringOrNull(value) {
88463
- return typeof value === "string" ? value : null;
89279
+ function buildClipThumbnailArgs(input) {
89280
+ return [
89281
+ "-hide_banner",
89282
+ "-loglevel",
89283
+ "error",
89284
+ "-nostdin",
89285
+ "-y",
89286
+ "-ss",
89287
+ String(Math.max(0, Math.floor(input.seekSec))),
89288
+ "-i",
89289
+ input.clipPath,
89290
+ "-frames:v",
89291
+ "1",
89292
+ "-vf",
89293
+ `scale='min(${input.maxWidth},iw)':-2`,
89294
+ "-f",
89295
+ "image2",
89296
+ "-vcodec",
89297
+ "mjpeg",
89298
+ input.jpegPath
89299
+ ];
88464
89300
  }
88465
- function stringOr(value, fallback) {
88466
- return typeof value === "string" && value.length > 0 ? value : fallback;
89301
+ function createClipThumbnailMinter(input) {
89302
+ return async ({ clipPath, jpegPath, triggerOffsetSec }) => {
89303
+ const args = buildClipThumbnailArgs({
89304
+ clipPath,
89305
+ jpegPath,
89306
+ seekSec: triggerOffsetSec,
89307
+ maxWidth: input.maxWidth ?? 640
89308
+ });
89309
+ let run;
89310
+ try {
89311
+ run = await input.runner(args);
89312
+ } catch {
89313
+ return {
89314
+ ok: false,
89315
+ reason: "decode-failed"
89316
+ };
89317
+ }
89318
+ if (run.spawnFailed) return {
89319
+ ok: false,
89320
+ reason: "ffmpeg-missing"
89321
+ };
89322
+ if (run.timedOut) return {
89323
+ ok: false,
89324
+ reason: "timed-out"
89325
+ };
89326
+ if (run.code !== 0) return {
89327
+ ok: false,
89328
+ reason: "decode-failed"
89329
+ };
89330
+ return { ok: true };
89331
+ };
89332
+ }
89333
+ /**
89334
+ * The production runner: one bounded ffmpeg, killed at the deadline.
89335
+ *
89336
+ * Bounded and killed, not merely awaited — an ffmpeg that never exits on a
89337
+ * clip it cannot parse would otherwise hold a process per clip on a hub that
89338
+ * already runs a permanent prebuffer per recorded camera.
89339
+ */
89340
+ function createFfmpegClipThumbnailRunner(input) {
89341
+ const timeoutMs = input.timeoutMs ?? 1e4;
89342
+ return (args) => new Promise((resolve) => {
89343
+ let settled = false;
89344
+ const finish = (run) => {
89345
+ if (settled) return;
89346
+ settled = true;
89347
+ clearTimeout(timer);
89348
+ resolve(run);
89349
+ };
89350
+ const child = input.spawnFn(input.ffmpegBinaryPath, [...args], { stdio: [
89351
+ "ignore",
89352
+ "ignore",
89353
+ "pipe"
89354
+ ] });
89355
+ let stderr = "";
89356
+ child.stderr?.on("data", (chunk) => {
89357
+ if (stderr.length < 2e3) stderr += chunk.toString("utf8");
89358
+ });
89359
+ const timer = setTimeout(() => {
89360
+ child.kill("SIGKILL");
89361
+ finish({
89362
+ code: null,
89363
+ timedOut: true,
89364
+ spawnFailed: false,
89365
+ stderr
89366
+ });
89367
+ }, timeoutMs);
89368
+ timer.unref?.();
89369
+ child.on("error", (err) => {
89370
+ finish({
89371
+ code: null,
89372
+ timedOut: false,
89373
+ spawnFailed: true,
89374
+ stderr: err.message
89375
+ });
89376
+ });
89377
+ child.on("close", (code) => {
89378
+ finish({
89379
+ code,
89380
+ timedOut: false,
89381
+ spawnFailed: false,
89382
+ stderr
89383
+ });
89384
+ });
89385
+ });
88467
89386
  }
88468
89387
  //#endregion
88469
89388
  //#region src/mappers/builders/generic/characteristic-update.ts
@@ -88793,6 +89712,910 @@ function errMsg$14(err) {
88793
89712
  return err instanceof Error ? err.message : String(err);
88794
89713
  }
88795
89714
  //#endregion
89715
+ //#region src/mappers/builders/child-switch.ts
89716
+ /**
89717
+ * Child-switch builder — turns a camstack accessory child device (siren,
89718
+ * floodlight, spotlight, …) into a HomeKit Service ON THE PARENT
89719
+ * camera Accessory.
89720
+ *
89721
+ * The orchestrator (`child-accessory.ts → buildChildServicesFor`)
89722
+ * hands this builder the PARENT accessory + the CHILD's proxy + a
89723
+ * unique subtype so each service round-trips deterministically through
89724
+ * HAP serialisation. The builder:
89725
+ * - Maps `switch` cap → `Service.Switch`.
89726
+ * - Maps `switch` + `brightness` cap → `Service.Lightbulb` with
89727
+ * Brightness characteristic (lighting devices only — see below).
89728
+ * - Hydrates initial state from `getStatus({})`.
89729
+ * - Wires `Characteristic.On` / `Characteristic.Brightness` through
89730
+ * the typed DeviceProxy.
89731
+ * - Subscribes to runtime-state slice updates so iOS Home reflects
89732
+ * external state changes (e.g. another integration toggling the
89733
+ * light).
89734
+ *
89735
+ * Service-kind selection: most cameras expose `brightness` on a siren
89736
+ * because it controls the alarm VOLUME, not luminosity. Mapping a
89737
+ * siren onto `Service.Lightbulb` makes it appear as a regular light in
89738
+ * iOS Home (wrong icon, wrong scenes, wrong Siri intents). We therefore
89739
+ * gate `Service.Lightbulb` on the child's `DeviceType` — only Light /
89740
+ * Generic devices keep the Lightbulb shape; Siren, Switch, and others
89741
+ * fall back to `Service.Switch`. Volume control for sirens is exposed
89742
+ * through the camstack admin UI; iOS Home only needs the on/off toggle.
89743
+ *
89744
+ * Child devices without `switch` / `brightness` cap (status sensors,
89745
+ * IR LED, etc.) return a no-op handle.
89746
+ */
89747
+ async function buildChildSwitch(bctx, subtype, deviceType) {
89748
+ const { ctx, accessory, proxy, numericDeviceId, displayName } = bctx;
89749
+ const log = ctx.logger.withTags({ deviceId: numericDeviceId });
89750
+ const hasSwitch = !!proxy.switch;
89751
+ const hasBrightness = !!proxy.brightness;
89752
+ if (!hasSwitch && !hasBrightness) {
89753
+ log.debug("export-hap: child has no switch/brightness cap — skipping");
89754
+ return { async dispose() {} };
89755
+ }
89756
+ const isLightingDevice = deviceType === DeviceType.Light || deviceType === DeviceType.Generic;
89757
+ const useLightbulb = hasBrightness && isLightingDevice;
89758
+ const service = useLightbulb ? accessory.addService(import_dist.Service.Lightbulb, displayName, subtype) : accessory.addService(import_dist.Service.Switch, displayName, subtype);
89759
+ applyServiceLabel(service, displayName);
89760
+ try {
89761
+ const switchStatus = await proxy.switch?.getStatus({});
89762
+ if (switchStatus && typeof switchStatus.on === "boolean") service.updateCharacteristic(import_dist.Characteristic.On, switchStatus.on);
89763
+ } catch (err) {
89764
+ log.debug("export-hap: child switch.getStatus failed (non-fatal)", { meta: { error: errMsg$13(err) } });
89765
+ }
89766
+ if (useLightbulb) try {
89767
+ const status = await proxy.brightness?.getStatus({});
89768
+ if (status && typeof status.percentage === "number") service.updateCharacteristic(import_dist.Characteristic.Brightness, status.percentage);
89769
+ } catch (err) {
89770
+ log.debug("export-hap: child brightness.getStatus failed (non-fatal)", { meta: { error: errMsg$13(err) } });
89771
+ }
89772
+ if (hasSwitch) service.getCharacteristic(import_dist.Characteristic.On).onSet(async (value) => {
89773
+ const on = value === true;
89774
+ try {
89775
+ await proxy.switch?.setState({ on });
89776
+ } catch (err) {
89777
+ log.warn("export-hap: child switch.setState failed", { meta: {
89778
+ on,
89779
+ error: errMsg$13(err)
89780
+ } });
89781
+ }
89782
+ });
89783
+ if (useLightbulb) service.getCharacteristic(import_dist.Characteristic.Brightness).onSet(async (value) => {
89784
+ const percentage = typeof value === "number" ? value : Number(value);
89785
+ if (!Number.isFinite(percentage)) return;
89786
+ try {
89787
+ await proxy.brightness?.setBrightness({ percentage });
89788
+ } catch (err) {
89789
+ log.warn("export-hap: child brightness.setBrightness failed", { meta: {
89790
+ percentage,
89791
+ error: errMsg$13(err)
89792
+ } });
89793
+ }
89794
+ });
89795
+ const unsubscribes = [];
89796
+ if (proxy.state.switch) {
89797
+ const unsubSwitch = proxy.state.switch.subscribe((value) => {
89798
+ if (value && typeof value.on === "boolean") service.updateCharacteristic(import_dist.Characteristic.On, value.on);
89799
+ });
89800
+ unsubscribes.push(unsubSwitch);
89801
+ }
89802
+ if (useLightbulb && proxy.state.brightness) {
89803
+ const unsubBrightness = proxy.state.brightness.subscribe((value) => {
89804
+ if (value && typeof value.percentage === "number") service.updateCharacteristic(import_dist.Characteristic.Brightness, value.percentage);
89805
+ });
89806
+ unsubscribes.push(unsubBrightness);
89807
+ }
89808
+ return { async dispose() {
89809
+ for (const u of unsubscribes) try {
89810
+ u();
89811
+ } catch {}
89812
+ } };
89813
+ }
89814
+ function errMsg$13(err) {
89815
+ return err instanceof Error ? err.message : String(err);
89816
+ }
89817
+ //#endregion
89818
+ //#region src/mappers/builders/generic/lock.ts
89819
+ /**
89820
+ * `lock-control` → `Service.LockMechanism`.
89821
+ *
89822
+ * Not a sensor row: HomeKit models a lock as two characteristics, a CURRENT
89823
+ * state the accessory owns and a TARGET state the controller writes, and the
89824
+ * cap's five states do not map onto either one alone.
89825
+ *
89826
+ * ```
89827
+ * cap state LockCurrentState LockTargetState
89828
+ * locked SECURED SECURED
89829
+ * unlocked UNSECURED UNSECURED
89830
+ * locking UNKNOWN SECURED (in flight — do not claim SECURED)
89831
+ * unlocking UNKNOWN UNSECURED
89832
+ * jammed JAMMED unchanged (the controller's intent stands)
89833
+ * ```
89834
+ *
89835
+ * Reporting SECURED while the bolt is still moving is the failure worth naming:
89836
+ * iOS renders the lock as closed, the operator walks away, and the motor stalls
89837
+ * behind them. `UNKNOWN` is the honest answer for an in-flight transition.
89838
+ *
89839
+ * `open` (the cap's third method, for locks with a latch/buzzer) has no HomeKit
89840
+ * counterpart on this service and is deliberately not wired — a Switch that
89841
+ * silently buzzes a door open would be a second knob nobody asked for.
89842
+ */
89843
+ var SUBTYPE$1 = "lock-control";
89844
+ var readLock = reader(LockControlStatusSchema.pick({ state: true }), (status) => {
89845
+ const current = status.state === "locked" ? import_dist.Characteristic.LockCurrentState.SECURED : status.state === "unlocked" ? import_dist.Characteristic.LockCurrentState.UNSECURED : status.state === "jammed" ? import_dist.Characteristic.LockCurrentState.JAMMED : import_dist.Characteristic.LockCurrentState.UNKNOWN;
89846
+ const target = status.state === "locked" || status.state === "locking" ? import_dist.Characteristic.LockTargetState.SECURED : status.state === "unlocked" || status.state === "unlocking" ? import_dist.Characteristic.LockTargetState.UNSECURED : null;
89847
+ return [{
89848
+ characteristic: import_dist.Characteristic.LockCurrentState,
89849
+ value: current
89850
+ }, ...target === null ? [] : [{
89851
+ characteristic: import_dist.Characteristic.LockTargetState,
89852
+ value: target
89853
+ }]];
89854
+ });
89855
+ async function buildLockMechanism(bctx) {
89856
+ const { ctx, accessory, proxy, numericDeviceId, displayName } = bctx;
89857
+ const log = ctx.logger.withTags({ deviceId: numericDeviceId });
89858
+ const label = hapServiceName([displayName], `Lock ${numericDeviceId}`);
89859
+ const service = accessory.addService(import_dist.Service.LockMechanism, label, SUBTYPE$1);
89860
+ applyServiceLabel(service, label);
89861
+ const apply = (status, source) => {
89862
+ const updates = readLock(status);
89863
+ if (updates.length === 0) {
89864
+ log.debug("export-hap: lock status did not match the cap schema — no update", { meta: { source } });
89865
+ return;
89866
+ }
89867
+ applyUpdates(service, updates);
89868
+ };
89869
+ try {
89870
+ const status = await proxy.lockControl?.getStatus({});
89871
+ if (status !== void 0 && status !== null) apply(status, "getStatus");
89872
+ } catch (err) {
89873
+ log.debug("export-hap: lock getStatus hydrate failed (non-fatal)", { meta: { error: errMsg$12(err) } });
89874
+ }
89875
+ service.getCharacteristic(import_dist.Characteristic.LockTargetState).onSet(async (value) => {
89876
+ const secure = value === import_dist.Characteristic.LockTargetState.SECURED;
89877
+ try {
89878
+ if (secure) await proxy.lockControl?.lock({});
89879
+ else await proxy.lockControl?.unlock({});
89880
+ } catch (err) {
89881
+ log.warn("export-hap: lock command failed", { meta: {
89882
+ secure,
89883
+ error: errMsg$12(err)
89884
+ } });
89885
+ }
89886
+ });
89887
+ const unsubscribe = proxy.state.lockControl?.subscribe((value) => {
89888
+ if (value === void 0 || value === null) return;
89889
+ apply(value, "slice");
89890
+ }) ?? null;
89891
+ return { async dispose() {
89892
+ try {
89893
+ unsubscribe?.();
89894
+ } catch {}
89895
+ } };
89896
+ }
89897
+ function errMsg$12(err) {
89898
+ return err instanceof Error ? err.message : String(err);
89899
+ }
89900
+ //#endregion
89901
+ //#region src/mappers/builders/generic/security-state.ts
89902
+ /** Is this a settled arm state worth remembering? */
89903
+ function armedMemoOf(state) {
89904
+ return state === "armed_home" || state === "armed_away" || state === "armed_night" || state === "armed_vacation" || state === "armed_custom_bypass" ? state : null;
89905
+ }
89906
+ function currentForArmed(state) {
89907
+ switch (state) {
89908
+ case "armed_home":
89909
+ case "armed_custom_bypass": return import_dist.Characteristic.SecuritySystemCurrentState.STAY_ARM;
89910
+ case "armed_night": return import_dist.Characteristic.SecuritySystemCurrentState.NIGHT_ARM;
89911
+ default: return import_dist.Characteristic.SecuritySystemCurrentState.AWAY_ARM;
89912
+ }
89913
+ }
89914
+ function targetForArmed(state) {
89915
+ switch (state) {
89916
+ case "armed_home":
89917
+ case "armed_custom_bypass": return import_dist.Characteristic.SecuritySystemTargetState.STAY_ARM;
89918
+ case "armed_night": return import_dist.Characteristic.SecuritySystemTargetState.NIGHT_ARM;
89919
+ default: return import_dist.Characteristic.SecuritySystemTargetState.AWAY_ARM;
89920
+ }
89921
+ }
89922
+ function securityStateFor(state, memo) {
89923
+ const settled = armedMemoOf(state);
89924
+ if (settled !== null) return {
89925
+ current: currentForArmed(settled),
89926
+ target: targetForArmed(settled)
89927
+ };
89928
+ switch (state) {
89929
+ case "disarmed": return {
89930
+ current: import_dist.Characteristic.SecuritySystemCurrentState.DISARMED,
89931
+ target: import_dist.Characteristic.SecuritySystemTargetState.DISARM
89932
+ };
89933
+ case "arming": return {
89934
+ current: import_dist.Characteristic.SecuritySystemCurrentState.DISARMED,
89935
+ target: memo === null ? null : targetForArmed(memo)
89936
+ };
89937
+ case "disarming": return {
89938
+ current: memo === null ? import_dist.Characteristic.SecuritySystemCurrentState.DISARMED : currentForArmed(memo),
89939
+ target: import_dist.Characteristic.SecuritySystemTargetState.DISARM
89940
+ };
89941
+ case "pending": return {
89942
+ current: memo === null ? import_dist.Characteristic.SecuritySystemCurrentState.DISARMED : currentForArmed(memo),
89943
+ target: memo === null ? null : targetForArmed(memo)
89944
+ };
89945
+ default: return {
89946
+ current: import_dist.Characteristic.SecuritySystemCurrentState.ALARM_TRIGGERED,
89947
+ target: memo === null ? null : targetForArmed(memo)
89948
+ };
89949
+ }
89950
+ }
89951
+ /**
89952
+ * The arm mode behind a HomeKit target, or `null` for DISARM and for anything
89953
+ * outside the four values HomeKit defines.
89954
+ *
89955
+ * `null` is not an error the caller can ignore: DISARM is a different COMMAND,
89956
+ * and a value HomeKit never sends is a write this builder refuses to translate.
89957
+ */
89958
+ function alarmArmModeFor(target) {
89959
+ switch (target) {
89960
+ case import_dist.Characteristic.SecuritySystemTargetState.STAY_ARM: return "home";
89961
+ case import_dist.Characteristic.SecuritySystemTargetState.AWAY_ARM: return "away";
89962
+ case import_dist.Characteristic.SecuritySystemTargetState.NIGHT_ARM: return "night";
89963
+ default: return null;
89964
+ }
89965
+ }
89966
+ //#endregion
89967
+ //#region src/mappers/builders/generic/security-target.ts
89968
+ /**
89969
+ * The panel modes behind each HomeKit target, PRIMARY first.
89970
+ *
89971
+ * The primary is the mode `alarmArmModeFor` names; the secondary is the other
89972
+ * mode `security-state.ts` reports through the same HomeKit value, so the two
89973
+ * modules agree in both directions by construction.
89974
+ */
89975
+ var MODES_BY_TARGET = new Map([
89976
+ [import_dist.Characteristic.SecuritySystemTargetState.STAY_ARM, ["home", "custom_bypass"]],
89977
+ [import_dist.Characteristic.SecuritySystemTargetState.AWAY_ARM, ["away", "vacation"]],
89978
+ [import_dist.Characteristic.SecuritySystemTargetState.NIGHT_ARM, ["night"]]
89979
+ ]);
89980
+ /** Arm targets in the order HomeKit numbers them; DISARM is appended last. */
89981
+ var ARM_TARGETS = [
89982
+ import_dist.Characteristic.SecuritySystemTargetState.STAY_ARM,
89983
+ import_dist.Characteristic.SecuritySystemTargetState.AWAY_ARM,
89984
+ import_dist.Characteristic.SecuritySystemTargetState.NIGHT_ARM
89985
+ ];
89986
+ /**
89987
+ * The `validValues` for `SecuritySystemTargetState`, or `null` to leave
89988
+ * HomeKit's own four values in place.
89989
+ *
89990
+ * DISARM is always present: disarming is not one of the panel's arm modes and
89991
+ * a panel that cannot be disarmed is not a thing this cap can express.
89992
+ */
89993
+ function targetValidValuesFor(availableModes) {
89994
+ if (availableModes.length === 0) return null;
89995
+ return [...ARM_TARGETS.filter((target) => (MODES_BY_TARGET.get(target) ?? []).some((mode) => availableModes.includes(mode))), import_dist.Characteristic.SecuritySystemTargetState.DISARM];
89996
+ }
89997
+ /**
89998
+ * The arm mode to send for a HomeKit target, or `null` when there is none.
89999
+ *
90000
+ * `null` is not a shrug the caller may swallow: DISARM is a different command,
90001
+ * and an arm target the panel accepts no mode for is a write this module
90002
+ * refuses to translate rather than guess at.
90003
+ */
90004
+ function armModeForTarget(target, availableModes) {
90005
+ const primary = alarmArmModeFor(target);
90006
+ if (primary === null) return null;
90007
+ if (availableModes.length === 0) return primary;
90008
+ return (MODES_BY_TARGET.get(target) ?? [primary]).find((mode) => availableModes.includes(mode)) ?? null;
90009
+ }
90010
+ //#endregion
90011
+ //#region src/mappers/builders/generic/security-system.ts
90012
+ /**
90013
+ * `alarm-panel` → `Service.SecuritySystem`.
90014
+ *
90015
+ * The lock builder's shape (a current/target pair, a hydrate, an `onSet`, a
90016
+ * slice subscription, a dispose) applied to the one device on the fleet where
90017
+ * reporting a state that is not true has a consequence worse than a stalled
90018
+ * motor. The mapping itself is NOT here: `security-state.ts` owns what the
90019
+ * panel's state looks like to HomeKit and `security-target.ts` owns what
90020
+ * HomeKit may ask for, both pure and both tested on their own. This file is
90021
+ * the service around them.
90022
+ *
90023
+ * ## The memo
90024
+ *
90025
+ * `pending`, `disarming` and `triggered` do not carry the mode the panel was
90026
+ * armed to — only the last settled state knows it. This builder is the caller
90027
+ * `security-state.ts` describes: it remembers the last settled arm and forgets
90028
+ * it on `disarmed`, because a memo that outlives a disarm makes the next
90029
+ * `arming` claim a target nobody asked for.
90030
+ *
90031
+ * The memo is deliberately NOT seeded from the controller's own write. HomeKit
90032
+ * already holds the target the operator just set; not writing it back is
90033
+ * exactly right. The cost is a panel armed from its own keypad during an exit
90034
+ * delay, where the target stays at whatever HomeKit last knew — a stale target
90035
+ * for the length of the delay, against inventing an intent, and the invention
90036
+ * is worse.
90037
+ *
90038
+ * ## `requiresCode`: the arm is REFUSED, not attempted
90039
+ *
90040
+ * HomeKit's SecuritySystem has no PIN prompt — there is no characteristic for
90041
+ * it and no way to ask the operator mid-write. So a panel that requires a code
90042
+ * cannot be armed or disarmed from the Home app, and this builder says so
90043
+ * twice: the write fails with `INSUFFICIENT_AUTHORIZATION` (iOS renders the
90044
+ * tile's failure) and a WARN names the device, the command and the mode.
90045
+ *
90046
+ * Sending the command code-less and letting the panel reject it was the
90047
+ * alternative. It is worse: the refusal costs a round trip, it is
90048
+ * indistinguishable from a broken panel in the log, and repeated code-less
90049
+ * attempts are what locks a real alarm panel out.
90050
+ */
90051
+ var SUBTYPE = "alarm-panel";
90052
+ /** The subset of the status this builder reads. */
90053
+ var PanelReadSchema = AlarmPanelStatusSchema.pick({
90054
+ state: true,
90055
+ availableModes: true,
90056
+ requiresCode: true
90057
+ });
90058
+ /**
90059
+ * Turn a panel status into characteristic writes, folding the memo.
90060
+ *
90061
+ * A payload that does not match the cap's own schema yields NO updates and
90062
+ * leaves the memo alone — it is a status we could not read, not a panel that
90063
+ * changed. The caller logs the drop; a frozen characteristic is otherwise
90064
+ * indistinguishable from a quiet panel.
90065
+ */
90066
+ function securityUpdatesFor(status, memo) {
90067
+ const parsed = PanelReadSchema.safeParse(status);
90068
+ if (!parsed.success) return {
90069
+ updates: [],
90070
+ memo,
90071
+ panel: null
90072
+ };
90073
+ const nextMemo = parsed.data.state === "disarmed" ? null : armedMemoOf(parsed.data.state) ?? memo;
90074
+ const { current, target } = securityStateFor(parsed.data.state, nextMemo);
90075
+ return {
90076
+ updates: [{
90077
+ characteristic: import_dist.Characteristic.SecuritySystemCurrentState,
90078
+ value: current
90079
+ }, ...target === null ? [] : [{
90080
+ characteristic: import_dist.Characteristic.SecuritySystemTargetState,
90081
+ value: target
90082
+ }]],
90083
+ memo: nextMemo,
90084
+ panel: {
90085
+ availableModes: parsed.data.availableModes,
90086
+ requiresCode: parsed.data.requiresCode
90087
+ }
90088
+ };
90089
+ }
90090
+ async function buildSecuritySystem(bctx) {
90091
+ const { ctx, accessory, proxy, numericDeviceId, displayName } = bctx;
90092
+ const log = ctx.logger.withTags({ deviceId: numericDeviceId });
90093
+ const label = hapServiceName([displayName], `Alarm ${numericDeviceId}`);
90094
+ const service = accessory.addService(import_dist.Service.SecuritySystem, label, SUBTYPE);
90095
+ applyServiceLabel(service, label);
90096
+ let memo = null;
90097
+ let availableModes = [];
90098
+ let requiresCode = false;
90099
+ /** Only log the `validValues` narrowing when it actually changes. */
90100
+ let lastValidValues = "";
90101
+ const applyValidValues = () => {
90102
+ const validValues = targetValidValuesFor(availableModes);
90103
+ if (validValues === null) {
90104
+ if (lastValidValues !== "unconstrained") {
90105
+ lastValidValues = "unconstrained";
90106
+ log.info("export-hap: alarm panel named no arm modes — leaving HomeKit’s targets unconstrained", { meta: { subtype: SUBTYPE } });
90107
+ }
90108
+ return;
90109
+ }
90110
+ const key = validValues.join(",");
90111
+ if (key === lastValidValues) return;
90112
+ lastValidValues = key;
90113
+ service.getCharacteristic(import_dist.Characteristic.SecuritySystemTargetState).setProps({ validValues: [...validValues] });
90114
+ log.info("export-hap: alarm panel target constrained to the modes it accepts", { meta: {
90115
+ subtype: SUBTYPE,
90116
+ availableModes: [...availableModes],
90117
+ validValues
90118
+ } });
90119
+ };
90120
+ const apply = (status, source) => {
90121
+ const next = securityUpdatesFor(status, memo);
90122
+ if (next.updates.length === 0) {
90123
+ log.debug("export-hap: alarm panel status did not match the cap schema — no update", { meta: {
90124
+ subtype: SUBTYPE,
90125
+ source
90126
+ } });
90127
+ return;
90128
+ }
90129
+ memo = next.memo;
90130
+ if (next.panel !== null) {
90131
+ availableModes = next.panel.availableModes;
90132
+ requiresCode = next.panel.requiresCode;
90133
+ applyValidValues();
90134
+ }
90135
+ applyUpdates(service, next.updates);
90136
+ };
90137
+ try {
90138
+ const status = await proxy.alarmPanel?.getStatus({});
90139
+ if (status !== void 0 && status !== null) apply(status, "getStatus");
90140
+ } catch (err) {
90141
+ log.debug("export-hap: alarm panel getStatus hydrate failed (non-fatal)", { meta: { error: errMsg$11(err) } });
90142
+ }
90143
+ service.getCharacteristic(import_dist.Characteristic.SecuritySystemTargetState).onSet(async (value) => {
90144
+ const target = typeof value === "number" ? value : Number(value);
90145
+ const disarm = target === import_dist.Characteristic.SecuritySystemTargetState.DISARM;
90146
+ const mode = disarm ? null : armModeForTarget(target, availableModes);
90147
+ if (requiresCode) {
90148
+ log.warn("export-hap: alarm panel requires a PIN — HomeKit cannot supply one, command REFUSED", { meta: {
90149
+ subtype: SUBTYPE,
90150
+ command: disarm ? "disarm" : "arm",
90151
+ mode,
90152
+ target
90153
+ } });
90154
+ throw new import_dist.HapStatusError(import_dist.HAPStatus.INSUFFICIENT_AUTHORIZATION);
90155
+ }
90156
+ if (!disarm && mode === null) {
90157
+ log.warn("export-hap: alarm panel cannot honour this HomeKit arm target — REFUSED", { meta: {
90158
+ subtype: SUBTYPE,
90159
+ target,
90160
+ availableModes: [...availableModes]
90161
+ } });
90162
+ throw new import_dist.HapStatusError(import_dist.HAPStatus.INVALID_VALUE_IN_REQUEST);
90163
+ }
90164
+ try {
90165
+ if (mode === null) await proxy.alarmPanel?.disarm({});
90166
+ else await proxy.alarmPanel?.arm({ mode });
90167
+ } catch (err) {
90168
+ log.warn("export-hap: alarm panel command failed", { meta: {
90169
+ subtype: SUBTYPE,
90170
+ command: mode === null ? "disarm" : "arm",
90171
+ mode,
90172
+ error: errMsg$11(err)
90173
+ } });
90174
+ throw new import_dist.HapStatusError(import_dist.HAPStatus.SERVICE_COMMUNICATION_FAILURE);
90175
+ }
90176
+ });
90177
+ const unsubscribe = proxy.state.alarmPanel?.subscribe((value) => {
90178
+ if (value === void 0 || value === null) return;
90179
+ apply(value, "slice");
90180
+ }) ?? null;
90181
+ return { async dispose() {
90182
+ try {
90183
+ unsubscribe?.();
90184
+ } catch {}
90185
+ } };
90186
+ }
90187
+ function errMsg$11(err) {
90188
+ return err instanceof Error ? err.message : String(err);
90189
+ }
90190
+ //#endregion
90191
+ //#region src/mappers/builders/generic/sensor-service.ts
90192
+ async function buildSensorService(input) {
90193
+ const { bctx, spec, name, subtype } = input;
90194
+ const { ctx, accessory, proxy, numericDeviceId } = bctx;
90195
+ const log = ctx.logger.withTags({ deviceId: numericDeviceId });
90196
+ const label = hapServiceName([name], spec.label);
90197
+ const service = spec.addService(accessory, label, subtype);
90198
+ applyServiceLabel(service, label);
90199
+ const apply = (status, source) => {
90200
+ const updates = spec.read(status);
90201
+ if (updates.length === 0) {
90202
+ log.debug("export-hap: sensor status did not match the cap schema — no update", { meta: {
90203
+ subtype,
90204
+ source
90205
+ } });
90206
+ return;
90207
+ }
90208
+ applyUpdates(service, updates);
90209
+ };
90210
+ try {
90211
+ const status = await spec.getStatus(proxy);
90212
+ if (status !== void 0 && status !== null) apply(status, "getStatus");
90213
+ } catch (err) {
90214
+ log.debug("export-hap: sensor getStatus hydrate failed (non-fatal)", { meta: {
90215
+ subtype,
90216
+ error: errMsg$10(err)
90217
+ } });
90218
+ }
90219
+ const unsubscribe = spec.subscribe(proxy, (value) => {
90220
+ if (value === void 0 || value === null) return;
90221
+ apply(value, "slice");
90222
+ });
90223
+ return { async dispose() {
90224
+ try {
90225
+ unsubscribe?.();
90226
+ } catch {}
90227
+ } };
90228
+ }
90229
+ function errMsg$10(err) {
90230
+ return err instanceof Error ? err.message : String(err);
90231
+ }
90232
+ //#endregion
90233
+ //#region src/mappers/builders/generic/cap-service-table.ts
90234
+ /**
90235
+ * The capability→HomeKit-service TABLE for non-camera devices.
90236
+ *
90237
+ * This is the whole coverage decision for the generic export path, and it is
90238
+ * deliberately a table rather than a `switch` on `DeviceType`. The doctrine is
90239
+ * the one the Home Assistant exporter already writes down: *coverage is decided
90240
+ * by a device's capabilities, not its type — restricting the picker by type is
90241
+ * what confined the previous exporter to cameras.* A `DeviceType` here is only
90242
+ * ever a UI filter (`HAP_EXPORTABLE_DEVICE_TYPES`) or an icon
90243
+ * (`ACCESSORY_CATEGORY_BY_TYPE`); it never decides whether something exports.
90244
+ *
90245
+ * The shape mirrors `CAP_ENTITY_MAP` in
90246
+ * `packages/addon-provider-homeassistant/src/ha-export/entity-catalog.ts` so
90247
+ * the two can be unified later — one row per capability, keyed by the cap name
90248
+ * exactly as it appears in `proxy.binding.entries[].capName`.
90249
+ *
90250
+ * ## Reading a status
90251
+ *
90252
+ * Every row parses the cap's OWN Zod status schema rather than duck-typing the
90253
+ * payload, and it `.pick()`s only the fields it reads: a provider that omits a
90254
+ * timestamp must not silence a sensor, and a provider that sends the wrong
90255
+ * shape must not write a garbage characteristic. A parse that fails yields no
90256
+ * updates, and the builder that owns the service logs the drop — silence reads
90257
+ * as "the sensor never changed".
90258
+ *
90259
+ * ## Tier
90260
+ *
90261
+ * Tier A, plus `alarm-panel`: the caps whose HomeKit service exists today and
90262
+ * needs no feature-dependent shape. `cover` still needs `cover-positionable`
90263
+ * and `climate-control` the dual-setpoint split; those are a separate task,
90264
+ * and adding a row here must never mean adding a service whose semantics this
90265
+ * file cannot fully honour.
90266
+ *
90267
+ * `alarm-panel` earned its row by answering the two questions that kept it
90268
+ * out: a target the panel would refuse is never offered (`availableModes` →
90269
+ * `validValues`, `security-target.ts`) and an arm a PIN-gated panel cannot
90270
+ * accept is refused out loud rather than attempted (`security-system.ts`).
90271
+ */
90272
+ /** HomeKit's floor for `CurrentAmbientLightLevel`; 0 lux is out of range. */
90273
+ var MIN_LUX = 1e-4;
90274
+ var SENSOR_SPECS = {
90275
+ contact: {
90276
+ label: "Contact",
90277
+ addService: (accessory, name, subtype) => accessory.addService(import_dist.Service.ContactSensor, name, subtype),
90278
+ getStatus: (proxy) => proxy.contact?.getStatus({}),
90279
+ subscribe: (proxy, onValue) => proxy.state.contact?.subscribe(onValue) ?? null,
90280
+ read: reader(ContactStatusSchema.pick({ entryOpen: true }), (status) => [{
90281
+ characteristic: import_dist.Characteristic.ContactSensorState,
90282
+ value: status.entryOpen ? import_dist.Characteristic.ContactSensorState.CONTACT_NOT_DETECTED : import_dist.Characteristic.ContactSensorState.CONTACT_DETECTED
90283
+ }])
90284
+ },
90285
+ motion: {
90286
+ label: "Motion",
90287
+ addService: (accessory, name, subtype) => accessory.addService(import_dist.Service.MotionSensor, name, subtype),
90288
+ getStatus: (proxy) => proxy.motion?.getStatus({}),
90289
+ subscribe: (proxy, onValue) => proxy.state.motion?.subscribe(onValue) ?? null,
90290
+ read: reader(MotionStatusSchema.pick({ detected: true }), (status) => [{
90291
+ characteristic: import_dist.Characteristic.MotionDetected,
90292
+ value: status.detected
90293
+ }])
90294
+ },
90295
+ presence: {
90296
+ label: "Presence",
90297
+ addService: (accessory, name, subtype) => accessory.addService(import_dist.Service.OccupancySensor, name, subtype),
90298
+ getStatus: (proxy) => proxy.presence?.getStatus({}),
90299
+ subscribe: (proxy, onValue) => proxy.state.presence?.subscribe(onValue) ?? null,
90300
+ read: reader(PresenceStatusSchema.pick({ state: true }), (status) => [{
90301
+ characteristic: import_dist.Characteristic.OccupancyDetected,
90302
+ value: status.state === "home" ? import_dist.Characteristic.OccupancyDetected.OCCUPANCY_DETECTED : import_dist.Characteristic.OccupancyDetected.OCCUPANCY_NOT_DETECTED
90303
+ }])
90304
+ },
90305
+ smoke: {
90306
+ label: "Smoke",
90307
+ addService: (accessory, name, subtype) => accessory.addService(import_dist.Service.SmokeSensor, name, subtype),
90308
+ getStatus: (proxy) => proxy.smoke?.getStatus({}),
90309
+ subscribe: (proxy, onValue) => proxy.state.smoke?.subscribe(onValue) ?? null,
90310
+ read: reader(SmokeStatusSchema.pick({ detected: true }), (status) => [{
90311
+ characteristic: import_dist.Characteristic.SmokeDetected,
90312
+ value: status.detected ? import_dist.Characteristic.SmokeDetected.SMOKE_DETECTED : import_dist.Characteristic.SmokeDetected.SMOKE_NOT_DETECTED
90313
+ }])
90314
+ },
90315
+ "carbon-monoxide": {
90316
+ label: "Carbon monoxide",
90317
+ addService: (accessory, name, subtype) => accessory.addService(import_dist.Service.CarbonMonoxideSensor, name, subtype),
90318
+ getStatus: (proxy) => proxy.carbonMonoxide?.getStatus({}),
90319
+ subscribe: (proxy, onValue) => proxy.state.carbonMonoxide?.subscribe(onValue) ?? null,
90320
+ read: reader(CarbonMonoxideStatusSchema.pick({ detected: true }), (status) => [{
90321
+ characteristic: import_dist.Characteristic.CarbonMonoxideDetected,
90322
+ value: status.detected ? import_dist.Characteristic.CarbonMonoxideDetected.CO_LEVELS_ABNORMAL : import_dist.Characteristic.CarbonMonoxideDetected.CO_LEVELS_NORMAL
90323
+ }])
90324
+ },
90325
+ flood: {
90326
+ label: "Leak",
90327
+ addService: (accessory, name, subtype) => accessory.addService(import_dist.Service.LeakSensor, name, subtype),
90328
+ getStatus: (proxy) => proxy.flood?.getStatus({}),
90329
+ subscribe: (proxy, onValue) => proxy.state.flood?.subscribe(onValue) ?? null,
90330
+ read: reader(FloodStatusSchema.pick({ flooded: true }), (status) => [{
90331
+ characteristic: import_dist.Characteristic.LeakDetected,
90332
+ value: status.flooded ? import_dist.Characteristic.LeakDetected.LEAK_DETECTED : import_dist.Characteristic.LeakDetected.LEAK_NOT_DETECTED
90333
+ }])
90334
+ },
90335
+ "temperature-sensor": {
90336
+ label: "Temperature",
90337
+ addService: (accessory, name, subtype) => accessory.addService(import_dist.Service.TemperatureSensor, name, subtype),
90338
+ getStatus: (proxy) => proxy.temperatureSensor?.getStatus({}),
90339
+ subscribe: (proxy, onValue) => proxy.state.temperatureSensor?.subscribe(onValue) ?? null,
90340
+ read: reader(TemperatureSensorStatusSchema.pick({ celsius: true }), (status) => [{
90341
+ characteristic: import_dist.Characteristic.CurrentTemperature,
90342
+ value: status.celsius
90343
+ }])
90344
+ },
90345
+ "humidity-sensor": {
90346
+ label: "Humidity",
90347
+ addService: (accessory, name, subtype) => accessory.addService(import_dist.Service.HumiditySensor, name, subtype),
90348
+ getStatus: (proxy) => proxy.humiditySensor?.getStatus({}),
90349
+ subscribe: (proxy, onValue) => proxy.state.humiditySensor?.subscribe(onValue) ?? null,
90350
+ read: reader(HumiditySensorStatusSchema.pick({ percent: true }), (status) => [{
90351
+ characteristic: import_dist.Characteristic.CurrentRelativeHumidity,
90352
+ value: status.percent
90353
+ }])
90354
+ },
90355
+ "ambient-light-sensor": {
90356
+ label: "Light level",
90357
+ addService: (accessory, name, subtype) => accessory.addService(import_dist.Service.LightSensor, name, subtype),
90358
+ getStatus: (proxy) => proxy.ambientLightSensor?.getStatus({}),
90359
+ subscribe: (proxy, onValue) => proxy.state.ambientLightSensor?.subscribe(onValue) ?? null,
90360
+ read: reader(AmbientLightSensorStatusSchema.pick({ lux: true }), (status) => [{
90361
+ characteristic: import_dist.Characteristic.CurrentAmbientLightLevel,
90362
+ value: Math.max(MIN_LUX, status.lux)
90363
+ }])
90364
+ }
90365
+ };
90366
+ /**
90367
+ * The `battery` row reads like a sensor but writes three characteristics from
90368
+ * one status, and the camera path publishes the SAME service from the same cap.
90369
+ * The derivation therefore lives once, in `battery.ts`, and both callers read
90370
+ * it from there.
90371
+ */
90372
+ var BATTERY_SPEC = {
90373
+ label: "Battery",
90374
+ addService: (accessory, name, subtype) => accessory.addService(import_dist.Service.Battery, name, subtype),
90375
+ getStatus: (proxy) => proxy.battery?.getStatus({}),
90376
+ subscribe: (proxy, onValue) => proxy.state.battery?.subscribe(onValue) ?? null,
90377
+ read: batteryCharacteristicUpdates
90378
+ };
90379
+ function sensorRow(capName, spec) {
90380
+ return {
90381
+ caps: [capName],
90382
+ label: spec.label,
90383
+ build: ({ bctx, name }) => buildSensorService({
90384
+ bctx,
90385
+ spec,
90386
+ name,
90387
+ subtype: capName
90388
+ })
90389
+ };
90390
+ }
90391
+ /**
90392
+ * THE table. Order matters only for one thing: the first row that matches a
90393
+ * device decides the accessory's category when the device's own type does not.
90394
+ */
90395
+ var HAP_CAP_SERVICES = [
90396
+ {
90397
+ caps: ["switch", "brightness"],
90398
+ label: "Power",
90399
+ build: ({ bctx, name, deviceType }) => buildChildSwitch({
90400
+ ...bctx,
90401
+ displayName: name
90402
+ }, "switch", deviceType)
90403
+ },
90404
+ {
90405
+ caps: ["lock-control"],
90406
+ label: "Lock",
90407
+ build: ({ bctx, name }) => buildLockMechanism({
90408
+ ...bctx,
90409
+ displayName: name
90410
+ })
90411
+ },
90412
+ {
90413
+ caps: ["alarm-panel"],
90414
+ label: "Alarm",
90415
+ build: ({ bctx, name }) => buildSecuritySystem({
90416
+ ...bctx,
90417
+ displayName: name
90418
+ })
90419
+ },
90420
+ {
90421
+ caps: ["battery"],
90422
+ label: BATTERY_SPEC.label,
90423
+ build: ({ bctx, name }) => buildSensorService({
90424
+ bctx,
90425
+ spec: BATTERY_SPEC,
90426
+ name,
90427
+ subtype: "battery"
90428
+ })
90429
+ },
90430
+ ...Object.entries(SENSOR_SPECS).map(([capName, spec]) => sensorRow(capName, spec))
90431
+ ];
90432
+ /**
90433
+ * The rows a device's bound capabilities select, in table order.
90434
+ *
90435
+ * A row matches when ANY of its caps is bound: a lamp with `switch` but no
90436
+ * `brightness` is still a Lightbulb-shaped row, and the builder degrades on its
90437
+ * own.
90438
+ */
90439
+ function rowsForCaps(capNames) {
90440
+ return HAP_CAP_SERVICES.filter((row) => row.caps.some((cap) => capNames.has(cap)));
90441
+ }
90442
+ /**
90443
+ * The HomeKit accessory category per camstack device type — the icon iOS Home
90444
+ * draws and nothing else. Absent = `OTHER`, which is a valid accessory that
90445
+ * simply gets the generic tile.
90446
+ *
90447
+ * This is NOT a coverage decision. `HAP_EXPORTABLE_DEVICE_TYPES` (in
90448
+ * `mappers/index.ts`) filters the picker; whether a device exports anything is
90449
+ * decided by `rowsForCaps`.
90450
+ */
90451
+ var ACCESSORY_CATEGORY_BY_TYPE = {
90452
+ [DeviceType.Switch]: import_dist.Categories.SWITCH,
90453
+ [DeviceType.Light]: import_dist.Categories.LIGHTBULB,
90454
+ [DeviceType.Siren]: import_dist.Categories.SWITCH,
90455
+ [DeviceType.Lock]: import_dist.Categories.DOOR_LOCK,
90456
+ [DeviceType.Sensor]: import_dist.Categories.SENSOR,
90457
+ [DeviceType.Presence]: import_dist.Categories.SENSOR,
90458
+ [DeviceType.AlarmPanel]: import_dist.Categories.SECURITY_SYSTEM
90459
+ };
90460
+ function accessoryCategoryFor(deviceType) {
90461
+ return ACCESSORY_CATEGORY_BY_TYPE[deviceType] ?? import_dist.Categories.OTHER;
90462
+ }
90463
+ //#endregion
90464
+ //#region src/mappers/builders/stream-hwaccel-memo.ts
90465
+ /**
90466
+ * The two bounded memos HomeKit's decode path owns.
90467
+ *
90468
+ * ## Why they exist
90469
+ *
90470
+ * Everything D67 was actually about — one argv builder, one set of constants,
90471
+ * one hwaccel authority — HomeKit already had. What it did NOT have were the
90472
+ * two things the broker gained alongside them:
90473
+ *
90474
+ * 1. **A memo.** `probeDecoderHwaccel` issued a cross-process
90475
+ * `decoder.getInfo` per SESSION. iOS starts sessions in bursts — one on
90476
+ * record was started three times in 16 s — and every one of those paid a
90477
+ * cap call on a hub whose main thread is the scarce resource.
90478
+ * 2. **Failure feedback.** When HomeKit's hardware child died at init and
90479
+ * `shouldRetryInSoftware` saved the session, HomeKit told nobody. The next
90480
+ * session re-picked the same corpse and paid the same two-second death.
90481
+ * `EgressTranscodeManager` fixed exactly this for its own children
90482
+ * (26a522cd5) by reporting the dead backend into the broker's 60 s memo.
90483
+ *
90484
+ * ## Both ride `HwAccelCache`, deliberately
90485
+ *
90486
+ * `createHwAccelCache` from `@camstack/types` is the primitive the broker's own
90487
+ * `egressHwAccelCache` is built from, and the discipline it encodes is the
90488
+ * point: **caller-owned, never a module global** — a module global would
90489
+ * outlive an addon respawn and survive an operator changing the decoder
90490
+ * backend. Same TTL as the broker's, so "has hardware come back yet" cannot
90491
+ * answer differently depending on which consumer asked.
90492
+ *
90493
+ * ## The cross-process gap, stated honestly
90494
+ *
90495
+ * These memos are scoped to the `export-hap` PROCESS. When HomeKit's vaapi
90496
+ * child dies, the broker's next child still pays its own two-second death, and
90497
+ * vice versa — because addons may never import each other and there is no
90498
+ * capability for "this backend is dead on this node right now". Closing that
90499
+ * would need a new cap surface, which Phase 0 explicitly does not take. What is
90500
+ * closed here is HomeKit's own repetition of the cost, across cameras and
90501
+ * across sessions.
90502
+ */
90503
+ /**
90504
+ * The window both memos answer for.
90505
+ *
90506
+ * 60 s, the same as `stream-broker-manager`'s `egressHwAccelCache`. Long enough
90507
+ * that a burst of session restarts pays one read; short enough that an operator
90508
+ * who changes the decoder backend, or a host whose accelerator recovers, is
90509
+ * obeyed on the next session rather than after an addon respawn.
90510
+ */
90511
+ var HAP_DECODE_MEMO_TTL_MS = 6e4;
90512
+ /**
90513
+ * Separator inside the encoded reading. A control character, because a backend
90514
+ * name is `[a-z0-9]+` and the decoder's non-backend choices are `auto` /
90515
+ * `none` / `''`, none of which can contain one — so the split is total.
90516
+ */
90517
+ var READING_SEPARATOR = "";
90518
+ /**
90519
+ * Marks a `null` FIELD, distinct from an EMPTY one.
90520
+ *
90521
+ * `probedBestHwaccel: ''` means the decoder answered and has never probed
90522
+ * (=> `not-probed`); `null` means the field was absent altogether. Encoding
90523
+ * both as `''` would lose a distinction `selectHwDecode` acts on.
90524
+ */
90525
+ var NULL_FIELD = "\0";
90526
+ function encodeField(value) {
90527
+ return value === null ? NULL_FIELD : value;
90528
+ }
90529
+ function decodeField(value) {
90530
+ return value === NULL_FIELD ? null : value;
90531
+ }
90532
+ /**
90533
+ * A reading as ONE `string | null`, which is what {@link HwAccelCache} stores.
90534
+ *
90535
+ * The cache's three states are exactly the three a memoised reading needs:
90536
+ * `undefined` (never asked, or expired), `null` (asked, and the decoder could
90537
+ * not be reached), and a value. Encoding into the one cache rather than
90538
+ * splitting across two is what keeps those three from skewing — two caches
90539
+ * written together can still be READ across an expiry boundary.
90540
+ */
90541
+ function encodeDecoderReading(reading) {
90542
+ if (reading === null) return null;
90543
+ return `${encodeField(reading.hwaccel)}${READING_SEPARATOR}${encodeField(reading.probedBestHwaccel)}`;
90544
+ }
90545
+ function decodeDecoderReading(value) {
90546
+ if (value === null) return null;
90547
+ const [hwaccel = NULL_FIELD, probed = NULL_FIELD] = value.split(READING_SEPARATOR);
90548
+ return {
90549
+ hwaccel: decodeField(hwaccel),
90550
+ probedBestHwaccel: decodeField(probed)
90551
+ };
90552
+ }
90553
+ function createDecoderReadingMemo(options) {
90554
+ const cache = createHwAccelCache(options);
90555
+ return {
90556
+ read() {
90557
+ const cached = cache.read();
90558
+ return cached === void 0 ? void 0 : decodeDecoderReading(cached);
90559
+ },
90560
+ write(reading) {
90561
+ cache.write(encodeDecoderReading(reading));
90562
+ }
90563
+ };
90564
+ }
90565
+ function createHapDecodeMemos(now) {
90566
+ const options = {
90567
+ ttlMs: HAP_DECODE_MEMO_TTL_MS,
90568
+ ...now ? { now } : {}
90569
+ };
90570
+ return {
90571
+ reading: createDecoderReadingMemo(options),
90572
+ failedBackend: createHwAccelCache(options)
90573
+ };
90574
+ }
90575
+ //#endregion
90576
+ //#region src/mappers/builders/accessory-info.ts
90577
+ /**
90578
+ * `Service.AccessoryInformation` — the manufacturer / model / firmware /
90579
+ * serial block every HomeKit accessory carries.
90580
+ *
90581
+ * One implementation for both accessory shapes (camera and generic): the
90582
+ * fields come from the device's own metadata either way, and a second copy
90583
+ * would be a second answer to "what serial does this device publish".
90584
+ * Metadata is best-effort — a device that cannot answer still publishes.
90585
+ */
90586
+ async function populateAccessoryInfo(accessory, proxy, displayName, modelFallback) {
90587
+ const info = accessory.getService(import_dist.Service.AccessoryInformation);
90588
+ if (!info) return;
90589
+ try {
90590
+ const device = await proxy.deviceManager?.getDevice({});
90591
+ const metadata = readMetadata(device);
90592
+ info.setCharacteristic(import_dist.Characteristic.Name, device?.name ?? displayName);
90593
+ info.setCharacteristic(import_dist.Characteristic.Manufacturer, stringOr(metadata?.manufacturer, "CamStack"));
90594
+ info.setCharacteristic(import_dist.Characteristic.Model, stringOr(metadata?.model, modelFallback));
90595
+ info.setCharacteristic(import_dist.Characteristic.FirmwareRevision, stringOr(metadata?.firmware, "0.0.0"));
90596
+ info.setCharacteristic(import_dist.Characteristic.SerialNumber, stringOr(metadata?.sn, `camstack-${proxy.deviceId}`));
90597
+ } catch {}
90598
+ }
90599
+ /** The four fields this module reads, or `null` — the device record's
90600
+ * `metadata` is an open bag and nothing else here depends on its shape. */
90601
+ function readMetadata(device) {
90602
+ const metadata = device?.metadata;
90603
+ if (metadata === null || typeof metadata !== "object") return {};
90604
+ const entries = new Map(Object.entries(metadata));
90605
+ return {
90606
+ model: stringOrNull(entries.get("model")),
90607
+ manufacturer: stringOrNull(entries.get("manufacturer")),
90608
+ firmware: stringOrNull(entries.get("firmware")),
90609
+ sn: stringOrNull(entries.get("sn"))
90610
+ };
90611
+ }
90612
+ function stringOrNull(value) {
90613
+ return typeof value === "string" ? value : null;
90614
+ }
90615
+ function stringOr(value, fallback) {
90616
+ return typeof value === "string" && value.length > 0 ? value : fallback;
90617
+ }
90618
+ //#endregion
88796
90619
  //#region ../../node_modules/werift-rtp/lib/rtp/src/srtp/const.js
88797
90620
  var require_const = /* @__PURE__ */ __commonJSMin(((exports) => {
88798
90621
  Object.defineProperty(exports, "__esModule", { value: true });
@@ -100109,7 +101932,7 @@ var VIDEO_LOOPBACK_RCVBUF_BYTES = 8 * 1024 * 1024;
100109
101932
  * not burst size.
100110
101933
  */
100111
101934
  var AUDIO_LOOPBACK_RCVBUF_BYTES = 1024 * 1024;
100112
- function errMsg$13(err) {
101935
+ function errMsg$9(err) {
100113
101936
  return err instanceof Error ? err.message : String(err);
100114
101937
  }
100115
101938
  /**
@@ -100124,13 +101947,13 @@ function applyReceiveBuffer(socket, requestedBytes) {
100124
101947
  try {
100125
101948
  socket.setRecvBufferSize(requestedBytes);
100126
101949
  } catch (err) {
100127
- error = errMsg$13(err);
101950
+ error = errMsg$9(err);
100128
101951
  }
100129
101952
  let effectiveBytes = null;
100130
101953
  try {
100131
101954
  effectiveBytes = socket.getRecvBufferSize();
100132
101955
  } catch (err) {
100133
- if (error === null) error = errMsg$13(err);
101956
+ if (error === null) error = errMsg$9(err);
100134
101957
  }
100135
101958
  return {
100136
101959
  requestedBytes,
@@ -100401,20 +102224,20 @@ function buildCameraStreamingDelegate(bctx, advertised) {
100401
102224
  delegate: {
100402
102225
  handleSnapshotRequest(request, callback) {
100403
102226
  handleSnapshot(bctx, request).then((buf) => callback(void 0, buf)).catch((err) => {
100404
- log.warn("export-hap: snapshot failed", { meta: { error: errMsg$12(err) } });
100405
- callback(err instanceof Error ? err : new Error(errMsg$12(err)));
102227
+ log.warn("export-hap: snapshot failed", { meta: { error: errMsg$8(err) } });
102228
+ callback(err instanceof Error ? err : new Error(errMsg$8(err)));
100406
102229
  });
100407
102230
  },
100408
102231
  prepareStream(request, callback) {
100409
102232
  prepareStream(request, sessions, bctx, "classic").then((resp) => callback(void 0, resp)).catch((err) => {
100410
- log.warn("export-hap: prepareStream failed", { meta: { error: errMsg$12(err) } });
100411
- callback(err instanceof Error ? err : new Error(errMsg$12(err)));
102233
+ log.warn("export-hap: prepareStream failed", { meta: { error: errMsg$8(err) } });
102234
+ callback(err instanceof Error ? err : new Error(errMsg$8(err)));
100412
102235
  });
100413
102236
  },
100414
102237
  handleStreamRequest(request, callback) {
100415
102238
  handleStreamRequest(request, sessions, bctx, advertised).then(() => callback()).catch((err) => {
100416
- log.warn("export-hap: handleStreamRequest failed", { meta: { error: errMsg$12(err) } });
100417
- callback(err instanceof Error ? err : new Error(errMsg$12(err)));
102239
+ log.warn("export-hap: handleStreamRequest failed", { meta: { error: errMsg$8(err) } });
102240
+ callback(err instanceof Error ? err : new Error(errMsg$8(err)));
100418
102241
  });
100419
102242
  }
100420
102243
  },
@@ -100553,7 +102376,7 @@ async function prepareStream(request, sessions, bctx, negotiatedVia) {
100553
102376
  closeSocket(audioUdp);
100554
102377
  closeSocket(videoLoopUdp);
100555
102378
  closeSocket(audioLoopUdp);
100556
- throw new Error(`export-hap: outbound SrtpSession init failed: ${errMsg$12(err)}`, { cause: err });
102379
+ throw new Error(`export-hap: outbound SrtpSession init failed: ${errMsg$8(err)}`, { cause: err });
100557
102380
  }
100558
102381
  let upstreamAudioSrtp = null;
100559
102382
  try {
@@ -100567,7 +102390,7 @@ async function prepareStream(request, sessions, bctx, negotiatedVia) {
100567
102390
  profile: import_src.ProtectionProfileAes128CmHmacSha1_80
100568
102391
  });
100569
102392
  } catch (err) {
100570
- bctx.ctx.logger.withTags({ deviceId: bctx.numericDeviceId }).warn("export-hap: SrtpSession init failed (upstream audio decrypt disabled)", { meta: { error: errMsg$12(err) } });
102393
+ bctx.ctx.logger.withTags({ deviceId: bctx.numericDeviceId }).warn("export-hap: SrtpSession init failed (upstream audio decrypt disabled)", { meta: { error: errMsg$8(err) } });
100571
102394
  }
100572
102395
  const videoSsrc = randomSsrc();
100573
102396
  const audioSsrc = randomSsrc();
@@ -100687,7 +102510,7 @@ async function prepareStream(request, sessions, bctx, negotiatedVia) {
100687
102510
  });
100688
102511
  audioUdp.on("message", (packet, rinfo) => {
100689
102512
  handleIncomingAudioRtp(session, packet, rinfo.address, bctx).catch((err) => {
100690
- bctx.ctx.logger.withTags({ deviceId: bctx.numericDeviceId }).debug("export-hap: incoming-audio handler error (dropped)", { meta: { error: errMsg$12(err) } });
102513
+ bctx.ctx.logger.withTags({ deviceId: bctx.numericDeviceId }).debug("export-hap: incoming-audio handler error (dropped)", { meta: { error: errMsg$8(err) } });
100691
102514
  });
100692
102515
  });
100693
102516
  logLoopbackBuffer(tagLog, request.sessionID, "video", videoLoop.buffer);
@@ -100883,7 +102706,7 @@ function readControllerRtcp(session, leg, packet, log) {
100883
102706
  } catch (err) {
100884
102707
  drop(session, "inbound-rtcp-decrypt-failed");
100885
102708
  storeReceiverReports(session, leg, recordUnreadableRtcp(tally));
100886
- logUnreadableRtcp(session, leg, `decrypt: ${errMsg$12(err)}`, log);
102709
+ logUnreadableRtcp(session, leg, `decrypt: ${errMsg$8(err)}`, log);
100887
102710
  return;
100888
102711
  }
100889
102712
  const outcome = ingestDecryptedRtcp(plaintext, tally);
@@ -101701,7 +103524,7 @@ async function handleIncomingAudioRtp(session, packet, sourceAddress, bctx) {
101701
103524
  session.upstreamRtpDecryptFailures += 1;
101702
103525
  if (session.upstreamRtpDecryptFailures % 100 === 1) log.debug("export-hap: SRTP decrypt failed (rate-limited)", { meta: {
101703
103526
  failures: session.upstreamRtpDecryptFailures,
101704
- error: errMsg$12(err)
103527
+ error: errMsg$8(err)
101705
103528
  } });
101706
103529
  return;
101707
103530
  }
@@ -101715,7 +103538,7 @@ async function handleIncomingAudioRtp(session, packet, sourceAddress, bctx) {
101715
103538
  rtpPayloadType = parsedRtp.header.payloadType;
101716
103539
  } catch (err) {
101717
103540
  drop(session, "upstream-parse-failed");
101718
- log.debug("export-hap: RTP parse failed after decrypt", { meta: { error: errMsg$12(err) } });
103541
+ log.debug("export-hap: RTP parse failed after decrypt", { meta: { error: errMsg$8(err) } });
101719
103542
  return;
101720
103543
  }
101721
103544
  const negotiatedAudioPt = session.lastStartParams?.audioPt;
@@ -101739,7 +103562,7 @@ async function handleIncomingAudioRtp(session, packet, sourceAddress, bctx) {
101739
103562
  if (session.intercomTalkSessionId === null) {
101740
103563
  session.intercomTalkSessionId = "";
101741
103564
  const opened = await openIntercomTalkSession(bctx).catch((err) => {
101742
- log.warn("export-hap: intercom.startTalkSession failed", { meta: { error: errMsg$12(err) } });
103565
+ log.warn("export-hap: intercom.startTalkSession failed", { meta: { error: errMsg$8(err) } });
101743
103566
  return null;
101744
103567
  });
101745
103568
  if (opened) {
@@ -101766,7 +103589,7 @@ async function handleIncomingAudioRtp(session, packet, sourceAddress, bctx) {
101766
103589
  drop(session, "upstream-push-failed");
101767
103590
  log.debug("export-hap: intercom.pushTalkAudio failed (will re-open on next frame)", { meta: {
101768
103591
  sequenceNumber: session.intercomPcmSequence,
101769
- error: errMsg$12(err)
103592
+ error: errMsg$8(err)
101770
103593
  } });
101771
103594
  session.intercomTalkSessionId = null;
101772
103595
  }
@@ -101798,7 +103621,7 @@ async function closeIntercomTalkSession(session, bctx) {
101798
103621
  } catch (err) {
101799
103622
  log.debug("export-hap: intercom.endTalkSession failed (continuing)", { meta: {
101800
103623
  sessionId,
101801
- error: errMsg$12(err)
103624
+ error: errMsg$8(err)
101802
103625
  } });
101803
103626
  }
101804
103627
  }
@@ -101823,7 +103646,7 @@ function closeSocket(udp) {
101823
103646
  function randomSsrc() {
101824
103647
  return Math.floor(Math.random() * 2147483646) + 1 | 0;
101825
103648
  }
101826
- function errMsg$12(err) {
103649
+ function errMsg$8(err) {
101827
103650
  return err instanceof Error ? err.message : String(err);
101828
103651
  }
101829
103652
  //#endregion
@@ -101907,14 +103730,14 @@ async function buildDoorbell(input) {
101907
103730
  }
101908
103731
  log.info("export-hap: doorbell SINGLE_PRESS pushed to HomeKit", { meta: { ...delivery } });
101909
103732
  } catch (err) {
101910
- log.warn("export-hap: ringDoorbell() failed", { meta: { error: errMsg$11(err) } });
103733
+ log.warn("export-hap: ringDoorbell() failed", { meta: { error: errMsg$7(err) } });
101911
103734
  }
101912
103735
  });
101913
103736
  return { async dispose() {
101914
103737
  unsubscribe();
101915
103738
  } };
101916
103739
  }
101917
- function errMsg$11(err) {
103740
+ function errMsg$7(err) {
101918
103741
  return err instanceof Error ? err.message : String(err);
101919
103742
  }
101920
103743
  //#endregion
@@ -101962,7 +103785,7 @@ async function buildMotionSensor(bctx, existing = null) {
101962
103785
  const detected = await proxy.motion?.isDetected({});
101963
103786
  if (typeof detected === "boolean") motionService.updateCharacteristic(import_dist.Characteristic.MotionDetected, detected);
101964
103787
  } catch (err) {
101965
- ctx.logger.withTags({ deviceId: numericDeviceId }).debug("export-hap: initial motion hydrate failed (non-fatal)", { meta: { error: errMsg$10(err) } });
103788
+ ctx.logger.withTags({ deviceId: numericDeviceId }).debug("export-hap: initial motion hydrate failed (non-fatal)", { meta: { error: errMsg$6(err) } });
101966
103789
  }
101967
103790
  let resetTimer = null;
101968
103791
  const armReset = () => {
@@ -101996,7 +103819,7 @@ async function buildMotionSensor(bctx, existing = null) {
101996
103819
  }
101997
103820
  } };
101998
103821
  }
101999
- function errMsg$10(err) {
103822
+ function errMsg$6(err) {
102000
103823
  return err instanceof Error ? err.message : String(err);
102001
103824
  }
102002
103825
  //#endregion
@@ -102027,7 +103850,7 @@ async function buildPrivacySwitch(bctx) {
102027
103850
  const status = await proxy.privacyMask?.getStatus({});
102028
103851
  if (status && typeof status.enabled === "boolean") service.updateCharacteristic(import_dist.Characteristic.On, status.enabled);
102029
103852
  } catch (err) {
102030
- log.debug("export-hap: privacy-mask getStatus hydrate failed (non-fatal)", { meta: { error: errMsg$9(err) } });
103853
+ log.debug("export-hap: privacy-mask getStatus hydrate failed (non-fatal)", { meta: { error: errMsg$5(err) } });
102031
103854
  }
102032
103855
  service.getCharacteristic(import_dist.Characteristic.On).onSet(async (value) => {
102033
103856
  const enabled = value === true;
@@ -102036,7 +103859,7 @@ async function buildPrivacySwitch(bctx) {
102036
103859
  } catch (err) {
102037
103860
  log.warn("export-hap: privacy-mask setMask failed", { meta: {
102038
103861
  enabled,
102039
- error: errMsg$9(err)
103862
+ error: errMsg$5(err)
102040
103863
  } });
102041
103864
  }
102042
103865
  });
@@ -102054,7 +103877,7 @@ async function buildPrivacySwitch(bctx) {
102054
103877
  } catch {}
102055
103878
  } };
102056
103879
  }
102057
- function errMsg$9(err) {
103880
+ function errMsg$5(err) {
102058
103881
  return err instanceof Error ? err.message : String(err);
102059
103882
  }
102060
103883
  //#endregion
@@ -102145,7 +103968,7 @@ async function buildPtz(bctx) {
102145
103968
  } catch (err) {
102146
103969
  log.warn("export-hap: ptz.goToPreset failed", { meta: {
102147
103970
  presetId: preset.id,
102148
- error: errMsg$8(err)
103971
+ error: errMsg$4(err)
102149
103972
  } });
102150
103973
  }
102151
103974
  armReset(() => service.updateCharacteristic(import_dist.Characteristic.On, false), MOMENTARY_RESET_MS);
@@ -102163,14 +103986,14 @@ async function buildPtz(bctx) {
102163
103986
  try {
102164
103987
  await proxy.ptz?.stop({});
102165
103988
  } catch (err) {
102166
- log.debug("export-hap: ptz.stop failed (non-fatal)", { meta: { error: errMsg$8(err) } });
103989
+ log.debug("export-hap: ptz.stop failed (non-fatal)", { meta: { error: errMsg$4(err) } });
102167
103990
  }
102168
103991
  service.updateCharacteristic(import_dist.Characteristic.On, false);
102169
103992
  }, options.ptzPulseMs);
102170
103993
  } catch (err) {
102171
103994
  log.warn("export-hap: ptz.continuousMove failed", { meta: {
102172
103995
  dir: dir.label,
102173
- error: errMsg$8(err)
103996
+ error: errMsg$4(err)
102174
103997
  } });
102175
103998
  service.updateCharacteristic(import_dist.Characteristic.On, false);
102176
103999
  }
@@ -102202,7 +104025,7 @@ async function readPresets(bctx) {
102202
104025
  }
102203
104026
  return entries;
102204
104027
  } catch (err) {
102205
- ctx.logger.withTags({ deviceId: numericDeviceId }).debug("export-hap: ptz.getPresets failed (non-fatal)", { meta: { error: errMsg$8(err) } });
104028
+ ctx.logger.withTags({ deviceId: numericDeviceId }).debug("export-hap: ptz.getPresets failed (non-fatal)", { meta: { error: errMsg$4(err) } });
102206
104029
  return [];
102207
104030
  }
102208
104031
  }
@@ -102217,7 +104040,7 @@ async function tryBuildAutotrack(bctx) {
102217
104040
  const status = await proxy.ptzAutotrack.getStatus({});
102218
104041
  if (status && typeof status.enabled === "boolean") service.updateCharacteristic(import_dist.Characteristic.On, status.enabled);
102219
104042
  } catch (err) {
102220
- log.debug("export-hap: ptzAutotrack.getStatus failed (non-fatal)", { meta: { error: errMsg$8(err) } });
104043
+ log.debug("export-hap: ptzAutotrack.getStatus failed (non-fatal)", { meta: { error: errMsg$4(err) } });
102221
104044
  }
102222
104045
  service.getCharacteristic(import_dist.Characteristic.On).onSet(async (value) => {
102223
104046
  const enabled = value === true;
@@ -102226,16 +104049,218 @@ async function tryBuildAutotrack(bctx) {
102226
104049
  } catch (err) {
102227
104050
  log.warn("export-hap: ptzAutotrack.setEnabled failed", { meta: {
102228
104051
  enabled,
102229
- error: errMsg$8(err)
104052
+ error: errMsg$4(err)
102230
104053
  } });
102231
104054
  }
102232
104055
  });
102233
104056
  return { async dispose() {} };
102234
104057
  }
102235
- function errMsg$8(err) {
104058
+ function errMsg$4(err) {
102236
104059
  return err instanceof Error ? err.message : String(err);
102237
104060
  }
102238
104061
  //#endregion
104062
+ //#region src/hksv/clip-writer.ts
104063
+ var HksvClipWriter = class {
104064
+ input;
104065
+ log;
104066
+ started = false;
104067
+ settle = null;
104068
+ outcome;
104069
+ bytes = 0;
104070
+ fragments = 0;
104071
+ sawInit = false;
104072
+ truncated = null;
104073
+ startedAtMs;
104074
+ constructor(input) {
104075
+ this.input = input;
104076
+ this.log = input.logger;
104077
+ this.startedAtMs = input.now();
104078
+ this.outcome = new Promise((resolve) => {
104079
+ this.settle = resolve;
104080
+ });
104081
+ }
104082
+ /** Resolves once the file is closed. NEVER rejects — every failure is named. */
104083
+ get done() {
104084
+ return this.outcome;
104085
+ }
104086
+ /** Launch the loop. Returns immediately: the delegate awaits nothing here. */
104087
+ start() {
104088
+ if (this.started) return;
104089
+ this.started = true;
104090
+ this.run();
104091
+ }
104092
+ /**
104093
+ * The HDS stream ended. Releases the subscription, which drains whatever is
104094
+ * already queued and then completes the loop. Idempotent, synchronous, and
104095
+ * safe to call from the delegate's `finally`.
104096
+ */
104097
+ finish() {
104098
+ this.input.subscription.release();
104099
+ }
104100
+ async run() {
104101
+ const { subscription, sink } = this.input;
104102
+ const tags = { deviceId: this.input.deviceId };
104103
+ try {
104104
+ for await (const packet of subscription.packets()) {
104105
+ if (packet.data.length === 0) continue;
104106
+ if (this.bytes + packet.data.length > this.input.maxBytes) {
104107
+ this.truncated = "size-cap";
104108
+ this.log.warn("hksv clip tee: the per-clip size ceiling was reached — TRUNCATING", {
104109
+ tags,
104110
+ meta: {
104111
+ streamId: this.input.streamId,
104112
+ bytes: this.bytes,
104113
+ maxBytes: this.input.maxBytes,
104114
+ fragments: this.fragments
104115
+ }
104116
+ });
104117
+ break;
104118
+ }
104119
+ await sink.write(packet.data);
104120
+ this.bytes += packet.data.length;
104121
+ if (packet.kind === "init") this.sawInit = true;
104122
+ else this.fragments += 1;
104123
+ }
104124
+ if (this.truncated === null && subscription.closedReason === "slow-consumer") {
104125
+ this.truncated = "slow-consumer";
104126
+ this.log.warn("hksv clip tee: the plane closed the tee for falling behind — the clip is TRUNCATED (HomeKit recording unaffected)", {
104127
+ tags,
104128
+ meta: {
104129
+ streamId: this.input.streamId,
104130
+ bytes: this.bytes,
104131
+ fragments: this.fragments
104132
+ }
104133
+ });
104134
+ }
104135
+ } catch (err) {
104136
+ this.truncated = "write-failed";
104137
+ this.log.warn("hksv clip tee: writing the clip FAILED — the HomeKit recording is unaffected", {
104138
+ tags,
104139
+ meta: {
104140
+ streamId: this.input.streamId,
104141
+ bytes: this.bytes,
104142
+ fragments: this.fragments,
104143
+ error: err instanceof Error ? err.message : String(err)
104144
+ }
104145
+ });
104146
+ } finally {
104147
+ subscription.release();
104148
+ try {
104149
+ await sink.close();
104150
+ } catch (err) {
104151
+ if (this.truncated === null) this.truncated = "write-failed";
104152
+ this.log.warn("hksv clip tee: closing the clip file failed", {
104153
+ tags,
104154
+ meta: {
104155
+ streamId: this.input.streamId,
104156
+ error: err instanceof Error ? err.message : String(err)
104157
+ }
104158
+ });
104159
+ }
104160
+ this.resolve();
104161
+ }
104162
+ }
104163
+ resolve() {
104164
+ const settle = this.settle;
104165
+ this.settle = null;
104166
+ settle?.({
104167
+ bytes: this.bytes,
104168
+ fragments: this.fragments,
104169
+ sawInit: this.sawInit,
104170
+ truncated: this.truncated,
104171
+ startedAtMs: this.startedAtMs,
104172
+ endedAtMs: this.input.now()
104173
+ });
104174
+ }
104175
+ };
104176
+ //#endregion
104177
+ //#region src/hksv/clip-tee.ts
104178
+ function createClipTeeFactory(input) {
104179
+ return (request) => new ClipTeeRunner(input, request);
104180
+ }
104181
+ var ClipTeeRunner = class {
104182
+ input;
104183
+ request;
104184
+ writer = null;
104185
+ /** `finish()` arriving before the file is open — the short stream case. */
104186
+ finishPending = false;
104187
+ acknowledgePending = false;
104188
+ clipId = null;
104189
+ completed;
104190
+ constructor(input, request) {
104191
+ this.input = input;
104192
+ this.request = request;
104193
+ this.completed = this.run();
104194
+ }
104195
+ finish() {
104196
+ this.finishPending = true;
104197
+ this.writer?.finish();
104198
+ }
104199
+ acknowledge() {
104200
+ this.acknowledgePending = true;
104201
+ this.completed.then(() => this.persistAcknowledgement());
104202
+ }
104203
+ async run() {
104204
+ const { store, deviceId } = this.input;
104205
+ const now = this.input.now ?? Date.now;
104206
+ const tags = { deviceId };
104207
+ const open = await store.open({
104208
+ deviceId,
104209
+ streamId: this.request.streamId,
104210
+ startedAtMs: now(),
104211
+ prebufferSpanMs: this.request.prebufferSpanMs,
104212
+ width: this.input.width,
104213
+ height: this.input.height,
104214
+ fragmentMs: this.input.fragmentMs
104215
+ });
104216
+ if (open === null) {
104217
+ this.request.subscription.release();
104218
+ return;
104219
+ }
104220
+ this.clipId = open.clipId;
104221
+ const writer = new HksvClipWriter({
104222
+ logger: this.input.logger,
104223
+ deviceId,
104224
+ streamId: this.request.streamId,
104225
+ subscription: this.request.subscription,
104226
+ sink: open.sink,
104227
+ maxBytes: store.maxClipBytes,
104228
+ now
104229
+ });
104230
+ this.writer = writer;
104231
+ writer.start();
104232
+ if (this.finishPending) writer.finish();
104233
+ const outcome = await writer.done;
104234
+ try {
104235
+ await open.complete(outcome);
104236
+ } catch (err) {
104237
+ this.input.logger.warn("hksv clip tee: the clip could not be completed", {
104238
+ tags,
104239
+ meta: {
104240
+ streamId: this.request.streamId,
104241
+ error: err instanceof Error ? err.message : String(err)
104242
+ }
104243
+ });
104244
+ }
104245
+ }
104246
+ async persistAcknowledgement() {
104247
+ const clipId = this.clipId;
104248
+ if (clipId === null || !this.acknowledgePending) return;
104249
+ this.acknowledgePending = false;
104250
+ try {
104251
+ await this.input.store.acknowledge(this.input.deviceId, clipId);
104252
+ } catch (err) {
104253
+ this.input.logger.warn("hksv clip tee: recording the acknowledgement failed", {
104254
+ tags: { deviceId: this.input.deviceId },
104255
+ meta: {
104256
+ clipId,
104257
+ error: err instanceof Error ? err.message : String(err)
104258
+ }
104259
+ });
104260
+ }
104261
+ }
104262
+ };
104263
+ //#endregion
102239
104264
  //#region src/hksv/controller-census.ts
102240
104265
  var EMPTY_CENSUS = {
102241
104266
  serverPublished: false,
@@ -102557,8 +104582,13 @@ var HksvFragmentSource = class {
102557
104582
  * Returns `null` when there is no plane — the caller MUST treat that as
102558
104583
  * "cannot serve this recording" rather than opening an HDS stream it cannot
102559
104584
  * feed, which is the D50 failure exactly.
104585
+ *
104586
+ * Called ONCE PER CONSUMER, never shared: the delegate takes one for HomeKit
104587
+ * and the clip tee takes a second. `queueCapacity` is how much slack this
104588
+ * subscriber gets before the plane closes it (D80 closes, never gaps) — the
104589
+ * tee asks for less than HomeKit's stream on purpose.
102560
104590
  */
102561
- subscribe(tag) {
104591
+ subscribe(tag, queueCapacity) {
102562
104592
  const plane = this.plane;
102563
104593
  if (plane === null || plane.isEnded) return null;
102564
104594
  const stats = plane.prebufferStats();
@@ -102573,7 +104603,8 @@ var HksvFragmentSource = class {
102573
104603
  });
102574
104604
  return plane.subscribe({
102575
104605
  tag,
102576
- withPrebuffer: true
104606
+ withPrebuffer: true,
104607
+ ...queueCapacity === void 0 ? {} : { queueCapacity }
102577
104608
  });
102578
104609
  }
102579
104610
  /** What the ring holds — surfaced so the delegate can log what it served. */
@@ -102697,6 +104728,12 @@ var HksvFragmentSource = class {
102697
104728
  //#endregion
102698
104729
  //#region src/hksv/recording-delegate.ts
102699
104730
  /**
104731
+ * How many finished clips may wait for an acknowledgement iOS may never send.
104732
+ * Eight is more concurrent HKSV streams than one camera ever has; the number
104733
+ * exists so the map cannot grow, not to be tuned.
104734
+ */
104735
+ var MAX_TEES_AWAITING_ACK = 8;
104736
+ /**
102700
104737
  * `HDSProtocolSpecificErrorReason` is a `const enum`, so there is no reverse
102701
104738
  * map to index — and a bare number in the log is the difference between "iOS
102702
104739
  * closed it normally" and "iOS rejected our data", which is the whole reason
@@ -102725,6 +104762,12 @@ var HksvRecordingDelegate = class {
102725
104762
  log;
102726
104763
  /** The stream currently being yielded, so `closeRecordingStream` can end it. */
102727
104764
  open = null;
104765
+ /**
104766
+ * Tees whose stream has ended, still waiting for iOS's `ack`. BOUNDED: the
104767
+ * ack may never come (a dropped connection looks identical), and a map that
104768
+ * only ever grows is the unbounded buffer D9/D18 names in its smallest form.
104769
+ */
104770
+ tees = /* @__PURE__ */ new Map();
102728
104771
  constructor(input) {
102729
104772
  this.input = input;
102730
104773
  this.log = input.logger;
@@ -102805,6 +104848,7 @@ var HksvRecordingDelegate = class {
102805
104848
  };
102806
104849
  const startedAt = this.input.now();
102807
104850
  const prebufferSpanMs = source.prebufferSpanMs();
104851
+ const tee = this.openTee(streamId, source, prebufferSpanMs);
102808
104852
  let packets = 0;
102809
104853
  let bytes = 0;
102810
104854
  let markedLast = false;
@@ -102856,6 +104900,7 @@ var HksvRecordingDelegate = class {
102856
104900
  }
102857
104901
  } finally {
102858
104902
  subscription.release();
104903
+ this.finishTee(streamId, tee);
102859
104904
  if (this.open?.streamId === streamId) this.open = null;
102860
104905
  const fragmentOverrun = longestFragmentGapMs > this.input.advertisedFragmentMs * 1.5;
102861
104906
  this.log.info("hksv: recording stream finished", {
@@ -102886,6 +104931,20 @@ var HksvRecordingDelegate = class {
102886
104931
  tags: { deviceId: this.input.deviceId },
102887
104932
  meta: { streamId }
102888
104933
  });
104934
+ const tee = this.tees.get(streamId);
104935
+ this.tees.delete(streamId);
104936
+ if (tee === void 0) return;
104937
+ try {
104938
+ tee.acknowledge();
104939
+ } catch (err) {
104940
+ this.log.warn("hksv clip tee: recording the acknowledgement failed", {
104941
+ tags: { deviceId: this.input.deviceId },
104942
+ meta: {
104943
+ streamId,
104944
+ error: err instanceof Error ? err.message : String(err)
104945
+ }
104946
+ });
104947
+ }
102889
104948
  }
102890
104949
  closeRecordingStream(streamId, reason) {
102891
104950
  this.log.info("hksv: the recording stream was closed by the controller", {
@@ -102905,11 +104964,82 @@ var HksvRecordingDelegate = class {
102905
104964
  async dispose() {
102906
104965
  this.open?.subscription.release();
102907
104966
  this.open = null;
104967
+ this.tees.clear();
102908
104968
  const source = this.source;
102909
104969
  this.source = null;
102910
104970
  if (source) await source.stop("accessory disposed");
102911
104971
  }
102912
104972
  /**
104973
+ * A second subscription on the same plane, handed to the tee.
104974
+ *
104975
+ * Every failure here returns `null`: no tee, a warn naming the camera, and a
104976
+ * HomeKit recording that proceeds exactly as it would have. The one thing
104977
+ * this must never do is let a clip problem reach the generator above.
104978
+ */
104979
+ openTee(streamId, source, prebufferSpanMs) {
104980
+ const factory = this.input.openClipTee;
104981
+ if (factory === void 0) return null;
104982
+ let subscription = null;
104983
+ try {
104984
+ subscription = source.subscribe(`clip/${this.input.deviceId}#${streamId}`, 2);
104985
+ if (subscription === null) {
104986
+ this.log.warn("hksv clip tee: the plane refused a second subscription — this clip is NOT kept", {
104987
+ tags: { deviceId: this.input.deviceId },
104988
+ meta: { streamId }
104989
+ });
104990
+ return null;
104991
+ }
104992
+ const tee = factory({
104993
+ streamId,
104994
+ subscription,
104995
+ prebufferSpanMs
104996
+ });
104997
+ if (tee === null) {
104998
+ subscription.release();
104999
+ return null;
105000
+ }
105001
+ this.rememberTee(streamId, tee);
105002
+ return tee;
105003
+ } catch (err) {
105004
+ subscription?.release();
105005
+ this.log.warn("hksv clip tee: could NOT be opened — the HomeKit recording continues untouched", {
105006
+ tags: { deviceId: this.input.deviceId },
105007
+ meta: {
105008
+ streamId,
105009
+ error: err instanceof Error ? err.message : String(err)
105010
+ }
105011
+ });
105012
+ return null;
105013
+ }
105014
+ }
105015
+ finishTee(streamId, tee) {
105016
+ if (tee === null) return;
105017
+ try {
105018
+ tee.finish();
105019
+ } catch (err) {
105020
+ this.log.warn("hksv clip tee: closing the clip failed", {
105021
+ tags: { deviceId: this.input.deviceId },
105022
+ meta: {
105023
+ streamId,
105024
+ error: err instanceof Error ? err.message : String(err)
105025
+ }
105026
+ });
105027
+ }
105028
+ }
105029
+ /**
105030
+ * Hold the tee until iOS acknowledges, dropping the oldest once the map is
105031
+ * full. An ack that never arrives is the normal case for a dropped
105032
+ * connection, so this map's size is the only thing bounding it.
105033
+ */
105034
+ rememberTee(streamId, tee) {
105035
+ this.tees.set(streamId, tee);
105036
+ while (this.tees.size > MAX_TEES_AWAITING_ACK) {
105037
+ const oldest = this.tees.keys().next();
105038
+ if (oldest.done === true) break;
105039
+ this.tees.delete(oldest.value);
105040
+ }
105041
+ }
105042
+ /**
102913
105043
  * Start the prebuffer when iOS wants recording AND has chosen how, stop it
102914
105044
  * otherwise. Called from every state edge rather than each edge deciding for
102915
105045
  * itself: the two characteristics arrive in an order hap-nodejs explicitly
@@ -103079,10 +105209,20 @@ async function buildHksvRecording(input) {
103079
105209
  fps,
103080
105210
  fragmentLengthMs
103081
105211
  });
105212
+ const clipStore = bctx.options.hksvClipStore;
105213
+ const openClipTee = clipStore === null ? void 0 : createClipTeeFactory({
105214
+ logger: log,
105215
+ deviceId: numericDeviceId,
105216
+ store: clipStore,
105217
+ width: source.width,
105218
+ height: source.height,
105219
+ fragmentMs: fragmentLengthMs
105220
+ });
103082
105221
  const delegate = new HksvRecordingDelegate({
103083
105222
  logger: log,
103084
105223
  deviceId: numericDeviceId,
103085
105224
  isAudioActive: input.isAudioActive,
105225
+ ...openClipTee === void 0 ? {} : { openClipTee },
103086
105226
  advertisedFragmentMs: fragmentLengthMs,
103087
105227
  now: () => Date.now(),
103088
105228
  describeControllers: () => describeHapControllers(bctx.accessory),
@@ -103104,7 +105244,8 @@ async function buildHksvRecording(input) {
103104
105244
  measuredFps: fps,
103105
105245
  advertisedFps: advertisedResolution?.[2] ?? null,
103106
105246
  fragmentLengthMs,
103107
- sourceGopMs: gopMs ?? "unknown"
105247
+ sourceGopMs: gopMs ?? "unknown",
105248
+ clipsKept: openClipTee !== void 0
103108
105249
  } });
103109
105250
  return {
103110
105251
  options,
@@ -103129,133 +105270,30 @@ async function readProfileEntries(bctx) {
103129
105270
  * on 615 `ext` is the 1280×720 slot the broker calls `mid` — a name-based match
103130
105271
  * would silently read the 4K slot's GOP for a 720p recording.
103131
105272
  *
103132
- * `undefined` when the cap is not bound, which is most non-Hikvision providers.
103133
- */
103134
- async function readSourceGopMs(bctx, width, height) {
103135
- try {
103136
- const status = await bctx.proxy.streamParams?.getStatus({});
103137
- if (!status) return void 0;
103138
- for (const profile of [
103139
- status.main,
103140
- status.sub,
103141
- status.ext
103142
- ]) {
103143
- if (!profile) continue;
103144
- if (profile.width !== width || profile.height !== height) continue;
103145
- const { gop, framerate } = profile;
103146
- if (gop === void 0 || gop <= 0 || framerate <= 0) return void 0;
103147
- return Math.round(gop / framerate * 1e3);
103148
- }
103149
- return;
103150
- } catch {
103151
- return;
103152
- }
103153
- }
103154
- function resolveFps(fpsByProfile, profile) {
103155
- return fpsByProfile.get(profile)?.fps ?? ASSUMED_RECORDING_FPS;
103156
- }
103157
- //#endregion
103158
- //#region src/mappers/builders/child-switch.ts
103159
- /**
103160
- * Child-switch builder — turns a camstack accessory child device (siren,
103161
- * floodlight, spotlight, …) into a HomeKit Service ON THE PARENT
103162
- * camera Accessory.
103163
- *
103164
- * The orchestrator (`child-accessory.ts → buildChildServicesFor`)
103165
- * hands this builder the PARENT accessory + the CHILD's proxy + a
103166
- * unique subtype so each service round-trips deterministically through
103167
- * HAP serialisation. The builder:
103168
- * - Maps `switch` cap → `Service.Switch`.
103169
- * - Maps `switch` + `brightness` cap → `Service.Lightbulb` with
103170
- * Brightness characteristic (lighting devices only — see below).
103171
- * - Hydrates initial state from `getStatus({})`.
103172
- * - Wires `Characteristic.On` / `Characteristic.Brightness` through
103173
- * the typed DeviceProxy.
103174
- * - Subscribes to runtime-state slice updates so iOS Home reflects
103175
- * external state changes (e.g. another integration toggling the
103176
- * light).
103177
- *
103178
- * Service-kind selection: most cameras expose `brightness` on a siren
103179
- * because it controls the alarm VOLUME, not luminosity. Mapping a
103180
- * siren onto `Service.Lightbulb` makes it appear as a regular light in
103181
- * iOS Home (wrong icon, wrong scenes, wrong Siri intents). We therefore
103182
- * gate `Service.Lightbulb` on the child's `DeviceType` — only Light /
103183
- * Generic devices keep the Lightbulb shape; Siren, Switch, and others
103184
- * fall back to `Service.Switch`. Volume control for sirens is exposed
103185
- * through the camstack admin UI; iOS Home only needs the on/off toggle.
103186
- *
103187
- * Child devices without `switch` / `brightness` cap (status sensors,
103188
- * IR LED, etc.) return a no-op handle.
105273
+ * `undefined` when the cap is not bound, which is most non-Hikvision providers.
103189
105274
  */
103190
- async function buildChildSwitch(bctx, subtype, deviceType) {
103191
- const { ctx, accessory, proxy, numericDeviceId, displayName } = bctx;
103192
- const log = ctx.logger.withTags({ deviceId: numericDeviceId });
103193
- const hasSwitch = !!proxy.switch;
103194
- const hasBrightness = !!proxy.brightness;
103195
- if (!hasSwitch && !hasBrightness) {
103196
- log.debug("export-hap: child has no switch/brightness cap — skipping");
103197
- return { async dispose() {} };
103198
- }
103199
- const isLightingDevice = deviceType === DeviceType.Light || deviceType === DeviceType.Generic;
103200
- const useLightbulb = hasBrightness && isLightingDevice;
103201
- const service = useLightbulb ? accessory.addService(import_dist.Service.Lightbulb, displayName, subtype) : accessory.addService(import_dist.Service.Switch, displayName, subtype);
103202
- applyServiceLabel(service, displayName);
105275
+ async function readSourceGopMs(bctx, width, height) {
103203
105276
  try {
103204
- const switchStatus = await proxy.switch?.getStatus({});
103205
- if (switchStatus && typeof switchStatus.on === "boolean") service.updateCharacteristic(import_dist.Characteristic.On, switchStatus.on);
103206
- } catch (err) {
103207
- log.debug("export-hap: child switch.getStatus failed (non-fatal)", { meta: { error: errMsg$7(err) } });
103208
- }
103209
- if (useLightbulb) try {
103210
- const status = await proxy.brightness?.getStatus({});
103211
- if (status && typeof status.percentage === "number") service.updateCharacteristic(import_dist.Characteristic.Brightness, status.percentage);
103212
- } catch (err) {
103213
- log.debug("export-hap: child brightness.getStatus failed (non-fatal)", { meta: { error: errMsg$7(err) } });
103214
- }
103215
- if (hasSwitch) service.getCharacteristic(import_dist.Characteristic.On).onSet(async (value) => {
103216
- const on = value === true;
103217
- try {
103218
- await proxy.switch?.setState({ on });
103219
- } catch (err) {
103220
- log.warn("export-hap: child switch.setState failed", { meta: {
103221
- on,
103222
- error: errMsg$7(err)
103223
- } });
103224
- }
103225
- });
103226
- if (useLightbulb) service.getCharacteristic(import_dist.Characteristic.Brightness).onSet(async (value) => {
103227
- const percentage = typeof value === "number" ? value : Number(value);
103228
- if (!Number.isFinite(percentage)) return;
103229
- try {
103230
- await proxy.brightness?.setBrightness({ percentage });
103231
- } catch (err) {
103232
- log.warn("export-hap: child brightness.setBrightness failed", { meta: {
103233
- percentage,
103234
- error: errMsg$7(err)
103235
- } });
105277
+ const status = await bctx.proxy.streamParams?.getStatus({});
105278
+ if (!status) return void 0;
105279
+ for (const profile of [
105280
+ status.main,
105281
+ status.sub,
105282
+ status.ext
105283
+ ]) {
105284
+ if (!profile) continue;
105285
+ if (profile.width !== width || profile.height !== height) continue;
105286
+ const { gop, framerate } = profile;
105287
+ if (gop === void 0 || gop <= 0 || framerate <= 0) return void 0;
105288
+ return Math.round(gop / framerate * 1e3);
103236
105289
  }
103237
- });
103238
- const unsubscribes = [];
103239
- if (proxy.state.switch) {
103240
- const unsubSwitch = proxy.state.switch.subscribe((value) => {
103241
- if (value && typeof value.on === "boolean") service.updateCharacteristic(import_dist.Characteristic.On, value.on);
103242
- });
103243
- unsubscribes.push(unsubSwitch);
103244
- }
103245
- if (useLightbulb && proxy.state.brightness) {
103246
- const unsubBrightness = proxy.state.brightness.subscribe((value) => {
103247
- if (value && typeof value.percentage === "number") service.updateCharacteristic(import_dist.Characteristic.Brightness, value.percentage);
103248
- });
103249
- unsubscribes.push(unsubBrightness);
105290
+ return;
105291
+ } catch {
105292
+ return;
103250
105293
  }
103251
- return { async dispose() {
103252
- for (const u of unsubscribes) try {
103253
- u();
103254
- } catch {}
103255
- } };
103256
105294
  }
103257
- function errMsg$7(err) {
103258
- return err instanceof Error ? err.message : String(err);
105295
+ function resolveFps(fpsByProfile, profile) {
105296
+ return fpsByProfile.get(profile)?.fps ?? ASSUMED_RECORDING_FPS;
103259
105297
  }
103260
105298
  //#endregion
103261
105299
  //#region src/mappers/child-accessory.ts
@@ -103281,7 +105319,7 @@ async function buildChildServicesFor(input) {
103281
105319
  for (const h of handles) try {
103282
105320
  await h.dispose();
103283
105321
  } catch (err) {
103284
- ctx.logger.withTags({ deviceId: parentNumericId }).debug("export-hap: child service dispose failed (continuing)", { meta: { error: errMsg$6(err) } });
105322
+ ctx.logger.withTags({ deviceId: parentNumericId }).debug("export-hap: child service dispose failed (continuing)", { meta: { error: errMsg$3(err) } });
103285
105323
  }
103286
105324
  } };
103287
105325
  }
@@ -103297,7 +105335,7 @@ async function listChildren(ctx, parentNumericId) {
103297
105335
  features: Array.isArray(c.features) ? c.features.filter((f) => typeof f === "string") : []
103298
105336
  }));
103299
105337
  } catch (err) {
103300
- ctx.logger.withTags({ deviceId: parentNumericId }).debug("export-hap: deviceManager.getChildren failed (non-fatal)", { meta: { error: errMsg$6(err) } });
105338
+ ctx.logger.withTags({ deviceId: parentNumericId }).debug("export-hap: deviceManager.getChildren failed (non-fatal)", { meta: { error: errMsg$3(err) } });
103301
105339
  return [];
103302
105340
  }
103303
105341
  }
@@ -103313,7 +105351,7 @@ function asDeviceType(raw) {
103313
105351
  for (const value of Object.values(DeviceType)) if (value === lower) return value;
103314
105352
  return DeviceType.Generic;
103315
105353
  }
103316
- function errMsg$6(err) {
105354
+ function errMsg$3(err) {
103317
105355
  return err instanceof Error ? err.message : String(err);
103318
105356
  }
103319
105357
  //#endregion
@@ -103910,1035 +105948,389 @@ function buildMultiTierServices(input) {
103910
105948
  logger.info("hap multi-tier: refused a setup-endpoints write", {
103911
105949
  tags: { deviceId },
103912
105950
  meta: { reason: input.negotiation === void 0 ? "no-negotiator" : "not-a-string" }
103913
- });
103914
- return;
103915
- }
103916
- await input.negotiation.handleSetupEndpointsWrite(value);
103917
- });
103918
- streamManagement.addCharacteristic(setupEndpoints);
103919
- const streamingControl = new import_dist.Characteristic("RTP Streaming Control", CHAR_RTP_STREAMING_CONTROL, {
103920
- format: import_dist.Formats.TLV8,
103921
- perms: [import_dist.Perms.PAIRED_READ, import_dist.Perms.PAIRED_WRITE]
103922
- });
103923
- streamingControl.onGet(() => {
103924
- note("rtp-streaming-control");
103925
- return input.negotiation?.readStreamingControl() ?? encodeRtpStreamingControlStatus(null, RtpStreamingStatus.ERROR);
103926
- });
103927
- streamingControl.onSet(async (value) => {
103928
- if (input.negotiation === void 0 || typeof value !== "string") {
103929
- logger.info("hap multi-tier: refused an rtp streaming control write", {
103930
- tags: { deviceId },
103931
- meta: { reason: input.negotiation === void 0 ? "no-negotiator" : "not-a-string" }
103932
- });
103933
- return;
103934
- }
103935
- await input.negotiation.handleStreamingControlWrite(value);
103936
- });
103937
- streamManagement.addCharacteristic(streamingControl);
103938
- const sensor = new import_dist.Characteristic("Sensor UUID", CHAR_SENSOR_UUID, {
103939
- format: import_dist.Formats.DATA,
103940
- perms: [import_dist.Perms.PAIRED_READ]
103941
- });
103942
- const sensorValue = sensorUuid.toString("base64");
103943
- sensor.onGet(() => {
103944
- note("sensor-uuid");
103945
- return sensorValue;
103946
- });
103947
- streamManagement.addCharacteristic(sensor);
103948
- return {
103949
- capabilities,
103950
- globalOperatingMode,
103951
- streamManagement
103952
- };
103953
- }
103954
- //#endregion
103955
- //#region src/multi-tier/negotiation.ts
103956
- /**
103957
- * The adapter between Apple's new characteristics and the streaming engine this
103958
- * addon already has.
103959
- *
103960
- * ## Why an adapter and not a second engine
103961
- *
103962
- * The multi-tier RTP service owns its OWN `Setup Endpoints` and `RTP Streaming
103963
- * Control`, so hap-nodejs's `CameraController` never negotiates for it — and
103964
- * the library's own handlers are all `private`, so its engine cannot be
103965
- * borrowed either. But the sockets, the SRTP contexts, the ffmpeg pipeline and
103966
- * the teardown are the same ones the classic service uses. A second copy would
103967
- * be two engines to keep in agreement, and this repo has paid for that shape
103968
- * before. So: new characteristics, one engine, through `StreamNegotiator`.
103969
- *
103970
- * ## What is different about the new flow
103971
- *
103972
- * The classic `Selected RTP Stream Configuration` carries the whole selected
103973
- * configuration in its start — codec, profile, level, resolution, bitrate.
103974
- * `RTP Streaming Control` carries a **tier identifier**, because the
103975
- * configuration was agreed when we published `Supported Video Stream Tiers`.
103976
- * So this maps an identifier back to the tier WE published, and a tier we never
103977
- * published is `No Such Stream` — improvising a geometry there would serve a
103978
- * stream the controller did not ask for and it would look like it worked.
103979
- *
103980
- * ## Every path answers
103981
- *
103982
- * A read of either characteristic always produces a valid TLV8. The Home app
103983
- * reads a camera's characteristics in one batch, and one member that answers
103984
- * nothing took the kitchen camera's whole read down and left it on "loading".
103985
- * So an engine failure, a malformed write and "nothing has happened yet" are
103986
- * all statuses, never silence.
103987
- */
103988
- /** RTP payload types. 96 is the first dynamic one (RFC 3551); 110 is what our
103989
- * own Opus offers use. Both are echoed in the tier declaration. */
103990
- var VIDEO_PAYLOAD_TYPE = 96;
103991
- var AUDIO_PAYLOAD_TYPE = 110;
103992
- /** RTCP interval in SECONDS, which is the unit the engine reads. */
103993
- var RTCP_INTERVAL_SECONDS = 5;
103994
- /** The MTU the classic path uses for an Ethernet-sized path. */
103995
- var DEFAULT_MTU = 1378;
103996
- function errorMessage(error) {
103997
- return error instanceof Error ? error.message : String(error);
103998
- }
103999
- function buildMultiTierNegotiation(input) {
104000
- const { deviceId, logger, negotiator } = input;
104001
- const log = logger.withTags({ deviceId });
104002
- let setupResponse = encodeSetupEndpointsRefusal(SetupEndpointsStatus.ERROR);
104003
- let controlResponse = encodeRtpStreamingControlStatus(null, RtpStreamingStatus.ERROR);
104004
- /** Sessions we have STARTED, so an End can tell "not started" from "unknown". */
104005
- const started = /* @__PURE__ */ new Set();
104006
- async function handleSetupEndpointsWrite(base64) {
104007
- let sessionId = null;
104008
- try {
104009
- const request = decodeSetupEndpointsRequest(base64);
104010
- sessionId = request.sessionId;
104011
- const prepareRequest = {
104012
- sessionID: request.sessionId,
104013
- sourceAddress: request.controllerAddress,
104014
- targetAddress: request.controllerAddress,
104015
- addressVersion: request.addressVersion,
104016
- video: {
104017
- port: request.videoPort,
104018
- srtpCryptoSuite: import_dist.SRTPCryptoSuites.AES_CM_128_HMAC_SHA1_80,
104019
- srtp_key: request.videoSrtp.key,
104020
- srtp_salt: request.videoSrtp.salt
104021
- },
104022
- audio: {
104023
- port: request.audioPort,
104024
- srtpCryptoSuite: import_dist.SRTPCryptoSuites.AES_CM_128_HMAC_SHA1_80,
104025
- srtp_key: request.audioSrtp.key,
104026
- srtp_salt: request.audioSrtp.salt
104027
- }
104028
- };
104029
- const prepared = await negotiator.prepare(prepareRequest);
104030
- setupResponse = encodeSetupEndpointsResponse({
104031
- sessionId: request.sessionId,
104032
- addressVersion: request.addressVersion,
104033
- accessoryAddress: prepared.localAddress,
104034
- videoPort: prepared.videoPort,
104035
- audioPort: prepared.audioPort,
104036
- videoSrtp: {
104037
- key: request.videoSrtp.key,
104038
- salt: request.videoSrtp.salt
104039
- },
104040
- audioSrtp: {
104041
- key: request.audioSrtp.key,
104042
- salt: request.audioSrtp.salt
104043
- },
104044
- videoSsrc: prepared.videoSsrc,
104045
- audioSsrc: prepared.audioSsrc
104046
- });
104047
- log.info("hap multi-tier: endpoints prepared", { meta: {
104048
- sessionId: request.sessionId,
104049
- controller: `${request.controllerAddress}:${request.videoPort}/${request.audioPort}`,
104050
- accessory: `${prepared.localAddress}:${prepared.videoPort}/${prepared.audioPort}`,
104051
- videoSsrc: prepared.videoSsrc,
104052
- audioSsrc: prepared.audioSsrc
104053
- } });
104054
- } catch (error) {
104055
- setupResponse = encodeSetupEndpointsRefusal(SetupEndpointsStatus.ERROR);
104056
- log.warn("hap multi-tier: setup endpoints refused", { meta: {
104057
- sessionId,
104058
- error: errorMessage(error)
104059
- } });
104060
- }
104061
- }
104062
- async function handleStreamingControlWrite(base64) {
104063
- let sessionId = null;
104064
- try {
104065
- const command = decodeRtpStreamingControl(base64);
104066
- sessionId = command.sessionId;
104067
- if (!negotiator.has(command.sessionId)) {
104068
- controlResponse = encodeRtpStreamingControlStatus(command.sessionId, RtpStreamingStatus.UNKNOWN_SESSION_IDENTIFIER);
104069
- log.warn("hap multi-tier: control write for a session that was never prepared", { meta: {
104070
- sessionId: command.sessionId,
104071
- command: command.command
104072
- } });
104073
- return;
104074
- }
104075
- if (command.command === RtpStreamingCommand.END) {
104076
- if (!started.has(command.sessionId)) {
104077
- controlResponse = encodeRtpStreamingControlStatus(command.sessionId, RtpStreamingStatus.NO_SUCH_STREAM);
104078
- return;
104079
- }
104080
- const stop = {
104081
- sessionID: command.sessionId,
104082
- type: import_dist.StreamRequestTypes.STOP
104083
- };
104084
- await negotiator.control(stop);
104085
- started.delete(command.sessionId);
104086
- controlResponse = encodeRtpStreamingControlStatus(command.sessionId, RtpStreamingStatus.SUCCESS);
104087
- log.info("hap multi-tier: stream ended", { meta: { sessionId: command.sessionId } });
104088
- return;
104089
- }
104090
- const tier = input.tiers.find((t) => t.identifier === command.videoTier);
104091
- if (tier === void 0) {
104092
- controlResponse = encodeRtpStreamingControlStatus(command.sessionId, RtpStreamingStatus.NO_SUCH_STREAM);
104093
- log.warn("hap multi-tier: start named a tier we never published", { meta: {
104094
- sessionId: command.sessionId,
104095
- requested: command.videoTier,
104096
- published: input.tiers.map((t) => t.identifier)
104097
- } });
104098
- return;
104099
- }
104100
- log.info("hap multi-tier: start ssrc comparison", { meta: {
104101
- sessionId: command.sessionId,
104102
- controllerVideoSsrc: command.videoSsrc ?? null,
104103
- controllerAudioSsrc: command.audioSsrc ?? null,
104104
- note: "accessory keeps the ssrcs it advertised at setup"
104105
- } });
104106
- const start = {
104107
- sessionID: command.sessionId,
104108
- type: import_dist.StreamRequestTypes.START,
104109
- video: {
104110
- codec: import_dist.VideoCodecType.H264,
104111
- profile: import_dist.H264Profile.HIGH,
104112
- level: import_dist.H264Level.LEVEL4_0,
104113
- packetizationMode: import_dist.VideoCodecPacketizationMode.NON_INTERLEAVED,
104114
- width: tier.width,
104115
- height: tier.height,
104116
- fps: tier.frameRate,
104117
- pt: VIDEO_PAYLOAD_TYPE,
104118
- ssrc: command.videoSsrc ?? 0,
104119
- max_bit_rate: tier.targetAverageBitrateKbps,
104120
- rtcp_interval: RTCP_INTERVAL_SECONDS,
104121
- mtu: DEFAULT_MTU
104122
- },
104123
- audio: {
104124
- codec: import_dist.AudioStreamingCodecType.OPUS,
104125
- channel: 1,
104126
- bit_rate: 24,
104127
- sample_rate: import_dist.AudioStreamingSamplerate.KHZ_24,
104128
- packet_time: 20,
104129
- pt: AUDIO_PAYLOAD_TYPE,
104130
- ssrc: command.audioSsrc ?? 0,
104131
- max_bit_rate: 24,
104132
- rtcp_interval: RTCP_INTERVAL_SECONDS,
104133
- comfort_pt: 13,
104134
- comfortNoiseEnabled: false
104135
- }
104136
- };
104137
- await negotiator.control(start);
104138
- started.add(command.sessionId);
104139
- controlResponse = encodeRtpStreamingControlStatus(command.sessionId, RtpStreamingStatus.SUCCESS);
104140
- log.info("hap multi-tier: stream started", { meta: {
104141
- sessionId: command.sessionId,
104142
- tier: tier.identifier,
104143
- geometry: `${tier.width}x${tier.height}@${tier.frameRate}`,
104144
- targetKbps: tier.targetAverageBitrateKbps,
104145
- codec: input.codec
104146
- } });
104147
- } catch (error) {
104148
- controlResponse = encodeRtpStreamingControlStatus(sessionId, RtpStreamingStatus.ERROR);
104149
- log.warn("hap multi-tier: control write refused", { meta: {
104150
- sessionId,
104151
- error: errorMessage(error)
104152
- } });
104153
- }
104154
- }
104155
- return {
104156
- handleSetupEndpointsWrite,
104157
- readSetupEndpoints: () => setupResponse,
104158
- handleStreamingControlWrite,
104159
- readStreamingControl: () => controlResponse
104160
- };
104161
- }
104162
- //#endregion
104163
- //#region src/mappers/camera-accessory.ts
104164
- /**
104165
- * Camera accessory orchestrator — given a camstack deviceId, builds one
104166
- * HomeKit Accessory representing the camera (or doorbell) plus a
104167
- * separate Accessory per child device (siren, floodlight, …).
104168
- *
104169
- * Flow:
104170
- * 1. Resolve a typed `DeviceProxy` via `ctx.fetchDevice(deviceId)`.
104171
- * 2. Inspect `proxy.binding.entries` (or fall back to `getDevice`
104172
- * features) to decide whether the device is a doorbell vs a
104173
- * regular camera. Doorbells get `Categories.VIDEO_DOORBELL` +
104174
- * `DoorbellController`; otherwise `Categories.IP_CAMERA` +
104175
- * `CameraController`.
104176
- * 3. Build the streaming delegate via the `camera-streams` builder.
104177
- * 4. `configureController(controller)` to attach the streaming
104178
- * services to the Accessory.
104179
- * 5. Run each feature builder gated on cap presence (motion sensor,
104180
- * intercom, PTZ, doorbell).
104181
- * 6. Enumerate children via `proxy.deviceManager.getChildren({...})`
104182
- * and build one Accessory per child (see `child-accessory.ts`).
104183
- * 7. Return a `Mapper` whose `accessories[]` lists every accessory
104184
- * this device produced so the bridge can register them all and
104185
- * remove them all on unexpose.
104186
- */
104187
- async function buildCameraAccessory(input) {
104188
- const { ctx, deviceId, displayName, options } = input;
104189
- const numericId = Number.parseInt(deviceId, 10);
104190
- if (!Number.isFinite(numericId)) throw new Error(`export-hap: cannot map device '${deviceId}' — id is not numeric`);
104191
- const proxy = await ctx.fetchDevice(numericId);
104192
- const capNames = new Set(proxy.binding?.entries.map((e) => e.capName) ?? []);
104193
- const isDoorbell = capNames.has("doorbell");
104194
- const category = isDoorbell ? import_dist.Categories.VIDEO_DOORBELL : import_dist.Categories.IP_CAMERA;
104195
- const accessory = new import_dist.Accessory(displayName, accessoryUuidFor("camera", numericId));
104196
- accessory.category = category;
104197
- await populateAccessoryInfo(accessory, proxy, displayName, "Camera");
104198
- const bctx = {
104199
- ctx,
104200
- accessory,
104201
- proxy,
104202
- numericDeviceId: numericId,
104203
- displayName,
104204
- options
104205
- };
104206
- const advertisedVideo = await probeAdvertisedVideoProfile(bctx);
104207
- const streams = buildCameraStreamingDelegate(bctx, advertisedVideo);
104208
- const handles = [];
104209
- if (capNames.has("intercom")) handles.push(await buildIntercom({
104210
- bctx,
104211
- streamingOptions: streams.streamingOptions
104212
- }));
104213
- const recordingEnabled = options.hapDeviceSettings.hksvRecording === true;
104214
- let recordingAudioActive = true;
104215
- const recording = recordingEnabled ? await buildHksvRecording({
104216
- bctx,
104217
- fpsByProfile: advertisedVideo.fpsByProfile,
104218
- isAudioActive: () => recordingAudioActive
104219
- }) : null;
104220
- const controller = new (isDoorbell ? import_dist.DoorbellController : import_dist.CameraController)({
104221
- delegate: streams.delegate,
104222
- streamingOptions: streams.streamingOptions,
104223
- cameraStreamCount: 2,
104224
- ...recording === null ? {} : { recording },
104225
- ...recording === null || !capNames.has("motion-detection") ? {} : { sensors: { motion: true } }
104226
- });
104227
- accessory.configureController(controller);
104228
- if (options.hapDeviceSettings.multiTierService !== false) {
104229
- const sensor = advertisedVideo.sensor;
104230
- const services = buildMultiTierServices({
104231
- deviceId: numericId,
104232
- logger: ctx.logger,
104233
- width: sensor.width,
104234
- height: sensor.height,
104235
- codec: sensor.codec,
104236
- audioCaptureRateHz: 16e3,
104237
- negotiation: buildMultiTierNegotiation({
104238
- deviceId: numericId,
104239
- logger: ctx.logger,
104240
- negotiator: streams.negotiator,
104241
- tiers: tiersForSensor(sensor.width, sensor.height),
104242
- codec: sensor.codec
104243
- })
104244
- });
104245
- accessory.addService(services.capabilities);
104246
- accessory.addService(services.globalOperatingMode);
104247
- accessory.addService(services.streamManagement);
104248
- ctx.logger.info("hap multi-tier: services declared on this accessory", {
104249
- tags: { deviceId: numericId },
104250
- meta: {
104251
- serviceUuid: MULTI_TIER_RTP_SERVICE_UUID,
104252
- codec: sensor.codec === TierVideoCodec.H265 ? "h265" : "h264",
104253
- sensor: `${sensor.width}x${sensor.height}`
104254
- }
104255
- });
104256
- }
104257
- if (recording !== null) {
104258
- handles.push({ dispose: () => recording.dispose() });
104259
- const audioCharacteristic = (controller.recordingManagement?.operatingModeService)?.getCharacteristic(import_dist.Characteristic.RecordingAudioActive);
104260
- if (audioCharacteristic) {
104261
- recordingAudioActive = audioCharacteristic.value !== 0 && audioCharacteristic.value !== false;
104262
- audioCharacteristic.on("change", ({ newValue }) => {
104263
- recordingAudioActive = newValue !== 0 && newValue !== false;
104264
- });
104265
- }
104266
- }
104267
- if (capNames.has("motion-detection")) handles.push(await buildMotionSensor(bctx, recording === null ? null : controller.motionService ?? null));
104268
- if (isDoorbell && controller instanceof import_dist.DoorbellController) handles.push(await buildDoorbell({
104269
- bctx,
104270
- controller
104271
- }));
104272
- if (capNames.has("ptz") || capNames.has("ptz-autotrack")) handles.push(await buildPtz(bctx));
104273
- if (capNames.has("battery")) handles.push(await buildBattery(bctx));
104274
- if (capNames.has("privacy-mask")) handles.push(await buildPrivacySwitch(bctx));
104275
- handles.push(await buildChildServicesFor({ parentCtx: bctx }));
104276
- return {
104277
- accessory,
104278
- accessories: [accessory],
104279
- async dispose() {
104280
- const log = ctx.logger.withTags({ deviceId: numericId });
104281
- for (const h of handles) try {
104282
- await h.dispose();
104283
- } catch (err) {
104284
- log.debug("export-hap: builder dispose failed (continuing)", { meta: { error: errMsg$5(err) } });
104285
- }
104286
- try {
104287
- await streams.dispose();
104288
- } catch (err) {
104289
- log.debug("export-hap: streams dispose failed", { meta: { error: errMsg$5(err) } });
104290
- }
104291
- }
104292
- };
104293
- }
104294
- function errMsg$5(err) {
104295
- return err instanceof Error ? err.message : String(err);
104296
- }
104297
- //#endregion
104298
- //#region src/mappers/builders/generic/lock.ts
104299
- /**
104300
- * `lock-control` → `Service.LockMechanism`.
104301
- *
104302
- * Not a sensor row: HomeKit models a lock as two characteristics, a CURRENT
104303
- * state the accessory owns and a TARGET state the controller writes, and the
104304
- * cap's five states do not map onto either one alone.
104305
- *
104306
- * ```
104307
- * cap state LockCurrentState LockTargetState
104308
- * locked SECURED SECURED
104309
- * unlocked UNSECURED UNSECURED
104310
- * locking UNKNOWN SECURED (in flight — do not claim SECURED)
104311
- * unlocking UNKNOWN UNSECURED
104312
- * jammed JAMMED unchanged (the controller's intent stands)
104313
- * ```
104314
- *
104315
- * Reporting SECURED while the bolt is still moving is the failure worth naming:
104316
- * iOS renders the lock as closed, the operator walks away, and the motor stalls
104317
- * behind them. `UNKNOWN` is the honest answer for an in-flight transition.
104318
- *
104319
- * `open` (the cap's third method, for locks with a latch/buzzer) has no HomeKit
104320
- * counterpart on this service and is deliberately not wired — a Switch that
104321
- * silently buzzes a door open would be a second knob nobody asked for.
104322
- */
104323
- var SUBTYPE$1 = "lock-control";
104324
- var readLock = reader(LockControlStatusSchema.pick({ state: true }), (status) => {
104325
- const current = status.state === "locked" ? import_dist.Characteristic.LockCurrentState.SECURED : status.state === "unlocked" ? import_dist.Characteristic.LockCurrentState.UNSECURED : status.state === "jammed" ? import_dist.Characteristic.LockCurrentState.JAMMED : import_dist.Characteristic.LockCurrentState.UNKNOWN;
104326
- const target = status.state === "locked" || status.state === "locking" ? import_dist.Characteristic.LockTargetState.SECURED : status.state === "unlocked" || status.state === "unlocking" ? import_dist.Characteristic.LockTargetState.UNSECURED : null;
104327
- return [{
104328
- characteristic: import_dist.Characteristic.LockCurrentState,
104329
- value: current
104330
- }, ...target === null ? [] : [{
104331
- characteristic: import_dist.Characteristic.LockTargetState,
104332
- value: target
104333
- }]];
104334
- });
104335
- async function buildLockMechanism(bctx) {
104336
- const { ctx, accessory, proxy, numericDeviceId, displayName } = bctx;
104337
- const log = ctx.logger.withTags({ deviceId: numericDeviceId });
104338
- const label = hapServiceName([displayName], `Lock ${numericDeviceId}`);
104339
- const service = accessory.addService(import_dist.Service.LockMechanism, label, SUBTYPE$1);
104340
- applyServiceLabel(service, label);
104341
- const apply = (status, source) => {
104342
- const updates = readLock(status);
104343
- if (updates.length === 0) {
104344
- log.debug("export-hap: lock status did not match the cap schema — no update", { meta: { source } });
105951
+ });
104345
105952
  return;
104346
105953
  }
104347
- applyUpdates(service, updates);
104348
- };
104349
- try {
104350
- const status = await proxy.lockControl?.getStatus({});
104351
- if (status !== void 0 && status !== null) apply(status, "getStatus");
104352
- } catch (err) {
104353
- log.debug("export-hap: lock getStatus hydrate failed (non-fatal)", { meta: { error: errMsg$4(err) } });
104354
- }
104355
- service.getCharacteristic(import_dist.Characteristic.LockTargetState).onSet(async (value) => {
104356
- const secure = value === import_dist.Characteristic.LockTargetState.SECURED;
104357
- try {
104358
- if (secure) await proxy.lockControl?.lock({});
104359
- else await proxy.lockControl?.unlock({});
104360
- } catch (err) {
104361
- log.warn("export-hap: lock command failed", { meta: {
104362
- secure,
104363
- error: errMsg$4(err)
104364
- } });
105954
+ await input.negotiation.handleSetupEndpointsWrite(value);
105955
+ });
105956
+ streamManagement.addCharacteristic(setupEndpoints);
105957
+ const streamingControl = new import_dist.Characteristic("RTP Streaming Control", CHAR_RTP_STREAMING_CONTROL, {
105958
+ format: import_dist.Formats.TLV8,
105959
+ perms: [import_dist.Perms.PAIRED_READ, import_dist.Perms.PAIRED_WRITE]
105960
+ });
105961
+ streamingControl.onGet(() => {
105962
+ note("rtp-streaming-control");
105963
+ return input.negotiation?.readStreamingControl() ?? encodeRtpStreamingControlStatus(null, RtpStreamingStatus.ERROR);
105964
+ });
105965
+ streamingControl.onSet(async (value) => {
105966
+ if (input.negotiation === void 0 || typeof value !== "string") {
105967
+ logger.info("hap multi-tier: refused an rtp streaming control write", {
105968
+ tags: { deviceId },
105969
+ meta: { reason: input.negotiation === void 0 ? "no-negotiator" : "not-a-string" }
105970
+ });
105971
+ return;
104365
105972
  }
105973
+ await input.negotiation.handleStreamingControlWrite(value);
104366
105974
  });
104367
- const unsubscribe = proxy.state.lockControl?.subscribe((value) => {
104368
- if (value === void 0 || value === null) return;
104369
- apply(value, "slice");
104370
- }) ?? null;
104371
- return { async dispose() {
104372
- try {
104373
- unsubscribe?.();
104374
- } catch {}
104375
- } };
104376
- }
104377
- function errMsg$4(err) {
104378
- return err instanceof Error ? err.message : String(err);
104379
- }
104380
- //#endregion
104381
- //#region src/mappers/builders/generic/security-state.ts
104382
- /** Is this a settled arm state worth remembering? */
104383
- function armedMemoOf(state) {
104384
- return state === "armed_home" || state === "armed_away" || state === "armed_night" || state === "armed_vacation" || state === "armed_custom_bypass" ? state : null;
104385
- }
104386
- function currentForArmed(state) {
104387
- switch (state) {
104388
- case "armed_home":
104389
- case "armed_custom_bypass": return import_dist.Characteristic.SecuritySystemCurrentState.STAY_ARM;
104390
- case "armed_night": return import_dist.Characteristic.SecuritySystemCurrentState.NIGHT_ARM;
104391
- default: return import_dist.Characteristic.SecuritySystemCurrentState.AWAY_ARM;
104392
- }
104393
- }
104394
- function targetForArmed(state) {
104395
- switch (state) {
104396
- case "armed_home":
104397
- case "armed_custom_bypass": return import_dist.Characteristic.SecuritySystemTargetState.STAY_ARM;
104398
- case "armed_night": return import_dist.Characteristic.SecuritySystemTargetState.NIGHT_ARM;
104399
- default: return import_dist.Characteristic.SecuritySystemTargetState.AWAY_ARM;
104400
- }
104401
- }
104402
- function securityStateFor(state, memo) {
104403
- const settled = armedMemoOf(state);
104404
- if (settled !== null) return {
104405
- current: currentForArmed(settled),
104406
- target: targetForArmed(settled)
105975
+ streamManagement.addCharacteristic(streamingControl);
105976
+ const sensor = new import_dist.Characteristic("Sensor UUID", CHAR_SENSOR_UUID, {
105977
+ format: import_dist.Formats.DATA,
105978
+ perms: [import_dist.Perms.PAIRED_READ]
105979
+ });
105980
+ const sensorValue = sensorUuid.toString("base64");
105981
+ sensor.onGet(() => {
105982
+ note("sensor-uuid");
105983
+ return sensorValue;
105984
+ });
105985
+ streamManagement.addCharacteristic(sensor);
105986
+ return {
105987
+ capabilities,
105988
+ globalOperatingMode,
105989
+ streamManagement
104407
105990
  };
104408
- switch (state) {
104409
- case "disarmed": return {
104410
- current: import_dist.Characteristic.SecuritySystemCurrentState.DISARMED,
104411
- target: import_dist.Characteristic.SecuritySystemTargetState.DISARM
104412
- };
104413
- case "arming": return {
104414
- current: import_dist.Characteristic.SecuritySystemCurrentState.DISARMED,
104415
- target: memo === null ? null : targetForArmed(memo)
104416
- };
104417
- case "disarming": return {
104418
- current: memo === null ? import_dist.Characteristic.SecuritySystemCurrentState.DISARMED : currentForArmed(memo),
104419
- target: import_dist.Characteristic.SecuritySystemTargetState.DISARM
104420
- };
104421
- case "pending": return {
104422
- current: memo === null ? import_dist.Characteristic.SecuritySystemCurrentState.DISARMED : currentForArmed(memo),
104423
- target: memo === null ? null : targetForArmed(memo)
104424
- };
104425
- default: return {
104426
- current: import_dist.Characteristic.SecuritySystemCurrentState.ALARM_TRIGGERED,
104427
- target: memo === null ? null : targetForArmed(memo)
104428
- };
104429
- }
104430
- }
104431
- /**
104432
- * The arm mode behind a HomeKit target, or `null` for DISARM and for anything
104433
- * outside the four values HomeKit defines.
104434
- *
104435
- * `null` is not an error the caller can ignore: DISARM is a different COMMAND,
104436
- * and a value HomeKit never sends is a write this builder refuses to translate.
104437
- */
104438
- function alarmArmModeFor(target) {
104439
- switch (target) {
104440
- case import_dist.Characteristic.SecuritySystemTargetState.STAY_ARM: return "home";
104441
- case import_dist.Characteristic.SecuritySystemTargetState.AWAY_ARM: return "away";
104442
- case import_dist.Characteristic.SecuritySystemTargetState.NIGHT_ARM: return "night";
104443
- default: return null;
104444
- }
104445
- }
104446
- //#endregion
104447
- //#region src/mappers/builders/generic/security-target.ts
104448
- /**
104449
- * The panel modes behind each HomeKit target, PRIMARY first.
104450
- *
104451
- * The primary is the mode `alarmArmModeFor` names; the secondary is the other
104452
- * mode `security-state.ts` reports through the same HomeKit value, so the two
104453
- * modules agree in both directions by construction.
104454
- */
104455
- var MODES_BY_TARGET = new Map([
104456
- [import_dist.Characteristic.SecuritySystemTargetState.STAY_ARM, ["home", "custom_bypass"]],
104457
- [import_dist.Characteristic.SecuritySystemTargetState.AWAY_ARM, ["away", "vacation"]],
104458
- [import_dist.Characteristic.SecuritySystemTargetState.NIGHT_ARM, ["night"]]
104459
- ]);
104460
- /** Arm targets in the order HomeKit numbers them; DISARM is appended last. */
104461
- var ARM_TARGETS = [
104462
- import_dist.Characteristic.SecuritySystemTargetState.STAY_ARM,
104463
- import_dist.Characteristic.SecuritySystemTargetState.AWAY_ARM,
104464
- import_dist.Characteristic.SecuritySystemTargetState.NIGHT_ARM
104465
- ];
104466
- /**
104467
- * The `validValues` for `SecuritySystemTargetState`, or `null` to leave
104468
- * HomeKit's own four values in place.
104469
- *
104470
- * DISARM is always present: disarming is not one of the panel's arm modes and
104471
- * a panel that cannot be disarmed is not a thing this cap can express.
104472
- */
104473
- function targetValidValuesFor(availableModes) {
104474
- if (availableModes.length === 0) return null;
104475
- return [...ARM_TARGETS.filter((target) => (MODES_BY_TARGET.get(target) ?? []).some((mode) => availableModes.includes(mode))), import_dist.Characteristic.SecuritySystemTargetState.DISARM];
104476
- }
104477
- /**
104478
- * The arm mode to send for a HomeKit target, or `null` when there is none.
104479
- *
104480
- * `null` is not a shrug the caller may swallow: DISARM is a different command,
104481
- * and an arm target the panel accepts no mode for is a write this module
104482
- * refuses to translate rather than guess at.
104483
- */
104484
- function armModeForTarget(target, availableModes) {
104485
- const primary = alarmArmModeFor(target);
104486
- if (primary === null) return null;
104487
- if (availableModes.length === 0) return primary;
104488
- return (MODES_BY_TARGET.get(target) ?? [primary]).find((mode) => availableModes.includes(mode)) ?? null;
104489
105991
  }
104490
105992
  //#endregion
104491
- //#region src/mappers/builders/generic/security-system.ts
105993
+ //#region src/multi-tier/negotiation.ts
104492
105994
  /**
104493
- * `alarm-panel` → `Service.SecuritySystem`.
104494
- *
104495
- * The lock builder's shape (a current/target pair, a hydrate, an `onSet`, a
104496
- * slice subscription, a dispose) applied to the one device on the fleet where
104497
- * reporting a state that is not true has a consequence worse than a stalled
104498
- * motor. The mapping itself is NOT here: `security-state.ts` owns what the
104499
- * panel's state looks like to HomeKit and `security-target.ts` owns what
104500
- * HomeKit may ask for, both pure and both tested on their own. This file is
104501
- * the service around them.
104502
- *
104503
- * ## The memo
105995
+ * The adapter between Apple's new characteristics and the streaming engine this
105996
+ * addon already has.
104504
105997
  *
104505
- * `pending`, `disarming` and `triggered` do not carry the mode the panel was
104506
- * armed to — only the last settled state knows it. This builder is the caller
104507
- * `security-state.ts` describes: it remembers the last settled arm and forgets
104508
- * it on `disarmed`, because a memo that outlives a disarm makes the next
104509
- * `arming` claim a target nobody asked for.
105998
+ * ## Why an adapter and not a second engine
104510
105999
  *
104511
- * The memo is deliberately NOT seeded from the controller's own write. HomeKit
104512
- * already holds the target the operator just set; not writing it back is
104513
- * exactly right. The cost is a panel armed from its own keypad during an exit
104514
- * delay, where the target stays at whatever HomeKit last knew — a stale target
104515
- * for the length of the delay, against inventing an intent, and the invention
104516
- * is worse.
106000
+ * The multi-tier RTP service owns its OWN `Setup Endpoints` and `RTP Streaming
106001
+ * Control`, so hap-nodejs's `CameraController` never negotiates for it — and
106002
+ * the library's own handlers are all `private`, so its engine cannot be
106003
+ * borrowed either. But the sockets, the SRTP contexts, the ffmpeg pipeline and
106004
+ * the teardown are the same ones the classic service uses. A second copy would
106005
+ * be two engines to keep in agreement, and this repo has paid for that shape
106006
+ * before. So: new characteristics, one engine, through `StreamNegotiator`.
104517
106007
  *
104518
- * ## `requiresCode`: the arm is REFUSED, not attempted
106008
+ * ## What is different about the new flow
104519
106009
  *
104520
- * HomeKit's SecuritySystem has no PIN prompt — there is no characteristic for
104521
- * it and no way to ask the operator mid-write. So a panel that requires a code
104522
- * cannot be armed or disarmed from the Home app, and this builder says so
104523
- * twice: the write fails with `INSUFFICIENT_AUTHORIZATION` (iOS renders the
104524
- * tile's failure) and a WARN names the device, the command and the mode.
106010
+ * The classic `Selected RTP Stream Configuration` carries the whole selected
106011
+ * configuration in its start — codec, profile, level, resolution, bitrate.
106012
+ * `RTP Streaming Control` carries a **tier identifier**, because the
106013
+ * configuration was agreed when we published `Supported Video Stream Tiers`.
106014
+ * So this maps an identifier back to the tier WE published, and a tier we never
106015
+ * published is `No Such Stream` — improvising a geometry there would serve a
106016
+ * stream the controller did not ask for and it would look like it worked.
104525
106017
  *
104526
- * Sending the command code-less and letting the panel reject it was the
104527
- * alternative. It is worse: the refusal costs a round trip, it is
104528
- * indistinguishable from a broken panel in the log, and repeated code-less
104529
- * attempts are what locks a real alarm panel out.
104530
- */
104531
- var SUBTYPE = "alarm-panel";
104532
- /** The subset of the status this builder reads. */
104533
- var PanelReadSchema = AlarmPanelStatusSchema.pick({
104534
- state: true,
104535
- availableModes: true,
104536
- requiresCode: true
104537
- });
104538
- /**
104539
- * Turn a panel status into characteristic writes, folding the memo.
106018
+ * ## Every path answers
104540
106019
  *
104541
- * A payload that does not match the cap's own schema yields NO updates and
104542
- * leaves the memo alone — it is a status we could not read, not a panel that
104543
- * changed. The caller logs the drop; a frozen characteristic is otherwise
104544
- * indistinguishable from a quiet panel.
106020
+ * A read of either characteristic always produces a valid TLV8. The Home app
106021
+ * reads a camera's characteristics in one batch, and one member that answers
106022
+ * nothing took the kitchen camera's whole read down and left it on "loading".
106023
+ * So an engine failure, a malformed write and "nothing has happened yet" are
106024
+ * all statuses, never silence.
104545
106025
  */
104546
- function securityUpdatesFor(status, memo) {
104547
- const parsed = PanelReadSchema.safeParse(status);
104548
- if (!parsed.success) return {
104549
- updates: [],
104550
- memo,
104551
- panel: null
104552
- };
104553
- const nextMemo = parsed.data.state === "disarmed" ? null : armedMemoOf(parsed.data.state) ?? memo;
104554
- const { current, target } = securityStateFor(parsed.data.state, nextMemo);
104555
- return {
104556
- updates: [{
104557
- characteristic: import_dist.Characteristic.SecuritySystemCurrentState,
104558
- value: current
104559
- }, ...target === null ? [] : [{
104560
- characteristic: import_dist.Characteristic.SecuritySystemTargetState,
104561
- value: target
104562
- }]],
104563
- memo: nextMemo,
104564
- panel: {
104565
- availableModes: parsed.data.availableModes,
104566
- requiresCode: parsed.data.requiresCode
104567
- }
104568
- };
106026
+ /** RTP payload types. 96 is the first dynamic one (RFC 3551); 110 is what our
106027
+ * own Opus offers use. Both are echoed in the tier declaration. */
106028
+ var VIDEO_PAYLOAD_TYPE = 96;
106029
+ var AUDIO_PAYLOAD_TYPE = 110;
106030
+ /** RTCP interval in SECONDS, which is the unit the engine reads. */
106031
+ var RTCP_INTERVAL_SECONDS = 5;
106032
+ /** The MTU the classic path uses for an Ethernet-sized path. */
106033
+ var DEFAULT_MTU = 1378;
106034
+ function errorMessage(error) {
106035
+ return error instanceof Error ? error.message : String(error);
104569
106036
  }
104570
- async function buildSecuritySystem(bctx) {
104571
- const { ctx, accessory, proxy, numericDeviceId, displayName } = bctx;
104572
- const log = ctx.logger.withTags({ deviceId: numericDeviceId });
104573
- const label = hapServiceName([displayName], `Alarm ${numericDeviceId}`);
104574
- const service = accessory.addService(import_dist.Service.SecuritySystem, label, SUBTYPE);
104575
- applyServiceLabel(service, label);
104576
- let memo = null;
104577
- let availableModes = [];
104578
- let requiresCode = false;
104579
- /** Only log the `validValues` narrowing when it actually changes. */
104580
- let lastValidValues = "";
104581
- const applyValidValues = () => {
104582
- const validValues = targetValidValuesFor(availableModes);
104583
- if (validValues === null) {
104584
- if (lastValidValues !== "unconstrained") {
104585
- lastValidValues = "unconstrained";
104586
- log.info("export-hap: alarm panel named no arm modes — leaving HomeKit’s targets unconstrained", { meta: { subtype: SUBTYPE } });
104587
- }
104588
- return;
104589
- }
104590
- const key = validValues.join(",");
104591
- if (key === lastValidValues) return;
104592
- lastValidValues = key;
104593
- service.getCharacteristic(import_dist.Characteristic.SecuritySystemTargetState).setProps({ validValues: [...validValues] });
104594
- log.info("export-hap: alarm panel target constrained to the modes it accepts", { meta: {
104595
- subtype: SUBTYPE,
104596
- availableModes: [...availableModes],
104597
- validValues
104598
- } });
104599
- };
104600
- const apply = (status, source) => {
104601
- const next = securityUpdatesFor(status, memo);
104602
- if (next.updates.length === 0) {
104603
- log.debug("export-hap: alarm panel status did not match the cap schema — no update", { meta: {
104604
- subtype: SUBTYPE,
104605
- source
104606
- } });
104607
- return;
104608
- }
104609
- memo = next.memo;
104610
- if (next.panel !== null) {
104611
- availableModes = next.panel.availableModes;
104612
- requiresCode = next.panel.requiresCode;
104613
- applyValidValues();
104614
- }
104615
- applyUpdates(service, next.updates);
104616
- };
104617
- try {
104618
- const status = await proxy.alarmPanel?.getStatus({});
104619
- if (status !== void 0 && status !== null) apply(status, "getStatus");
104620
- } catch (err) {
104621
- log.debug("export-hap: alarm panel getStatus hydrate failed (non-fatal)", { meta: { error: errMsg$3(err) } });
104622
- }
104623
- service.getCharacteristic(import_dist.Characteristic.SecuritySystemTargetState).onSet(async (value) => {
104624
- const target = typeof value === "number" ? value : Number(value);
104625
- const disarm = target === import_dist.Characteristic.SecuritySystemTargetState.DISARM;
104626
- const mode = disarm ? null : armModeForTarget(target, availableModes);
104627
- if (requiresCode) {
104628
- log.warn("export-hap: alarm panel requires a PIN — HomeKit cannot supply one, command REFUSED", { meta: {
104629
- subtype: SUBTYPE,
104630
- command: disarm ? "disarm" : "arm",
104631
- mode,
104632
- target
106037
+ function buildMultiTierNegotiation(input) {
106038
+ const { deviceId, logger, negotiator } = input;
106039
+ const log = logger.withTags({ deviceId });
106040
+ let setupResponse = encodeSetupEndpointsRefusal(SetupEndpointsStatus.ERROR);
106041
+ let controlResponse = encodeRtpStreamingControlStatus(null, RtpStreamingStatus.ERROR);
106042
+ /** Sessions we have STARTED, so an End can tell "not started" from "unknown". */
106043
+ const started = /* @__PURE__ */ new Set();
106044
+ async function handleSetupEndpointsWrite(base64) {
106045
+ let sessionId = null;
106046
+ try {
106047
+ const request = decodeSetupEndpointsRequest(base64);
106048
+ sessionId = request.sessionId;
106049
+ const prepareRequest = {
106050
+ sessionID: request.sessionId,
106051
+ sourceAddress: request.controllerAddress,
106052
+ targetAddress: request.controllerAddress,
106053
+ addressVersion: request.addressVersion,
106054
+ video: {
106055
+ port: request.videoPort,
106056
+ srtpCryptoSuite: import_dist.SRTPCryptoSuites.AES_CM_128_HMAC_SHA1_80,
106057
+ srtp_key: request.videoSrtp.key,
106058
+ srtp_salt: request.videoSrtp.salt
106059
+ },
106060
+ audio: {
106061
+ port: request.audioPort,
106062
+ srtpCryptoSuite: import_dist.SRTPCryptoSuites.AES_CM_128_HMAC_SHA1_80,
106063
+ srtp_key: request.audioSrtp.key,
106064
+ srtp_salt: request.audioSrtp.salt
106065
+ }
106066
+ };
106067
+ const prepared = await negotiator.prepare(prepareRequest);
106068
+ setupResponse = encodeSetupEndpointsResponse({
106069
+ sessionId: request.sessionId,
106070
+ addressVersion: request.addressVersion,
106071
+ accessoryAddress: prepared.localAddress,
106072
+ videoPort: prepared.videoPort,
106073
+ audioPort: prepared.audioPort,
106074
+ videoSrtp: {
106075
+ key: request.videoSrtp.key,
106076
+ salt: request.videoSrtp.salt
106077
+ },
106078
+ audioSrtp: {
106079
+ key: request.audioSrtp.key,
106080
+ salt: request.audioSrtp.salt
106081
+ },
106082
+ videoSsrc: prepared.videoSsrc,
106083
+ audioSsrc: prepared.audioSsrc
106084
+ });
106085
+ log.info("hap multi-tier: endpoints prepared", { meta: {
106086
+ sessionId: request.sessionId,
106087
+ controller: `${request.controllerAddress}:${request.videoPort}/${request.audioPort}`,
106088
+ accessory: `${prepared.localAddress}:${prepared.videoPort}/${prepared.audioPort}`,
106089
+ videoSsrc: prepared.videoSsrc,
106090
+ audioSsrc: prepared.audioSsrc
104633
106091
  } });
104634
- throw new import_dist.HapStatusError(import_dist.HAPStatus.INSUFFICIENT_AUTHORIZATION);
104635
- }
104636
- if (!disarm && mode === null) {
104637
- log.warn("export-hap: alarm panel cannot honour this HomeKit arm target — REFUSED", { meta: {
104638
- subtype: SUBTYPE,
104639
- target,
104640
- availableModes: [...availableModes]
106092
+ } catch (error) {
106093
+ setupResponse = encodeSetupEndpointsRefusal(SetupEndpointsStatus.ERROR);
106094
+ log.warn("hap multi-tier: setup endpoints refused", { meta: {
106095
+ sessionId,
106096
+ error: errorMessage(error)
104641
106097
  } });
104642
- throw new import_dist.HapStatusError(import_dist.HAPStatus.INVALID_VALUE_IN_REQUEST);
104643
106098
  }
106099
+ }
106100
+ async function handleStreamingControlWrite(base64) {
106101
+ let sessionId = null;
104644
106102
  try {
104645
- if (mode === null) await proxy.alarmPanel?.disarm({});
104646
- else await proxy.alarmPanel?.arm({ mode });
104647
- } catch (err) {
104648
- log.warn("export-hap: alarm panel command failed", { meta: {
104649
- subtype: SUBTYPE,
104650
- command: mode === null ? "disarm" : "arm",
104651
- mode,
104652
- error: errMsg$3(err)
106103
+ const command = decodeRtpStreamingControl(base64);
106104
+ sessionId = command.sessionId;
106105
+ if (!negotiator.has(command.sessionId)) {
106106
+ controlResponse = encodeRtpStreamingControlStatus(command.sessionId, RtpStreamingStatus.UNKNOWN_SESSION_IDENTIFIER);
106107
+ log.warn("hap multi-tier: control write for a session that was never prepared", { meta: {
106108
+ sessionId: command.sessionId,
106109
+ command: command.command
106110
+ } });
106111
+ return;
106112
+ }
106113
+ if (command.command === RtpStreamingCommand.END) {
106114
+ if (!started.has(command.sessionId)) {
106115
+ controlResponse = encodeRtpStreamingControlStatus(command.sessionId, RtpStreamingStatus.NO_SUCH_STREAM);
106116
+ return;
106117
+ }
106118
+ const stop = {
106119
+ sessionID: command.sessionId,
106120
+ type: import_dist.StreamRequestTypes.STOP
106121
+ };
106122
+ await negotiator.control(stop);
106123
+ started.delete(command.sessionId);
106124
+ controlResponse = encodeRtpStreamingControlStatus(command.sessionId, RtpStreamingStatus.SUCCESS);
106125
+ log.info("hap multi-tier: stream ended", { meta: { sessionId: command.sessionId } });
106126
+ return;
106127
+ }
106128
+ const tier = input.tiers.find((t) => t.identifier === command.videoTier);
106129
+ if (tier === void 0) {
106130
+ controlResponse = encodeRtpStreamingControlStatus(command.sessionId, RtpStreamingStatus.NO_SUCH_STREAM);
106131
+ log.warn("hap multi-tier: start named a tier we never published", { meta: {
106132
+ sessionId: command.sessionId,
106133
+ requested: command.videoTier,
106134
+ published: input.tiers.map((t) => t.identifier)
106135
+ } });
106136
+ return;
106137
+ }
106138
+ log.info("hap multi-tier: start ssrc comparison", { meta: {
106139
+ sessionId: command.sessionId,
106140
+ controllerVideoSsrc: command.videoSsrc ?? null,
106141
+ controllerAudioSsrc: command.audioSsrc ?? null,
106142
+ note: "accessory keeps the ssrcs it advertised at setup"
104653
106143
  } });
104654
- throw new import_dist.HapStatusError(import_dist.HAPStatus.SERVICE_COMMUNICATION_FAILURE);
104655
- }
104656
- });
104657
- const unsubscribe = proxy.state.alarmPanel?.subscribe((value) => {
104658
- if (value === void 0 || value === null) return;
104659
- apply(value, "slice");
104660
- }) ?? null;
104661
- return { async dispose() {
104662
- try {
104663
- unsubscribe?.();
104664
- } catch {}
104665
- } };
104666
- }
104667
- function errMsg$3(err) {
104668
- return err instanceof Error ? err.message : String(err);
104669
- }
104670
- //#endregion
104671
- //#region src/mappers/builders/generic/sensor-service.ts
104672
- async function buildSensorService(input) {
104673
- const { bctx, spec, name, subtype } = input;
104674
- const { ctx, accessory, proxy, numericDeviceId } = bctx;
104675
- const log = ctx.logger.withTags({ deviceId: numericDeviceId });
104676
- const label = hapServiceName([name], spec.label);
104677
- const service = spec.addService(accessory, label, subtype);
104678
- applyServiceLabel(service, label);
104679
- const apply = (status, source) => {
104680
- const updates = spec.read(status);
104681
- if (updates.length === 0) {
104682
- log.debug("export-hap: sensor status did not match the cap schema — no update", { meta: {
104683
- subtype,
104684
- source
106144
+ const start = {
106145
+ sessionID: command.sessionId,
106146
+ type: import_dist.StreamRequestTypes.START,
106147
+ video: {
106148
+ codec: import_dist.VideoCodecType.H264,
106149
+ profile: import_dist.H264Profile.HIGH,
106150
+ level: import_dist.H264Level.LEVEL4_0,
106151
+ packetizationMode: import_dist.VideoCodecPacketizationMode.NON_INTERLEAVED,
106152
+ width: tier.width,
106153
+ height: tier.height,
106154
+ fps: tier.frameRate,
106155
+ pt: VIDEO_PAYLOAD_TYPE,
106156
+ ssrc: command.videoSsrc ?? 0,
106157
+ max_bit_rate: tier.targetAverageBitrateKbps,
106158
+ rtcp_interval: RTCP_INTERVAL_SECONDS,
106159
+ mtu: DEFAULT_MTU
106160
+ },
106161
+ audio: {
106162
+ codec: import_dist.AudioStreamingCodecType.OPUS,
106163
+ channel: 1,
106164
+ bit_rate: 24,
106165
+ sample_rate: import_dist.AudioStreamingSamplerate.KHZ_24,
106166
+ packet_time: 20,
106167
+ pt: AUDIO_PAYLOAD_TYPE,
106168
+ ssrc: command.audioSsrc ?? 0,
106169
+ max_bit_rate: 24,
106170
+ rtcp_interval: RTCP_INTERVAL_SECONDS,
106171
+ comfort_pt: 13,
106172
+ comfortNoiseEnabled: false
106173
+ }
106174
+ };
106175
+ await negotiator.control(start);
106176
+ started.add(command.sessionId);
106177
+ controlResponse = encodeRtpStreamingControlStatus(command.sessionId, RtpStreamingStatus.SUCCESS);
106178
+ log.info("hap multi-tier: stream started", { meta: {
106179
+ sessionId: command.sessionId,
106180
+ tier: tier.identifier,
106181
+ geometry: `${tier.width}x${tier.height}@${tier.frameRate}`,
106182
+ targetKbps: tier.targetAverageBitrateKbps,
106183
+ codec: input.codec
106184
+ } });
106185
+ } catch (error) {
106186
+ controlResponse = encodeRtpStreamingControlStatus(sessionId, RtpStreamingStatus.ERROR);
106187
+ log.warn("hap multi-tier: control write refused", { meta: {
106188
+ sessionId,
106189
+ error: errorMessage(error)
104685
106190
  } });
104686
- return;
104687
106191
  }
104688
- applyUpdates(service, updates);
104689
- };
104690
- try {
104691
- const status = await spec.getStatus(proxy);
104692
- if (status !== void 0 && status !== null) apply(status, "getStatus");
104693
- } catch (err) {
104694
- log.debug("export-hap: sensor getStatus hydrate failed (non-fatal)", { meta: {
104695
- subtype,
104696
- error: errMsg$2(err)
104697
- } });
104698
106192
  }
104699
- const unsubscribe = spec.subscribe(proxy, (value) => {
104700
- if (value === void 0 || value === null) return;
104701
- apply(value, "slice");
104702
- });
104703
- return { async dispose() {
104704
- try {
104705
- unsubscribe?.();
104706
- } catch {}
104707
- } };
104708
- }
104709
- function errMsg$2(err) {
104710
- return err instanceof Error ? err.message : String(err);
106193
+ return {
106194
+ handleSetupEndpointsWrite,
106195
+ readSetupEndpoints: () => setupResponse,
106196
+ handleStreamingControlWrite,
106197
+ readStreamingControl: () => controlResponse
106198
+ };
104711
106199
  }
104712
106200
  //#endregion
104713
- //#region src/mappers/builders/generic/cap-service-table.ts
106201
+ //#region src/mappers/camera-accessory.ts
104714
106202
  /**
104715
- * The capability→HomeKit-service TABLE for non-camera devices.
104716
- *
104717
- * This is the whole coverage decision for the generic export path, and it is
104718
- * deliberately a table rather than a `switch` on `DeviceType`. The doctrine is
104719
- * the one the Home Assistant exporter already writes down: *coverage is decided
104720
- * by a device's capabilities, not its type — restricting the picker by type is
104721
- * what confined the previous exporter to cameras.* A `DeviceType` here is only
104722
- * ever a UI filter (`HAP_EXPORTABLE_DEVICE_TYPES`) or an icon
104723
- * (`ACCESSORY_CATEGORY_BY_TYPE`); it never decides whether something exports.
104724
- *
104725
- * The shape mirrors `CAP_ENTITY_MAP` in
104726
- * `packages/addon-provider-homeassistant/src/ha-export/entity-catalog.ts` so
104727
- * the two can be unified later — one row per capability, keyed by the cap name
104728
- * exactly as it appears in `proxy.binding.entries[].capName`.
104729
- *
104730
- * ## Reading a status
104731
- *
104732
- * Every row parses the cap's OWN Zod status schema rather than duck-typing the
104733
- * payload, and it `.pick()`s only the fields it reads: a provider that omits a
104734
- * timestamp must not silence a sensor, and a provider that sends the wrong
104735
- * shape must not write a garbage characteristic. A parse that fails yields no
104736
- * updates, and the builder that owns the service logs the drop — silence reads
104737
- * as "the sensor never changed".
104738
- *
104739
- * ## Tier
104740
- *
104741
- * Tier A, plus `alarm-panel`: the caps whose HomeKit service exists today and
104742
- * needs no feature-dependent shape. `cover` still needs `cover-positionable`
104743
- * and `climate-control` the dual-setpoint split; those are a separate task,
104744
- * and adding a row here must never mean adding a service whose semantics this
104745
- * file cannot fully honour.
106203
+ * Camera accessory orchestrator — given a camstack deviceId, builds one
106204
+ * HomeKit Accessory representing the camera (or doorbell) plus a
106205
+ * separate Accessory per child device (siren, floodlight, …).
104746
106206
  *
104747
- * `alarm-panel` earned its row by answering the two questions that kept it
104748
- * out: a target the panel would refuse is never offered (`availableModes` →
104749
- * `validValues`, `security-target.ts`) and an arm a PIN-gated panel cannot
104750
- * accept is refused out loud rather than attempted (`security-system.ts`).
106207
+ * Flow:
106208
+ * 1. Resolve a typed `DeviceProxy` via `ctx.fetchDevice(deviceId)`.
106209
+ * 2. Inspect `proxy.binding.entries` (or fall back to `getDevice`
106210
+ * features) to decide whether the device is a doorbell vs a
106211
+ * regular camera. Doorbells get `Categories.VIDEO_DOORBELL` +
106212
+ * `DoorbellController`; otherwise `Categories.IP_CAMERA` +
106213
+ * `CameraController`.
106214
+ * 3. Build the streaming delegate via the `camera-streams` builder.
106215
+ * 4. `configureController(controller)` to attach the streaming
106216
+ * services to the Accessory.
106217
+ * 5. Run each feature builder gated on cap presence (motion sensor,
106218
+ * intercom, PTZ, doorbell).
106219
+ * 6. Enumerate children via `proxy.deviceManager.getChildren({...})`
106220
+ * and build one Accessory per child (see `child-accessory.ts`).
106221
+ * 7. Return a `Mapper` whose `accessories[]` lists every accessory
106222
+ * this device produced so the bridge can register them all and
106223
+ * remove them all on unexpose.
104751
106224
  */
104752
- /** HomeKit's floor for `CurrentAmbientLightLevel`; 0 lux is out of range. */
104753
- var MIN_LUX = 1e-4;
104754
- var SENSOR_SPECS = {
104755
- contact: {
104756
- label: "Contact",
104757
- addService: (accessory, name, subtype) => accessory.addService(import_dist.Service.ContactSensor, name, subtype),
104758
- getStatus: (proxy) => proxy.contact?.getStatus({}),
104759
- subscribe: (proxy, onValue) => proxy.state.contact?.subscribe(onValue) ?? null,
104760
- read: reader(ContactStatusSchema.pick({ entryOpen: true }), (status) => [{
104761
- characteristic: import_dist.Characteristic.ContactSensorState,
104762
- value: status.entryOpen ? import_dist.Characteristic.ContactSensorState.CONTACT_NOT_DETECTED : import_dist.Characteristic.ContactSensorState.CONTACT_DETECTED
104763
- }])
104764
- },
104765
- motion: {
104766
- label: "Motion",
104767
- addService: (accessory, name, subtype) => accessory.addService(import_dist.Service.MotionSensor, name, subtype),
104768
- getStatus: (proxy) => proxy.motion?.getStatus({}),
104769
- subscribe: (proxy, onValue) => proxy.state.motion?.subscribe(onValue) ?? null,
104770
- read: reader(MotionStatusSchema.pick({ detected: true }), (status) => [{
104771
- characteristic: import_dist.Characteristic.MotionDetected,
104772
- value: status.detected
104773
- }])
104774
- },
104775
- presence: {
104776
- label: "Presence",
104777
- addService: (accessory, name, subtype) => accessory.addService(import_dist.Service.OccupancySensor, name, subtype),
104778
- getStatus: (proxy) => proxy.presence?.getStatus({}),
104779
- subscribe: (proxy, onValue) => proxy.state.presence?.subscribe(onValue) ?? null,
104780
- read: reader(PresenceStatusSchema.pick({ state: true }), (status) => [{
104781
- characteristic: import_dist.Characteristic.OccupancyDetected,
104782
- value: status.state === "home" ? import_dist.Characteristic.OccupancyDetected.OCCUPANCY_DETECTED : import_dist.Characteristic.OccupancyDetected.OCCUPANCY_NOT_DETECTED
104783
- }])
104784
- },
104785
- smoke: {
104786
- label: "Smoke",
104787
- addService: (accessory, name, subtype) => accessory.addService(import_dist.Service.SmokeSensor, name, subtype),
104788
- getStatus: (proxy) => proxy.smoke?.getStatus({}),
104789
- subscribe: (proxy, onValue) => proxy.state.smoke?.subscribe(onValue) ?? null,
104790
- read: reader(SmokeStatusSchema.pick({ detected: true }), (status) => [{
104791
- characteristic: import_dist.Characteristic.SmokeDetected,
104792
- value: status.detected ? import_dist.Characteristic.SmokeDetected.SMOKE_DETECTED : import_dist.Characteristic.SmokeDetected.SMOKE_NOT_DETECTED
104793
- }])
104794
- },
104795
- "carbon-monoxide": {
104796
- label: "Carbon monoxide",
104797
- addService: (accessory, name, subtype) => accessory.addService(import_dist.Service.CarbonMonoxideSensor, name, subtype),
104798
- getStatus: (proxy) => proxy.carbonMonoxide?.getStatus({}),
104799
- subscribe: (proxy, onValue) => proxy.state.carbonMonoxide?.subscribe(onValue) ?? null,
104800
- read: reader(CarbonMonoxideStatusSchema.pick({ detected: true }), (status) => [{
104801
- characteristic: import_dist.Characteristic.CarbonMonoxideDetected,
104802
- value: status.detected ? import_dist.Characteristic.CarbonMonoxideDetected.CO_LEVELS_ABNORMAL : import_dist.Characteristic.CarbonMonoxideDetected.CO_LEVELS_NORMAL
104803
- }])
104804
- },
104805
- flood: {
104806
- label: "Leak",
104807
- addService: (accessory, name, subtype) => accessory.addService(import_dist.Service.LeakSensor, name, subtype),
104808
- getStatus: (proxy) => proxy.flood?.getStatus({}),
104809
- subscribe: (proxy, onValue) => proxy.state.flood?.subscribe(onValue) ?? null,
104810
- read: reader(FloodStatusSchema.pick({ flooded: true }), (status) => [{
104811
- characteristic: import_dist.Characteristic.LeakDetected,
104812
- value: status.flooded ? import_dist.Characteristic.LeakDetected.LEAK_DETECTED : import_dist.Characteristic.LeakDetected.LEAK_NOT_DETECTED
104813
- }])
104814
- },
104815
- "temperature-sensor": {
104816
- label: "Temperature",
104817
- addService: (accessory, name, subtype) => accessory.addService(import_dist.Service.TemperatureSensor, name, subtype),
104818
- getStatus: (proxy) => proxy.temperatureSensor?.getStatus({}),
104819
- subscribe: (proxy, onValue) => proxy.state.temperatureSensor?.subscribe(onValue) ?? null,
104820
- read: reader(TemperatureSensorStatusSchema.pick({ celsius: true }), (status) => [{
104821
- characteristic: import_dist.Characteristic.CurrentTemperature,
104822
- value: status.celsius
104823
- }])
104824
- },
104825
- "humidity-sensor": {
104826
- label: "Humidity",
104827
- addService: (accessory, name, subtype) => accessory.addService(import_dist.Service.HumiditySensor, name, subtype),
104828
- getStatus: (proxy) => proxy.humiditySensor?.getStatus({}),
104829
- subscribe: (proxy, onValue) => proxy.state.humiditySensor?.subscribe(onValue) ?? null,
104830
- read: reader(HumiditySensorStatusSchema.pick({ percent: true }), (status) => [{
104831
- characteristic: import_dist.Characteristic.CurrentRelativeHumidity,
104832
- value: status.percent
104833
- }])
104834
- },
104835
- "ambient-light-sensor": {
104836
- label: "Light level",
104837
- addService: (accessory, name, subtype) => accessory.addService(import_dist.Service.LightSensor, name, subtype),
104838
- getStatus: (proxy) => proxy.ambientLightSensor?.getStatus({}),
104839
- subscribe: (proxy, onValue) => proxy.state.ambientLightSensor?.subscribe(onValue) ?? null,
104840
- read: reader(AmbientLightSensorStatusSchema.pick({ lux: true }), (status) => [{
104841
- characteristic: import_dist.Characteristic.CurrentAmbientLightLevel,
104842
- value: Math.max(MIN_LUX, status.lux)
104843
- }])
106225
+ async function buildCameraAccessory(input) {
106226
+ const { ctx, deviceId, displayName, options } = input;
106227
+ const numericId = Number.parseInt(deviceId, 10);
106228
+ if (!Number.isFinite(numericId)) throw new Error(`export-hap: cannot map device '${deviceId}' — id is not numeric`);
106229
+ const proxy = await ctx.fetchDevice(numericId);
106230
+ const capNames = new Set(proxy.binding?.entries.map((e) => e.capName) ?? []);
106231
+ const isDoorbell = capNames.has("doorbell");
106232
+ const category = isDoorbell ? import_dist.Categories.VIDEO_DOORBELL : import_dist.Categories.IP_CAMERA;
106233
+ const accessory = new import_dist.Accessory(displayName, accessoryUuidFor("camera", numericId));
106234
+ accessory.category = category;
106235
+ await populateAccessoryInfo(accessory, proxy, displayName, "Camera");
106236
+ const bctx = {
106237
+ ctx,
106238
+ accessory,
106239
+ proxy,
106240
+ numericDeviceId: numericId,
106241
+ displayName,
106242
+ options
106243
+ };
106244
+ const advertisedVideo = await probeAdvertisedVideoProfile(bctx);
106245
+ const streams = buildCameraStreamingDelegate(bctx, advertisedVideo);
106246
+ const handles = [];
106247
+ if (capNames.has("intercom")) handles.push(await buildIntercom({
106248
+ bctx,
106249
+ streamingOptions: streams.streamingOptions
106250
+ }));
106251
+ const recordingEnabled = options.hapDeviceSettings.hksvRecording === true;
106252
+ let recordingAudioActive = true;
106253
+ const recording = recordingEnabled ? await buildHksvRecording({
106254
+ bctx,
106255
+ fpsByProfile: advertisedVideo.fpsByProfile,
106256
+ isAudioActive: () => recordingAudioActive
106257
+ }) : null;
106258
+ const controller = new (isDoorbell ? import_dist.DoorbellController : import_dist.CameraController)({
106259
+ delegate: streams.delegate,
106260
+ streamingOptions: streams.streamingOptions,
106261
+ cameraStreamCount: 2,
106262
+ ...recording === null ? {} : { recording },
106263
+ ...recording === null || !capNames.has("motion-detection") ? {} : { sensors: { motion: true } }
106264
+ });
106265
+ accessory.configureController(controller);
106266
+ if (options.hapDeviceSettings.multiTierService !== false) {
106267
+ const sensor = advertisedVideo.sensor;
106268
+ const services = buildMultiTierServices({
106269
+ deviceId: numericId,
106270
+ logger: ctx.logger,
106271
+ width: sensor.width,
106272
+ height: sensor.height,
106273
+ codec: sensor.codec,
106274
+ audioCaptureRateHz: 16e3,
106275
+ negotiation: buildMultiTierNegotiation({
106276
+ deviceId: numericId,
106277
+ logger: ctx.logger,
106278
+ negotiator: streams.negotiator,
106279
+ tiers: tiersForSensor(sensor.width, sensor.height),
106280
+ codec: sensor.codec
106281
+ })
106282
+ });
106283
+ accessory.addService(services.capabilities);
106284
+ accessory.addService(services.globalOperatingMode);
106285
+ accessory.addService(services.streamManagement);
106286
+ ctx.logger.info("hap multi-tier: services declared on this accessory", {
106287
+ tags: { deviceId: numericId },
106288
+ meta: {
106289
+ serviceUuid: MULTI_TIER_RTP_SERVICE_UUID,
106290
+ codec: sensor.codec === TierVideoCodec.H265 ? "h265" : "h264",
106291
+ sensor: `${sensor.width}x${sensor.height}`
106292
+ }
106293
+ });
104844
106294
  }
104845
- };
104846
- /**
104847
- * The `battery` row reads like a sensor but writes three characteristics from
104848
- * one status, and the camera path publishes the SAME service from the same cap.
104849
- * The derivation therefore lives once, in `battery.ts`, and both callers read
104850
- * it from there.
104851
- */
104852
- var BATTERY_SPEC = {
104853
- label: "Battery",
104854
- addService: (accessory, name, subtype) => accessory.addService(import_dist.Service.Battery, name, subtype),
104855
- getStatus: (proxy) => proxy.battery?.getStatus({}),
104856
- subscribe: (proxy, onValue) => proxy.state.battery?.subscribe(onValue) ?? null,
104857
- read: batteryCharacteristicUpdates
104858
- };
104859
- function sensorRow(capName, spec) {
106295
+ if (recording !== null) {
106296
+ handles.push({ dispose: () => recording.dispose() });
106297
+ const audioCharacteristic = (controller.recordingManagement?.operatingModeService)?.getCharacteristic(import_dist.Characteristic.RecordingAudioActive);
106298
+ if (audioCharacteristic) {
106299
+ recordingAudioActive = audioCharacteristic.value !== 0 && audioCharacteristic.value !== false;
106300
+ audioCharacteristic.on("change", ({ newValue }) => {
106301
+ recordingAudioActive = newValue !== 0 && newValue !== false;
106302
+ });
106303
+ }
106304
+ }
106305
+ if (capNames.has("motion-detection")) handles.push(await buildMotionSensor(bctx, recording === null ? null : controller.motionService ?? null));
106306
+ if (isDoorbell && controller instanceof import_dist.DoorbellController) handles.push(await buildDoorbell({
106307
+ bctx,
106308
+ controller
106309
+ }));
106310
+ if (capNames.has("ptz") || capNames.has("ptz-autotrack")) handles.push(await buildPtz(bctx));
106311
+ if (capNames.has("battery")) handles.push(await buildBattery(bctx));
106312
+ if (capNames.has("privacy-mask")) handles.push(await buildPrivacySwitch(bctx));
106313
+ handles.push(await buildChildServicesFor({ parentCtx: bctx }));
104860
106314
  return {
104861
- caps: [capName],
104862
- label: spec.label,
104863
- build: ({ bctx, name }) => buildSensorService({
104864
- bctx,
104865
- spec,
104866
- name,
104867
- subtype: capName
104868
- })
106315
+ accessory,
106316
+ accessories: [accessory],
106317
+ async dispose() {
106318
+ const log = ctx.logger.withTags({ deviceId: numericId });
106319
+ for (const h of handles) try {
106320
+ await h.dispose();
106321
+ } catch (err) {
106322
+ log.debug("export-hap: builder dispose failed (continuing)", { meta: { error: errMsg$2(err) } });
106323
+ }
106324
+ try {
106325
+ await streams.dispose();
106326
+ } catch (err) {
106327
+ log.debug("export-hap: streams dispose failed", { meta: { error: errMsg$2(err) } });
106328
+ }
106329
+ }
104869
106330
  };
104870
106331
  }
104871
- /**
104872
- * THE table. Order matters only for one thing: the first row that matches a
104873
- * device decides the accessory's category when the device's own type does not.
104874
- */
104875
- var HAP_CAP_SERVICES = [
104876
- {
104877
- caps: ["switch", "brightness"],
104878
- label: "Power",
104879
- build: ({ bctx, name, deviceType }) => buildChildSwitch({
104880
- ...bctx,
104881
- displayName: name
104882
- }, "switch", deviceType)
104883
- },
104884
- {
104885
- caps: ["lock-control"],
104886
- label: "Lock",
104887
- build: ({ bctx, name }) => buildLockMechanism({
104888
- ...bctx,
104889
- displayName: name
104890
- })
104891
- },
104892
- {
104893
- caps: ["alarm-panel"],
104894
- label: "Alarm",
104895
- build: ({ bctx, name }) => buildSecuritySystem({
104896
- ...bctx,
104897
- displayName: name
104898
- })
104899
- },
104900
- {
104901
- caps: ["battery"],
104902
- label: BATTERY_SPEC.label,
104903
- build: ({ bctx, name }) => buildSensorService({
104904
- bctx,
104905
- spec: BATTERY_SPEC,
104906
- name,
104907
- subtype: "battery"
104908
- })
104909
- },
104910
- ...Object.entries(SENSOR_SPECS).map(([capName, spec]) => sensorRow(capName, spec))
104911
- ];
104912
- /**
104913
- * The rows a device's bound capabilities select, in table order.
104914
- *
104915
- * A row matches when ANY of its caps is bound: a lamp with `switch` but no
104916
- * `brightness` is still a Lightbulb-shaped row, and the builder degrades on its
104917
- * own.
104918
- */
104919
- function rowsForCaps(capNames) {
104920
- return HAP_CAP_SERVICES.filter((row) => row.caps.some((cap) => capNames.has(cap)));
104921
- }
104922
- /**
104923
- * The HomeKit accessory category per camstack device type — the icon iOS Home
104924
- * draws and nothing else. Absent = `OTHER`, which is a valid accessory that
104925
- * simply gets the generic tile.
104926
- *
104927
- * This is NOT a coverage decision. `HAP_EXPORTABLE_DEVICE_TYPES` (in
104928
- * `mappers/index.ts`) filters the picker; whether a device exports anything is
104929
- * decided by `rowsForCaps`.
104930
- */
104931
- var ACCESSORY_CATEGORY_BY_TYPE = {
104932
- [DeviceType.Switch]: import_dist.Categories.SWITCH,
104933
- [DeviceType.Light]: import_dist.Categories.LIGHTBULB,
104934
- [DeviceType.Siren]: import_dist.Categories.SWITCH,
104935
- [DeviceType.Lock]: import_dist.Categories.DOOR_LOCK,
104936
- [DeviceType.Sensor]: import_dist.Categories.SENSOR,
104937
- [DeviceType.Presence]: import_dist.Categories.SENSOR,
104938
- [DeviceType.AlarmPanel]: import_dist.Categories.SECURITY_SYSTEM
104939
- };
104940
- function accessoryCategoryFor(deviceType) {
104941
- return ACCESSORY_CATEGORY_BY_TYPE[deviceType] ?? import_dist.Categories.OTHER;
106332
+ function errMsg$2(err) {
106333
+ return err instanceof Error ? err.message : String(err);
104942
106334
  }
104943
106335
  //#endregion
104944
106336
  //#region src/mappers/generic-accessory.ts
@@ -105042,118 +106434,6 @@ function getMapperFactory(kind) {
105042
106434
  return factory;
105043
106435
  }
105044
106436
  //#endregion
105045
- //#region src/mappers/builders/stream-hwaccel-memo.ts
105046
- /**
105047
- * The two bounded memos HomeKit's decode path owns.
105048
- *
105049
- * ## Why they exist
105050
- *
105051
- * Everything D67 was actually about — one argv builder, one set of constants,
105052
- * one hwaccel authority — HomeKit already had. What it did NOT have were the
105053
- * two things the broker gained alongside them:
105054
- *
105055
- * 1. **A memo.** `probeDecoderHwaccel` issued a cross-process
105056
- * `decoder.getInfo` per SESSION. iOS starts sessions in bursts — one on
105057
- * record was started three times in 16 s — and every one of those paid a
105058
- * cap call on a hub whose main thread is the scarce resource.
105059
- * 2. **Failure feedback.** When HomeKit's hardware child died at init and
105060
- * `shouldRetryInSoftware` saved the session, HomeKit told nobody. The next
105061
- * session re-picked the same corpse and paid the same two-second death.
105062
- * `EgressTranscodeManager` fixed exactly this for its own children
105063
- * (26a522cd5) by reporting the dead backend into the broker's 60 s memo.
105064
- *
105065
- * ## Both ride `HwAccelCache`, deliberately
105066
- *
105067
- * `createHwAccelCache` from `@camstack/types` is the primitive the broker's own
105068
- * `egressHwAccelCache` is built from, and the discipline it encodes is the
105069
- * point: **caller-owned, never a module global** — a module global would
105070
- * outlive an addon respawn and survive an operator changing the decoder
105071
- * backend. Same TTL as the broker's, so "has hardware come back yet" cannot
105072
- * answer differently depending on which consumer asked.
105073
- *
105074
- * ## The cross-process gap, stated honestly
105075
- *
105076
- * These memos are scoped to the `export-hap` PROCESS. When HomeKit's vaapi
105077
- * child dies, the broker's next child still pays its own two-second death, and
105078
- * vice versa — because addons may never import each other and there is no
105079
- * capability for "this backend is dead on this node right now". Closing that
105080
- * would need a new cap surface, which Phase 0 explicitly does not take. What is
105081
- * closed here is HomeKit's own repetition of the cost, across cameras and
105082
- * across sessions.
105083
- */
105084
- /**
105085
- * The window both memos answer for.
105086
- *
105087
- * 60 s, the same as `stream-broker-manager`'s `egressHwAccelCache`. Long enough
105088
- * that a burst of session restarts pays one read; short enough that an operator
105089
- * who changes the decoder backend, or a host whose accelerator recovers, is
105090
- * obeyed on the next session rather than after an addon respawn.
105091
- */
105092
- var HAP_DECODE_MEMO_TTL_MS = 6e4;
105093
- /**
105094
- * Separator inside the encoded reading. A control character, because a backend
105095
- * name is `[a-z0-9]+` and the decoder's non-backend choices are `auto` /
105096
- * `none` / `''`, none of which can contain one — so the split is total.
105097
- */
105098
- var READING_SEPARATOR = "";
105099
- /**
105100
- * Marks a `null` FIELD, distinct from an EMPTY one.
105101
- *
105102
- * `probedBestHwaccel: ''` means the decoder answered and has never probed
105103
- * (=> `not-probed`); `null` means the field was absent altogether. Encoding
105104
- * both as `''` would lose a distinction `selectHwDecode` acts on.
105105
- */
105106
- var NULL_FIELD = "\0";
105107
- function encodeField(value) {
105108
- return value === null ? NULL_FIELD : value;
105109
- }
105110
- function decodeField(value) {
105111
- return value === NULL_FIELD ? null : value;
105112
- }
105113
- /**
105114
- * A reading as ONE `string | null`, which is what {@link HwAccelCache} stores.
105115
- *
105116
- * The cache's three states are exactly the three a memoised reading needs:
105117
- * `undefined` (never asked, or expired), `null` (asked, and the decoder could
105118
- * not be reached), and a value. Encoding into the one cache rather than
105119
- * splitting across two is what keeps those three from skewing — two caches
105120
- * written together can still be READ across an expiry boundary.
105121
- */
105122
- function encodeDecoderReading(reading) {
105123
- if (reading === null) return null;
105124
- return `${encodeField(reading.hwaccel)}${READING_SEPARATOR}${encodeField(reading.probedBestHwaccel)}`;
105125
- }
105126
- function decodeDecoderReading(value) {
105127
- if (value === null) return null;
105128
- const [hwaccel = NULL_FIELD, probed = NULL_FIELD] = value.split(READING_SEPARATOR);
105129
- return {
105130
- hwaccel: decodeField(hwaccel),
105131
- probedBestHwaccel: decodeField(probed)
105132
- };
105133
- }
105134
- function createDecoderReadingMemo(options) {
105135
- const cache = createHwAccelCache(options);
105136
- return {
105137
- read() {
105138
- const cached = cache.read();
105139
- return cached === void 0 ? void 0 : decodeDecoderReading(cached);
105140
- },
105141
- write(reading) {
105142
- cache.write(encodeDecoderReading(reading));
105143
- }
105144
- };
105145
- }
105146
- function createHapDecodeMemos(now) {
105147
- const options = {
105148
- ttlMs: HAP_DECODE_MEMO_TTL_MS,
105149
- ...now ? { now } : {}
105150
- };
105151
- return {
105152
- reading: createDecoderReadingMemo(options),
105153
- failedBackend: createHwAccelCache(options)
105154
- };
105155
- }
105156
- //#endregion
105157
106437
  //#region src/reconcile/sync-state.ts
105158
106438
  function syncStateFromJson(json) {
105159
106439
  const map = /* @__PURE__ */ new Map();
@@ -105339,6 +106619,16 @@ var ExportHapAddon = class extends BaseAddon {
105339
106619
  */
105340
106620
  decodeMemos = createHapDecodeMemos();
105341
106621
  /**
106622
+ * Where HomeKit's own clips are kept.
106623
+ *
106624
+ * ONE per addon: HomeKit Secure Video is a SINK — HAP has no read-back, so a
106625
+ * clip is ours only if we kept a copy of what we SENT — and the copy has to
106626
+ * be pruned against bounds that span every camera, which a per-camera store
106627
+ * could not see. Created at boot rather than lazily so the directory's
106628
+ * failure to exist is one line at boot and not a surprise mid-recording.
106629
+ */
106630
+ hksvClipStore = null;
106631
+ /**
105342
106632
  * THE bridge. Lazily created and published the first time a non-camera
105343
106633
  * accessory needs it; never torn down while the addon runs, because an
105344
106634
  * unpublish would drop a pairing the operator already entered a code for.
@@ -105364,6 +106654,14 @@ var ExportHapAddon = class extends BaseAddon {
105364
106654
  this.lastError = errMsg(err);
105365
106655
  this.ctx.logger.error("export-hap: HAP storage init failed", { meta: { error: this.lastError } });
105366
106656
  }
106657
+ this.hksvClipStore = new HksvClipStore({
106658
+ logger: this.ctx.logger.child("hksv-clips"),
106659
+ rootDir: node_path.join(this.ctx.dataDir, CLIP_DIR_NAME),
106660
+ mintThumbnail: createClipThumbnailMinter({ runner: createFfmpegClipThumbnailRunner({
106661
+ ffmpegBinaryPath: "ffmpeg",
106662
+ spawnFn: node_child_process.spawn
106663
+ }) })
106664
+ });
105367
106665
  const validKinds = new Set(SUPPORTED_MAPPER_KINDS);
105368
106666
  const cleaned = this.config.exposed.filter((entry) => {
105369
106667
  if (validKinds.has(entry.mapperKind)) return true;
@@ -105556,6 +106854,7 @@ var ExportHapAddon = class extends BaseAddon {
105556
106854
  options: {
105557
106855
  ptzPulseMs: this.config.ptzPulseMs,
105558
106856
  decodeMemos: this.decodeMemos,
106857
+ hksvClipStore: this.hksvClipStore,
105559
106858
  hapDeviceSettings: {
105560
106859
  streamPreference: entrySettings.streamPreference ?? "auto",
105561
106860
  hksvRecording: resolveHksvRecording(entrySettings),