@camstack/addon-export-hap 1.2.127 → 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.
@@ -1,9 +1,12 @@
1
1
  import { createRequire } from "node:module";
2
+ import { spawn } from "node:child_process";
2
3
  import { createHash, randomBytes, randomUUID } from "node:crypto";
3
4
  import * as path from "node:path";
4
- import { readFileSync } from "node:fs";
5
- import { spawn } from "node:child_process";
5
+ import { join } from "node:path";
6
+ import { createWriteStream, readFileSync } from "node:fs";
6
7
  import * as fs from "node:fs/promises";
8
+ import { mkdir, readFile, readdir, rm, stat, writeFile } from "node:fs/promises";
9
+ import { once } from "node:events";
7
10
  import { createSocket } from "node:dgram";
8
11
  import { networkInterfaces } from "node:os";
9
12
  //#region \0rolldown/runtime.js
@@ -35,43 +38,6 @@ var __copyProps = (to, from, except, desc) => {
35
38
  var __toCommonJS = (mod) => __hasOwnProp.call(mod, "module.exports") ? mod["module.exports"] : __copyProps(__defProp({}, "__esModule", { value: true }), mod);
36
39
  var __require = /* @__PURE__ */ createRequire(import.meta.url);
37
40
  //#endregion
38
- //#region src/exposed-entry.ts
39
- /**
40
- * Carrying an exposed-device entry across a re-expose.
41
- *
42
- * `exposeDevice` rebuilds its entry from scratch — display name, mapper kind,
43
- * timestamp — and then REPLACES the stored one. Anything the rebuilt object
44
- * does not mention is therefore destroyed, and two things it never mentioned
45
- * were the per-camera settings and the capability list.
46
- *
47
- * The visible cost: the operator's "Source stream (HomeKit)" selector writes
48
- * `low`, the addon logs `streamPreference changed — refreshing accessory
49
- * {from=auto to=low}`, and the accessory that comes back derives its
50
- * advertisement from DEFAULTS — `streamPreference=auto` — because the settings
51
- * were dropped between the write and the rebuild. The selector only ever took
52
- * effect after a full addon restart, when the settings were loaded first. A
53
- * second write after the re-expose hid this: the store ended up correct, so
54
- * nothing looked wrong except the stream nobody could explain.
55
- */
56
- /**
57
- * Fill `base` from `existing` for the given keys, letting `base` win wherever
58
- * it actually says something.
59
- *
60
- * That asymmetry is the point: a caller who passes `capabilities` is stating a
61
- * new truth and must not be overruled by the stored copy, while a caller who
62
- * says nothing about `settings` is not asking for them to be erased.
63
- */
64
- function carryForward(base, existing, keys) {
65
- if (existing === void 0) return base;
66
- const out = { ...base };
67
- for (const key of keys) {
68
- if (out[key] !== void 0) continue;
69
- const carried = existing[key];
70
- if (carried !== void 0) out[key] = carried;
71
- }
72
- return out;
73
- }
74
- //#endregion
75
41
  //#region ../types/dist/event-category-BVDXG4tB.mjs
76
42
  var EventCategory = /* @__PURE__ */ function(EventCategory) {
77
43
  EventCategory["SystemBoot"] = "system.boot";
@@ -5433,7 +5399,7 @@ var ZodIssueCode = {
5433
5399
  var ZodFirstPartyTypeKind;
5434
5400
  ZodFirstPartyTypeKind || (ZodFirstPartyTypeKind = {});
5435
5401
  //#endregion
5436
- //#region ../types/dist/sleep-GU_us3DG.mjs
5402
+ //#region ../types/dist/sleep-CiR4z7r9.mjs
5437
5403
  /**
5438
5404
  * The audio chunk plane's byte format, and the ONE expansion from a coded
5439
5405
  * window to float samples (D455).
@@ -6575,6 +6541,17 @@ var CamProfileSchema = _enum([
6575
6541
  "mid",
6576
6542
  "low"
6577
6543
  ]);
6544
+ /**
6545
+ * Which rung a per-camera consumer listens on: an explicit profile, or `auto`.
6546
+ *
6547
+ * `auto` is a REAL value, not an absent one — it means "resolve it by the rule
6548
+ * that consumer owns". It lives beside `CamProfile` because a rung is the only
6549
+ * operator-facing way to name a stream: a cam-stream id is broker-internal and
6550
+ * moves when a slot is reassigned, so a stored id silently stops meaning what
6551
+ * the operator picked. Today's one user is the audio analyser, whose rule is
6552
+ * "the lowest AVAILABLE native rung" (D537).
6553
+ */
6554
+ var AudioStreamPickSchema = union([CamProfileSchema, literal("auto")]);
6578
6555
  var CamStreamKindSchema = _enum([
6579
6556
  "pull-rtsp",
6580
6557
  "pull-rtmp",
@@ -7434,7 +7411,7 @@ method(object({ deviceId: number() }), array(StreamSourceEntrySchema)), method(o
7434
7411
  input: unknown()
7435
7412
  }), unknown(), { kind: "mutation" }), method(object({ deviceId: number() }), _void(), { kind: "mutation" }), method(object({ deviceId: number() }), unknown().nullable()), method(object({ deviceId: number() }), RawStateResultSchema.nullable(), { auth: "protected" });
7436
7413
  //#endregion
7437
- //#region ../types/dist/canonical-hash-DBkl8q6V.mjs
7414
+ //#region ../types/dist/canonical-hash-CPK2Dy60.mjs
7438
7415
  var AUDIO_ENCODER_BY_CODEC = {
7439
7416
  opus: "libopus",
7440
7417
  aac: "aac",
@@ -7492,10 +7469,17 @@ function buildInputArgs(input, decodeHwAccel) {
7492
7469
  const args = [];
7493
7470
  if (!isSoftwareDecode(decodeHwAccel)) args.push("-hwaccel", String(decodeHwAccel));
7494
7471
  if (input.extraArgs?.length) args.push(...input.extraArgs);
7472
+ if (input.decodeThreadCount !== void 0 && input.decodeThreadCount > 0) args.push("-threads", String(input.decodeThreadCount));
7495
7473
  if (input.analyzeDurationUs !== void 0) args.push("-analyzeduration", String(input.analyzeDurationUs));
7496
7474
  if (input.probeSizeBytes !== void 0) args.push("-probesize", String(input.probeSizeBytes));
7475
+ if (input.lowDelay === true) args.push("-flags", "low_delay");
7476
+ if (input.useWallclockTimestamps === true) args.push("-use_wallclock_as_timestamps", "1");
7497
7477
  if (input.fflags?.length) for (const flag of input.fflags) args.push("-fflags", flag);
7498
7478
  if (input.rtspTransport) args.push("-rtsp_transport", input.rtspTransport);
7479
+ if (input.rawVideo !== void 0) {
7480
+ const raw = input.rawVideo;
7481
+ args.push("-f", "rawvideo", "-pix_fmt", raw.pixelFormat, "-video_size", `${String(raw.width)}x${String(raw.height)}`, "-framerate", String(raw.framerate));
7482
+ }
7499
7483
  args.push("-i", input.url);
7500
7484
  return args;
7501
7485
  }
@@ -7528,16 +7512,29 @@ function resolveStreamMaps(selection) {
7528
7512
  };
7529
7513
  }
7530
7514
  /** The `-vf` filter args, or `[]` when a consumer `-vf` already claims the slot. */
7531
- function buildVideoFilterArgs(scale, outputArgs, filterGraph) {
7515
+ function buildVideoFilterArgs(scale, outputArgs, filterGraph, hwUpload) {
7532
7516
  if (filterGraph) {
7533
7517
  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)");
7534
7518
  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");
7535
7519
  return [];
7536
7520
  }
7537
- if (!scale) return [];
7538
7521
  if (outputArgs.some((a) => a === "-vf")) return [];
7539
- if (scale.mode === "exact") return ["-vf", `scale=${scale.width}:${scale.height}`];
7540
- return ["-vf", `scale='min(${scale.width},iw)':'min(${scale.height},ih)':force_original_aspect_ratio=decrease:force_divisible_by=2`];
7522
+ if (!scale) return hwUpload.length > 0 ? ["-vf", hwUpload] : [];
7523
+ const suffix = hwUpload.length > 0 ? `,${hwUpload}` : "";
7524
+ if (scale.mode === "exact") return ["-vf", `scale=${scale.width}:${scale.height}${suffix}`];
7525
+ return ["-vf", `scale='min(${scale.width},iw)':'min(${scale.height},ih)':force_original_aspect_ratio=decrease:force_divisible_by=2${suffix}`];
7526
+ }
7527
+ /**
7528
+ * The upload filter a hardware encode needs, or `''`.
7529
+ *
7530
+ * Nothing is uploaded when the DECODE already left the frames on the GPU
7531
+ * (`-hwaccel vaapi`): they are surfaces already, and uploading a surface is an
7532
+ * error rather than a no-op.
7533
+ */
7534
+ function resolveHwUpload(device, decodeHwAccel) {
7535
+ if (device === void 0) return "";
7536
+ if (!isSoftwareDecode(decodeHwAccel)) return "";
7537
+ return "format=nv12,hwupload";
7541
7538
  }
7542
7539
  /** Rate-control args for an encode plan. */
7543
7540
  function buildRateControlArgs(video) {
@@ -7557,7 +7554,18 @@ function buildRateControlArgs(video) {
7557
7554
  ];
7558
7555
  }
7559
7556
  /** The whole video block (`-vf` … `-c:v` … knobs), after `-i`. */
7560
- function buildVideoArgs(video, outputArgs, filterGraph) {
7557
+ function buildVideoArgs(video, outputArgs, filterGraph, hwEncode) {
7558
+ if (video.kind === "raw") {
7559
+ 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");
7560
+ return [
7561
+ ...video.filter !== void 0 ? ["-vf", video.filter] : [],
7562
+ "-c:v",
7563
+ "rawvideo",
7564
+ "-pix_fmt",
7565
+ video.pixelFormat,
7566
+ ...video.fps !== void 0 ? ["-r", String(video.fps)] : []
7567
+ ];
7568
+ }
7561
7569
  if (video.kind === "copy") {
7562
7570
  if (filterGraph) throw new Error("FfmpegInvocation: a filter graph produces a NEW picture, so video.kind must be `encode`");
7563
7571
  return [
@@ -7566,8 +7574,10 @@ function buildVideoArgs(video, outputArgs, filterGraph) {
7566
7574
  ...video.bitstreamFilter ? ["-bsf:v", video.bitstreamFilter] : []
7567
7575
  ];
7568
7576
  }
7577
+ const hwUpload = hwEncode === void 0 ? "" : resolveHwUpload(hwEncode.device, hwEncode.decodeHwAccel);
7578
+ 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");
7569
7579
  const args = [
7570
- ...buildVideoFilterArgs(video.scale, outputArgs, filterGraph),
7580
+ ...buildVideoFilterArgs(video.scale, outputArgs, filterGraph, hwUpload),
7571
7581
  "-c:v",
7572
7582
  video.encoder
7573
7583
  ];
@@ -7737,15 +7747,23 @@ function buildAudioSidecarArgs(sidecar) {
7737
7747
  * (`0:a:0?`): the sidecar is defined as the SOURCE's microphone on its own RTP
7738
7748
  * output, not as whatever the main output happens to carry.
7739
7749
  */
7750
+ /** The hardware-encode context for this invocation, or `undefined`. */
7751
+ function hwEncodeFor(inv) {
7752
+ return inv.hwEncodeDevice === void 0 ? void 0 : {
7753
+ device: inv.hwEncodeDevice,
7754
+ decodeHwAccel: inv.decodeHwAccel
7755
+ };
7756
+ }
7740
7757
  function buildFfmpegArgs(inv) {
7741
7758
  const graph = inv.filterGraph ?? null;
7742
7759
  const head = [
7743
7760
  ...logBannerArgs(inv.logLevel),
7761
+ ...inv.hwEncodeDevice !== void 0 ? ["-vaapi_device", inv.hwEncodeDevice.renderNode] : [],
7744
7762
  ...buildInputArgs(inv.input, inv.decodeHwAccel),
7745
7763
  ...(inv.extraInputs ?? []).flatMap((extra) => buildInputArgs(extra, inv.decodeHwAccel)),
7746
7764
  ...graph ? ["-filter_complex", graph.graph] : []
7747
7765
  ];
7748
- const threadArgs = inv.threadCount > 0 ? ["-threads", String(inv.threadCount)] : [];
7766
+ const threadArgs = [...inv.threadCount > 0 ? ["-threads", String(inv.threadCount)] : [], ...inv.filterThreadCount !== void 0 && inv.filterThreadCount > 0 ? ["-filter_threads", String(inv.filterThreadCount)] : []];
7749
7767
  const maps = resolveStreamMaps(inv);
7750
7768
  const videoMapArgs = maps.video ? ["-map", maps.video] : [];
7751
7769
  const audioMapArgs = maps.audio ? ["-map", maps.audio] : [];
@@ -7754,7 +7772,7 @@ function buildFfmpegArgs(inv) {
7754
7772
  "-an",
7755
7773
  "-map",
7756
7774
  maps.video ?? "0:v:0",
7757
- ...buildVideoArgs(inv.video, inv.outputArgs, graph),
7775
+ ...buildVideoArgs(inv.video, inv.outputArgs, graph, hwEncodeFor(inv)),
7758
7776
  ...threadArgs,
7759
7777
  ...inv.outputArgs,
7760
7778
  ...buildRtpOutputArgs(inv.sink.video)
@@ -7777,7 +7795,7 @@ function buildFfmpegArgs(inv) {
7777
7795
  return [
7778
7796
  ...head,
7779
7797
  ...videoMapArgs,
7780
- ...buildVideoArgs(inv.video, inv.outputArgs, graph),
7798
+ ...buildVideoArgs(inv.video, inv.outputArgs, graph, hwEncodeFor(inv)),
7781
7799
  ...threadArgs,
7782
7800
  ...mutedAudio ? [] : audioMapArgs,
7783
7801
  ...audioArgs,
@@ -8456,6 +8474,7 @@ var CameraSwitchIdSchema = _enum([
8456
8474
  "device-audio",
8457
8475
  "broker-audio",
8458
8476
  "audio-analysis",
8477
+ "event-saving",
8459
8478
  "recording",
8460
8479
  "notifications"
8461
8480
  ]);
@@ -8930,11 +8949,31 @@ var RecordingBandTriggersSchema = RecordingTriggersSchema;
8930
8949
  * band engine's `applies` rule). `start`/`end` are `HH:MM`; an `end <= start`
8931
8950
  * span wraps past midnight (handled by the band engine).
8932
8951
  */
8952
+ /**
8953
+ * Whether a band's footage carries sound.
8954
+ *
8955
+ * The recorder has always attached with `audio: 'aac'`, so every band of every
8956
+ * camera recorded sound whether anyone wanted it or not. The broker already
8957
+ * serves the other answer — `audio: 'none'` takes the video-only `/muted`
8958
+ * restream — so this was a choice the product could always have offered.
8959
+ *
8960
+ * It belongs on the BAND rather than the camera because one camera can want
8961
+ * both: silent across the shop floor during opening hours, sound overnight.
8962
+ * And it applies to BOTH modes — an events band firing on motion has the same
8963
+ * question to answer as a continuous one.
8964
+ */
8965
+ var RecordingBandAudioSchema = _enum(["on", "muted"]);
8933
8966
  var RecordingBandSchema = object({
8934
8967
  days: array(RecordingWeekdaySchema),
8935
8968
  start: string().regex(HHMM),
8936
8969
  end: string().regex(HHMM),
8937
8970
  mode: RecordingBandModeSchema,
8971
+ /**
8972
+ * Sound, or silence. OPTIONAL and absent-means-`on`: every recording
8973
+ * written before this existed carried sound, and a band whose key is
8974
+ * missing must keep meaning what it meant. See {@link RecordingBandAudioSchema}.
8975
+ */
8976
+ audio: RecordingBandAudioSchema.optional(),
8938
8977
  triggers: RecordingBandTriggersSchema.optional(),
8939
8978
  /**
8940
8979
  * RETIRED (D381). A band no longer carries a pre-buffer of its own.
@@ -20756,6 +20795,7 @@ var MediaFileKindEnum = _enum([
20756
20795
  "keyFrameSmall",
20757
20796
  "thumbnailSmall",
20758
20797
  "confirmationCrop",
20798
+ "debugCrop",
20759
20799
  "sceneFrame"
20760
20800
  ]);
20761
20801
  /**
@@ -22420,6 +22460,18 @@ var RunnerCameraConfigSchema = object({
22420
22460
  motionStreamId: string(),
22421
22461
  detectionStreamId: string(),
22422
22462
  /**
22463
+ * Which rung this camera's audio analysis listens on — `auto` (the lowest
22464
+ * AVAILABLE native rung) or an explicit one.
22465
+ *
22466
+ * Not consumed by the runner: carried here so it travels with the camera's
22467
+ * config the way `audioMotionWindowMs` does, and so the orchestrator's
22468
+ * watchdog resolves the same stream the audio subscription did. OPTIONAL
22469
+ * with no default — absent means `auto`, which is what every camera written
22470
+ * before this field had, and an absent optional adds nothing to the
22471
+ * generated cap-input defaults (D537).
22472
+ */
22473
+ audioStreamPick: AudioStreamPickSchema.optional(),
22474
+ /**
22423
22475
  * Motion sources that can activate the pipeline. At least one must
22424
22476
  * be present. Multiple sources OR-ed: any one firing arms the
22425
22477
  * cooldown timer and transitions watching → active. The same
@@ -23490,10 +23542,9 @@ var ServerBootModeSchema = _enum([
23490
23542
  * - `idle` / `checking` / `staging` — steady / in-flight registry work.
23491
23543
  * - `pending-restart` — a version is staged and the node has NOT yet
23492
23544
  * restarted onto it (still running the OLD version).
23493
- * - `awaiting-confirmation` — the node HAS restarted onto the staged version
23494
- * (it is the active probation boot) and is waiting to confirm boot-health.
23495
- * Apply/rollback are refused in this state and the node must NOT be
23496
- * manually restarted, or the probation boot auto-rolls-back.
23545
+ * - `awaiting-confirmation` — RETIRED with the probation boot (single-copy
23546
+ * collapse, 2026-07-18). Kept in the enum so an older client still parses;
23547
+ * no provider emits it.
23497
23548
  */
23498
23549
  var ServerUpdateStateSchema = _enum([
23499
23550
  "idle",
@@ -23582,12 +23633,35 @@ var ServerRollbackInfoSchema = object({
23582
23633
  var ServerPackageStatusSchema = object({
23583
23634
  /** Root package name (`@camstack/server` on the hub). */
23584
23635
  packageName: string(),
23585
- /** Version of the code the running process ACTUALLY loaded. */
23636
+ /**
23637
+ * Version of the code THIS PROCESS loaded — captured once, at boot, from the
23638
+ * package.json of the copy the code was loaded from, and never re-read. A
23639
+ * copy replaced on disk under a live process must not move this number;
23640
+ * that divergence is what `installedVersion` is for. Before 2026-09-20 this
23641
+ * was a lazy disk read that would have followed such a swap silently.
23642
+ */
23586
23643
  runningVersion: string().nullable(),
23587
23644
  /** Node.js runtime version the node's process runs on (`process.versions.node`). */
23588
23645
  nodeRuntimeVersion: string().nullable(),
23589
23646
  /** Active data-dir root version; null when booted from seed/workspace. */
23590
23647
  activeVersion: string().nullable(),
23648
+ /**
23649
+ * Version of the root package in the data-root `current/` copy RIGHT NOW —
23650
+ * what the next boot would load if nothing is staged. Normally equal to
23651
+ * `runningVersion`; differs only when the copy on disk changed under the
23652
+ * live process. `null` when there is no data-root copy (baked seed or
23653
+ * workspace boot). Optional for version skew: an older provider omits it.
23654
+ */
23655
+ installedVersion: string().nullable().optional(),
23656
+ /**
23657
+ * True when the code that would run after a restart is NOT the code running
23658
+ * now: a version is staged (`pendingVersion`), or `installedVersion` differs
23659
+ * from `runningVersion`. Derived on every read, never stored. This is the
23660
+ * one field that lets an operator or a script say "1.2.353 is installed and
23661
+ * will run after a restart" instead of reading it off a number that means
23662
+ * "installed". Optional for version skew.
23663
+ */
23664
+ restartRequired: boolean().optional(),
23591
23665
  /** N-1 version kept for rollback; null when no previous version exists. */
23592
23666
  previousVersion: string().nullable(),
23593
23667
  /** Version of the immutable baked seed closure (image fallback). */
@@ -23622,19 +23696,71 @@ var ServerPackageStatusSchema = object({
23622
23696
  });
23623
23697
  var ServerUpdateCheckResultSchema = object({
23624
23698
  packageName: string(),
23699
+ /** Same meaning as on the status payload: what THIS process loaded. */
23625
23700
  runningVersion: string().nullable(),
23626
23701
  latestVersion: string().nullable(),
23702
+ /**
23703
+ * `latestVersion` is newer than `runningVersion` AND is not the version
23704
+ * already staged on this node. A staged-and-waiting version is not "an
23705
+ * update available" — applying it again is refused (`already-staged`), and
23706
+ * a button that announced "updating…" on this boolean then got refused
23707
+ * would be lying. It is reported through `pendingVersion` instead.
23708
+ */
23627
23709
  updateAvailable: boolean(),
23628
23710
  checkedAtMs: number(),
23629
23711
  /** Non-null when the registry lookup failed (offline, bad registry, …). */
23630
- error: string().nullable()
23712
+ error: string().nullable(),
23713
+ /**
23714
+ * Version staged on this node and awaiting its restart, when one is. Before
23715
+ * 2026-09-20 a check answered `{runningVersion, updateAvailable:false}` for a
23716
+ * node whose staged closure had never booted, and the caller had no way to
23717
+ * see the difference. Optional for version skew.
23718
+ */
23719
+ pendingVersion: string().nullable().optional(),
23720
+ /** See the status payload: staged, or installed-on-disk ≠ running. Optional for skew. */
23721
+ restartRequired: boolean().optional()
23631
23722
  });
23723
+ /**
23724
+ * What an action DID — the discriminant `accepted` cannot carry. Before
23725
+ * 2026-09-20 `applyServerUpdate` answered `accepted:false` both for a refusal
23726
+ * where nothing was wrong (already current, already staged) and for a staging
23727
+ * install that died on `npm error code ECONNRESET`; the only difference was
23728
+ * prose in `message`, and an operator restarted a container on the strength of
23729
+ * it, booting the previous version.
23730
+ *
23731
+ * - `staged` — the closure is installed and validated; the node
23732
+ * restarts to apply it (`restarting:true`). NOTHING
23733
+ * runs the new code until that restart completes.
23734
+ * - `restart-scheduled` — a plain, version-preserving restart was accepted.
23735
+ * - `already-current` — the target IS the running version; nothing done.
23736
+ * - `refused` — dropped by a gate; `reason` names which.
23737
+ * - `failed` — attempted and did not land; `error` says why.
23738
+ */
23739
+ var ServerUpdateOutcomeSchema = _enum([
23740
+ "staged",
23741
+ "restart-scheduled",
23742
+ "already-current",
23743
+ "refused",
23744
+ "failed"
23745
+ ]);
23632
23746
  var ServerUpdateActionResultSchema = object({
23633
23747
  accepted: boolean(),
23634
23748
  targetVersion: string().nullable(),
23635
23749
  /** True when a graceful restart was scheduled to apply the change. */
23636
23750
  restarting: boolean(),
23637
- message: string()
23751
+ message: string(),
23752
+ /** See {@link ServerUpdateOutcomeSchema}. Optional for version skew. */
23753
+ outcome: ServerUpdateOutcomeSchema.optional(),
23754
+ /**
23755
+ * Stable, greppable cause for `refused` / `failed` (the engine's
23756
+ * `UpdateRefusalReason`, `staging-failed`, …). `null` on success.
23757
+ */
23758
+ reason: string().nullable().optional(),
23759
+ /**
23760
+ * The failure itself, verbatim enough to act on (the npm output for a
23761
+ * staging install). Non-null only for `failed`.
23762
+ */
23763
+ error: string().nullable().optional()
23638
23764
  });
23639
23765
  method(_void(), ServerPackageStatusSchema, { auth: "admin" }), method(_void(), ServerUpdateCheckResultSchema, {
23640
23766
  kind: "mutation",
@@ -24911,6 +25037,104 @@ var WebrtcStreamChoiceSchema = object({
24911
25037
  /** Bitrate in kbps (null for adaptive). */
24912
25038
  bitrateKbps: number().nullable()
24913
25039
  });
25040
+ /**
25041
+ * The last move the adaptive ladder made on this session, and the numbers it
25042
+ * rested on. Mirrors `LadderDecision` in the stream-broker's
25043
+ * `adaptive-controller.ts` — the log line's evidence, made readable.
25044
+ *
25045
+ * Nullability is load-bearing: `lossRatio: null` means the loss tracker had
25046
+ * no window to report, which is NOT a link with zero loss.
25047
+ */
25048
+ var WebrtcLadderDecisionSchema = object({
25049
+ /** `loss` — RTCP / client-reported packet loss; `stall` — the picture
25050
+ * stopped moving; `healthy` — a promotion. */
25051
+ cause: _enum([
25052
+ "loss",
25053
+ "stall",
25054
+ "healthy"
25055
+ ]),
25056
+ /** The rung the ladder moved TO. Null for a move that changed no rung
25057
+ * (an MTU reduction, a forced transcode-down at the bottom). */
25058
+ tier: CamProfileSchema.nullable(),
25059
+ /** Whether that move forced the egress transcode-down rung. */
25060
+ forcedTranscodeDown: boolean(),
25061
+ /** Loss ratio over the tracker's window at the moment of the decision. */
25062
+ lossRatio: number().nullable(),
25063
+ /** The client's reported downlink at that moment, in kbps. */
25064
+ clientKbps: number().nullable(),
25065
+ /** How long the stall detector had been seeing nothing arrive. */
25066
+ stalledForMs: number().nullable(),
25067
+ /** Whether the broker WAS sending while that was measured — the half that
25068
+ * separates "the camera stopped" from "the link stopped". */
25069
+ serverSending: boolean().nullable(),
25070
+ /** Epoch ms. The overlay prints its age; a decision from 40 minutes ago
25071
+ * must not read like one from this second. */
25072
+ at: number()
25073
+ });
25074
+ /** Per-tier count of MEASURED downgrades this viewer session has suffered. */
25075
+ var WebrtcTierFailureSchema = object({
25076
+ tier: CamProfileSchema,
25077
+ failures: number().int().nonnegative()
25078
+ });
25079
+ /**
25080
+ * What the SERVER decided for one WebRTC session — the half the client cannot
25081
+ * measure. See `getSessionDebug`.
25082
+ */
25083
+ var WebrtcSessionDebugSchema = object({
25084
+ sessionId: string(),
25085
+ /** Whether the session still exists here. Everything below is null when
25086
+ * it does not — a dead session has no measurements, not zero ones. */
25087
+ alive: boolean(),
25088
+ /** Epoch ms this snapshot was taken, so the overlay can show its age. */
25089
+ observedAt: number(),
25090
+ /** The broker actually feeding this session (`<deviceId>/<camStreamId>`). */
25091
+ brokerId: string().nullable(),
25092
+ camStreamId: string().nullable(),
25093
+ /** `derived` = this hub BUILT the rung being served (a transcode/scale of
25094
+ * another stream); `native` = the camera's own encode. */
25095
+ sourceKind: _enum([
25096
+ "native",
25097
+ "derived",
25098
+ "unknown"
25099
+ ]),
25100
+ /** Whether the ladder is running at all (an `adaptive` target). */
25101
+ adaptive: boolean(),
25102
+ /** The rung `auto` ACTUALLY resolved to right now. Null when the broker
25103
+ * is bound to no profile slot (a manually-activated stream). */
25104
+ servedTier: CamProfileSchema.nullable(),
25105
+ /** Which node ingests the source. Null when unresolved. */
25106
+ ingestNodeId: string().nullable(),
25107
+ /** Whether that node is a remote one (the media crosses a node boundary
25108
+ * before it reaches this session). Null when unresolved. */
25109
+ ingestIsRemote: boolean().nullable(),
25110
+ /** Codec off the source, and the one negotiated with this client. */
25111
+ sourceCodec: string().nullable(),
25112
+ negotiatedCodec: string().nullable(),
25113
+ /** Whether the EGRESS leg re-encodes for this client or forwards the
25114
+ * source bytes untouched. */
25115
+ egressLeg: _enum([
25116
+ "copy",
25117
+ "transcode",
25118
+ "unknown"
25119
+ ]),
25120
+ /** What the broker itself reports about the rung it is serving. */
25121
+ brokerStatus: string().nullable(),
25122
+ brokerCodec: string().nullable(),
25123
+ brokerBitrateKbps: number().nullable(),
25124
+ brokerInputFps: number().nullable(),
25125
+ brokerUptimeMs: number().nullable(),
25126
+ /** RTP packets this session has sent — the server's own "am I sending?". */
25127
+ rtpPacketsSent: number().nullable(),
25128
+ /** D42: the track may be carrying the recorded feeder, not live. The ladder
25129
+ * is suspended there, so a frozen picture in `recorded` mode means
25130
+ * something entirely different. */
25131
+ trackMode: _enum(["live", "recorded"]).nullable(),
25132
+ lastDecision: WebrtcLadderDecisionSchema.nullable(),
25133
+ tierFailures: array(WebrtcTierFailureSchema),
25134
+ /** Successful promotions in this viewer session (drives the progressive
25135
+ * healthy timer). Null when no ladder is running. */
25136
+ upgradeCount: number().nullable()
25137
+ });
24914
25138
  DeviceType.Camera, method(object({ deviceId: number().int().nonnegative() }), array(WebrtcStreamChoiceSchema)), method(object({
24915
25139
  deviceId: number().int().nonnegative(),
24916
25140
  target: WebrtcStreamTargetSchema,
@@ -25075,7 +25299,10 @@ DeviceType.Camera, method(object({ deviceId: number().int().nonnegative() }), ar
25075
25299
  * the same answer as "it ended": either way nothing is holding it up.
25076
25300
  */
25077
25301
  alive: boolean()
25078
- }));
25302
+ })), method(object({
25303
+ deviceId: number().int().nonnegative(),
25304
+ sessionId: string()
25305
+ }), WebrtcSessionDebugSchema, { kind: "query" });
25079
25306
  object({
25080
25307
  /** All accessory children of the parent. */
25081
25308
  childDeviceIds: array(number()).readonly(),
@@ -28199,16 +28426,37 @@ var GridProfileOfferSchema = object({
28199
28426
  ]),
28200
28427
  offered: boolean(),
28201
28428
  /** Sources that cannot serve it. Empty when it is offered, or when there are no cells. */
28202
- missingSources: array(number().int().positive())
28429
+ missingSources: array(number().int().positive()),
28430
+ /**
28431
+ * The canvas this profile composes onto, `WxH`, or empty when it is not
28432
+ * offered. DERIVED from the cells and the sources' own size at this profile —
28433
+ * it is reported because nothing else in the system would ever say what the
28434
+ * grid came out as, and because it is the number an operator would otherwise
28435
+ * expect to type.
28436
+ */
28437
+ canvas: string(),
28438
+ /**
28439
+ * Whether this profile is PUBLISHED, of the ones the grid could serve.
28440
+ *
28441
+ * A grid's `high` is composed of its sources' `high`, so on a 4K fleet it is
28442
+ * a 4K canvas built from 4K decodes — something to opt into, not something a
28443
+ * viewer's adaptive should be handed by climbing to the top rung it can see.
28444
+ * Default is `mid` + `low`.
28445
+ */
28446
+ published: boolean()
28203
28447
  });
28204
28448
  var GridLayoutViewSchema = object({
28205
28449
  /** The persisted grid row this camera was declared from. */
28206
28450
  instanceId: string(),
28207
28451
  deviceId: number().int().nonnegative(),
28208
28452
  name: string(),
28209
- /** The canvas the operator laid out — this grid's `high`. mid/low are scaled from it. */
28210
- width: number().int(),
28211
- height: number().int(),
28453
+ /**
28454
+ * NO canvas size. A grid's resolution is not authored: each profile derives
28455
+ * its own from the cells and its sources' dimensions. The two numbers that
28456
+ * used to be here were a text field that silently decided both how much the
28457
+ * composite cost and how sharp it was — see `profiles[].canvas` for what it
28458
+ * came out as.
28459
+ */
28212
28460
  fps: number().int(),
28213
28461
  cells: array(GridLayoutCellSchema),
28214
28462
  /** What the catalog will publish, and what it refuses to. Read-only. */
@@ -28217,9 +28465,13 @@ var GridLayoutViewSchema = object({
28217
28465
  var GridLayoutPatchSchema = object({
28218
28466
  deviceId: number().int().nonnegative(),
28219
28467
  name: string().min(1).max(160).optional(),
28220
- width: number().int().min(160).max(7680).optional(),
28221
- height: number().int().min(120).max(4320).optional(),
28222
28468
  fps: number().int().min(1).max(60).optional(),
28469
+ /** Which profiles to publish. See `GridProfileOffer.published`. */
28470
+ publishedProfiles: array(_enum([
28471
+ "high",
28472
+ "mid",
28473
+ "low"
28474
+ ])).max(3).optional(),
28223
28475
  /**
28224
28476
  * The whole cell list at once. A per-cell patch would need an ordering the
28225
28477
  * editor does not have, and a half-applied layout is a picture nobody asked
@@ -38799,6 +39051,12 @@ Object.freeze({
38799
39051
  addonId: null,
38800
39052
  access: "view"
38801
39053
  },
39054
+ "webrtcSession.getSessionDebug": {
39055
+ capName: "webrtc-session",
39056
+ capScope: "device",
39057
+ addonId: null,
39058
+ access: "view"
39059
+ },
38802
39060
  "webrtcSession.getSessionState": {
38803
39061
  capName: "webrtc-session",
38804
39062
  capScope: "device",
@@ -40782,6 +41040,11 @@ Object.freeze({
40782
41040
  form: "single",
40783
41041
  optional: false
40784
41042
  }],
41043
+ "webrtcSession.getSessionDebug": [{
41044
+ name: "deviceId",
41045
+ form: "single",
41046
+ optional: false
41047
+ }],
40785
41048
  "webrtcSession.getSessionState": [{
40786
41049
  name: "deviceId",
40787
41050
  form: "single",
@@ -41400,6 +41663,60 @@ DEFAULT_NATIVE_LEASE_SETTINGS.sceneBudgetMb;
41400
41663
  DEFAULT_NATIVE_LEASE_SETTINGS.admission;
41401
41664
  var MB = 1024 * 1024;
41402
41665
  1024 * MB, 3072 * MB;
41666
+ //#endregion
41667
+ //#region ../types/dist/node.mjs
41668
+ /**
41669
+ * The ffmpeg build every CamStack node downloads, per platform and arch.
41670
+ *
41671
+ * ## Why a download, on every node, including the container
41672
+ *
41673
+ * The version of ffmpeg used to be CODE: baked into the image, changeable only
41674
+ * by rebuilding and rolling it. Now it is DATA — this file states the release,
41675
+ * and a node that boots with a different one downloads it. Changing ffmpeg is a
41676
+ * framework bump, not an image rebuild.
41677
+ *
41678
+ * It also settles the licence question by removing it. FFmpeg's licence is
41679
+ * chosen at compile time: the core is LGPL 2.1+, `--enable-gpl` makes it GPL
41680
+ * (for x264/x265), `--enable-version3` makes that v3, and `--enable-nonfree`
41681
+ * makes the result undistributable. These are GPL v3 builds, and we do not
41682
+ * distribute them: the user's own node fetches the archive from the project
41683
+ * that publishes it, so we convey no GPL work and inherit none of the
41684
+ * obligations that come with conveying one. That is the whole reason the image
41685
+ * no longer installs ffmpeg at all — not a technical preference.
41686
+ *
41687
+ * ## Why jellyfin-ffmpeg
41688
+ *
41689
+ * One vendor covering linux-amd64, linux-arm64, darwin-arm64 and darwin-x64
41690
+ * from one release, with the widest hardware surface of anything maintained:
41691
+ * VAAPI, QSV, NVENC/NVDEC, AMF, Vulkan, OpenCL and DRM on amd64, plus Rockchip
41692
+ * MPP on arm64. It reaches the vendor libraries through dlopen trampolines,
41693
+ * so the binary is not linked against a driver stack it may not find.
41694
+ *
41695
+ * **What it still needs from the host, measured 2026-09-19.** On the Unraid
41696
+ * HOST, which has no libva, `-vaapi_device` aborts the process outright —
41697
+ * `implib-gen: libva-drm.so.2: failed to load library ... Assertion '0 &&
41698
+ * "Assertion in generated code"' failed`. Inside our container, which installs
41699
+ * libva plus the iHD driver, the same command encodes: verified end to end with
41700
+ * `testsrc → format=nv12,hwupload → h264_vaapi`. So the image drops ffmpeg and
41701
+ * KEEPS the Intel media stack; the drivers are the part a binary cannot bring.
41702
+ *
41703
+ * ## Why each artifact carries a checksum and a claim
41704
+ *
41705
+ * The download is now the only source, so `sha256` is verified before anything
41706
+ * is extracted — jellyfin publishes no checksums on its release assets, so
41707
+ * these are ours, taken at the version bump. And `expectedHwaccels` is what the
41708
+ * build is supposed to be able to do: a binary that probes short of its own
41709
+ * claim is a source that changed under us, which is precisely what went
41710
+ * unnoticed for months when a static build turned out to carry no VAAPI at all
41711
+ * (D536).
41712
+ */
41713
+ /** The pinned jellyfin-ffmpeg release. Changing this is the version change. */
41714
+ var FFMPEG_RELEASE = "8.1.2-5";
41715
+ var BASE = `https://github.com/jellyfin/jellyfin-ffmpeg/releases/download/v${FFMPEG_RELEASE}`;
41716
+ function portable(target) {
41717
+ return `${BASE}/jellyfin-ffmpeg_${FFMPEG_RELEASE}_portable_${target}-gpl.tar.xz`;
41718
+ }
41719
+ portable("linux64"), portable("linuxarm64"), portable("macarm64"), portable("mac64");
41403
41720
  var DEFAULT_FIRST_UNIT_TIMEOUT_MS = 12e3;
41404
41721
  /** Heartbeat cadence — ~2 minutes of 4 s fragments. */
41405
41722
  var FRAGMENT_LOG_EVERY = 30;
@@ -88381,6 +88698,43 @@ var BridgeHost = class {
88381
88698
  }
88382
88699
  };
88383
88700
  //#endregion
88701
+ //#region src/exposed-entry.ts
88702
+ /**
88703
+ * Carrying an exposed-device entry across a re-expose.
88704
+ *
88705
+ * `exposeDevice` rebuilds its entry from scratch — display name, mapper kind,
88706
+ * timestamp — and then REPLACES the stored one. Anything the rebuilt object
88707
+ * does not mention is therefore destroyed, and two things it never mentioned
88708
+ * were the per-camera settings and the capability list.
88709
+ *
88710
+ * The visible cost: the operator's "Source stream (HomeKit)" selector writes
88711
+ * `low`, the addon logs `streamPreference changed — refreshing accessory
88712
+ * {from=auto to=low}`, and the accessory that comes back derives its
88713
+ * advertisement from DEFAULTS — `streamPreference=auto` — because the settings
88714
+ * were dropped between the write and the rebuild. The selector only ever took
88715
+ * effect after a full addon restart, when the settings were loaded first. A
88716
+ * second write after the re-expose hid this: the store ended up correct, so
88717
+ * nothing looked wrong except the stream nobody could explain.
88718
+ */
88719
+ /**
88720
+ * Fill `base` from `existing` for the given keys, letting `base` win wherever
88721
+ * it actually says something.
88722
+ *
88723
+ * That asymmetry is the point: a caller who passes `capabilities` is stating a
88724
+ * new truth and must not be overruled by the stored copy, while a caller who
88725
+ * says nothing about `settings` is not asking for them to be erased.
88726
+ */
88727
+ function carryForward(base, existing, keys) {
88728
+ if (existing === void 0) return base;
88729
+ const out = { ...base };
88730
+ for (const key of keys) {
88731
+ if (out[key] !== void 0) continue;
88732
+ const carried = existing[key];
88733
+ if (carried !== void 0) out[key] = carried;
88734
+ }
88735
+ return out;
88736
+ }
88737
+ //#endregion
88384
88738
  //#region src/hap-setup-uri.ts
88385
88739
  function errMsg$15(e) {
88386
88740
  return e instanceof Error ? e.message : String(e);
@@ -88415,47 +88769,610 @@ function firstExposedAccessorySetupUri(exposed, logger) {
88415
88769
  }
88416
88770
  }
88417
88771
  //#endregion
88418
- //#region src/mappers/builders/accessory-info.ts
88772
+ //#region src/hksv/clip-record.ts
88773
+ /**
88774
+ * What ONE teed HomeKit clip is, on disk.
88775
+ *
88776
+ * ## Why a sidecar at all
88777
+ *
88778
+ * HomeKit Secure Video is a SINK. HAP's `dataSend` is `hello/whoami/open/data/
88779
+ * ack/close` and `CameraRecordingDelegate` is push-only, so a clip "recorded by
88780
+ * HomeKit" can only ever be ours if we kept our own copy of what we SENT.
88781
+ * There is no read-back to reconcile against later, and there never will be.
88782
+ * Everything a later reader can know about a teed clip therefore has to be
88783
+ * written down at the moment it was written — by the only process that ever saw
88784
+ * it — or it is lost with the session.
88785
+ *
88786
+ * ## The two coverage limits, stated on the record rather than discovered
88787
+ *
88788
+ * - **Only some cameras record at all.** `pickRecordingSource` refuses anything
88789
+ * without an H.264 slot, `MAX_RECORDING_HEIGHT` prefers ≤ 1080p, and
88790
+ * `deriveFragmentLengthMs` refuses a GOP longer than 8 s. A camera the tee
88791
+ * never writes for is not a gap in this store, it is a camera HKSV never
88792
+ * recorded; {@link HksvClipRecord} exists only for the ones it did.
88793
+ * - **The duration is whatever iOS PULLED.** `durationMs` is the wall-clock
88794
+ * span of the HDS stream (prebuffer replay plus however long the hub kept it
88795
+ * open), not a window CamStack chose. A short clip is not a truncated one —
88796
+ * {@link HksvClipRecord.truncated} is the only thing that says truncated.
88797
+ *
88798
+ * ## `thumbnailFile` is VOUCHED
88799
+ *
88800
+ * It is written only after the JPEG has been stat'd on disk at non-zero size.
88801
+ * A minter that reports success and produced nothing leaves
88802
+ * {@link HksvClipRecord.thumbnailUnavailable} instead — the whole point of
88803
+ * vouching is that the promise and the file are not the same claim.
88804
+ */
88805
+ /** The `videoclips` source id a later provider will list these under. */
88806
+ var HKSV_CLIP_SOURCE = "hksv";
88807
+ /** Why no still could be minted from the saved fMP4. Named, never blank. */
88808
+ var HksvThumbnailUnavailableReasonSchema = _enum([
88809
+ "decode-failed",
88810
+ "timed-out",
88811
+ "ffmpeg-missing",
88812
+ "no-init-segment",
88813
+ "not-attempted"
88814
+ ]);
88815
+ /** Why a clip stopped short of the stream that fed it. */
88816
+ var HksvClipTruncationSchema = _enum([
88817
+ "size-cap",
88818
+ "slow-consumer",
88819
+ "write-failed"
88820
+ ]);
88821
+ var HksvClipRecordSchema = object({
88822
+ /** Bumped when a field's MEANING changes; a reader refuses what it cannot read. */
88823
+ schemaVersion: literal(1),
88824
+ /** `hksv:<deviceId>:<startedAtMs>-<streamId>` — self-contained, so a later
88825
+ * provider addresses bytes and stills without re-listing. */
88826
+ clipId: string().min(1),
88827
+ source: literal(HKSV_CLIP_SOURCE),
88828
+ deviceId: number().int(),
88829
+ /** The HDS stream this clip was teed from — the join to the delegate's logs. */
88830
+ streamId: number().int(),
88831
+ startedAtMs: number().int(),
88832
+ endedAtMs: number().int(),
88833
+ /** `endedAtMs - startedAtMs`. What iOS pulled, never a window we chose. */
88834
+ durationMs: number().int().nonnegative(),
88835
+ /** How much of the clip's head came from the prebuffer ring, by ARRIVAL age. */
88836
+ prebufferSpanMs: number().int().nonnegative(),
88837
+ bytes: number().int().nonnegative(),
88838
+ fragments: number().int().nonnegative(),
88839
+ /** Basenames, relative to the clip's own device directory. */
88840
+ file: string().min(1),
88841
+ thumbnailFile: string().min(1).optional(),
88842
+ thumbnailUnavailable: object({ reason: HksvThumbnailUnavailableReasonSchema }).optional(),
88843
+ truncated: HksvClipTruncationSchema.optional(),
88844
+ /** iOS sent `ack` for this stream: HomeKit itself kept the clip. */
88845
+ acknowledgedByHomeKit: boolean(),
88846
+ width: number().int().positive(),
88847
+ height: number().int().positive(),
88848
+ /** The advertised fragment length the source was cutting at. */
88849
+ fragmentMs: number().int().positive()
88850
+ });
88419
88851
  /**
88420
- * `Service.AccessoryInformation` — the manufacturer / model / firmware /
88421
- * serial block every HomeKit accessory carries.
88422
- *
88423
- * One implementation for both accessory shapes (camera and generic): the
88424
- * fields come from the device's own metadata either way, and a second copy
88425
- * would be a second answer to "what serial does this device publish".
88426
- * Metadata is best-effort — a device that cannot answer still publishes.
88852
+ * One stem per clip, three extensions. Derived from the id and from nothing
88853
+ * else, so a pruner deleting a clip never has to guess which JPEG was its.
88427
88854
  */
88428
- async function populateAccessoryInfo(accessory, proxy, displayName, modelFallback) {
88429
- const info = accessory.getService(import_dist.Service.AccessoryInformation);
88430
- if (!info) return;
88855
+ function clipFileNames(clipId) {
88856
+ const stem = clipStem(clipId);
88857
+ return {
88858
+ clip: `${stem}.mp4`,
88859
+ thumbnail: `${stem}.jpg`,
88860
+ record: `${stem}.json`
88861
+ };
88862
+ }
88863
+ function clipIdFor(deviceId, startedAtMs, streamId) {
88864
+ return `${HKSV_CLIP_SOURCE}:${deviceId}:${startedAtMs}-${streamId}`;
88865
+ }
88866
+ /** The filesystem-safe tail of an id. `:` is legal on ext4 and hostile everywhere else. */
88867
+ function clipStem(clipId) {
88868
+ return clipId.replace(/[^A-Za-z0-9_-]/g, "_");
88869
+ }
88870
+ //#endregion
88871
+ //#region src/hksv/clip-store.ts
88872
+ /**
88873
+ * `HksvClipStore` — who owns the teed clips, and for how long.
88874
+ *
88875
+ * ## The owner
88876
+ *
88877
+ * The **export-hap addon**, in its OWN data dir, and nothing else. Not the
88878
+ * recorder's storage locations and not the `eventMedia` class:
88879
+ *
88880
+ * - `export-hap` is hub-only and runs in its own runner. The recorder's
88881
+ * location machinery (D385/D386/D389) is another addon's, in another
88882
+ * process, and reaching it would be a cross-runner write on the frame path
88883
+ * of a live HomeKit session — exactly what D9/D18 forbid.
88884
+ * - A teed clip is a DERIVED artifact of a HomeKit session, with no track, no
88885
+ * detection and no event behind it. Filing it under `eventMedia` would put
88886
+ * it inside the post-analysis retention sweep, where its lifetime would be
88887
+ * decided by a policy written for a different question.
88888
+ * - The precedent is D549 decision 13: Reolink's clip thumbnails live in the
88889
+ * vendor addon's data dir under a per-device LRU, outside `eventMedia` and
88890
+ * outside the sweep, for the same reason.
88891
+ *
88892
+ * Layout: `<dataDir>/hksv-clips/<deviceId>/<stem>.{mp4,jpg,json}`. One stem per
88893
+ * clip, three extensions, so a pruner deleting a clip never has to guess which
88894
+ * JPEG was its.
88895
+ *
88896
+ * ## The retention, and why these numbers
88897
+ *
88898
+ * HomeKit's own retention is unreadable — HAP has no read-back, so CamStack can
88899
+ * never ask "does iOS still have this one?" and reconcile. The store therefore
88900
+ * cannot mirror HomeKit's lifetime and does not pretend to: it keeps a bounded
88901
+ * recent window and says, per clip, exactly when it started. Four bounds, all
88902
+ * enforced on every completion:
88903
+ *
88904
+ * - **{@link DEFAULT_CLIP_BOUNDS.maxClipBytes} per clip (256 MB).** A clip's
88905
+ * duration is whatever iOS pulled — there is no window we choose — so this
88906
+ * is the only ceiling on a session that never closes. At the ~64 KB/s a
88907
+ * 1080p copy branch produces it is over an hour of video.
88908
+ * - **{@link DEFAULT_CLIP_BOUNDS.maxClipsPerDevice} (200)** and
88909
+ * **{@link DEFAULT_CLIP_BOUNDS.maxBytesPerDevice} (512 MB)**, oldest first.
88910
+ * Measured fragment sizes (D84): ~255 KB per 8 s at 720p, ~500 KB at 1080p,
88911
+ * so a typical 20-40 s HKSV clip is 1.5-3 MB and 200 of them is ~400 MB.
88912
+ * Both bounds exist for the same reason the prebuffer ring has both: at 4K
88913
+ * a fragment is 6.35 MB, a 25x spread, and a count-only bound is a
88914
+ * per-camera disk figure nobody can predict.
88915
+ * - **{@link DEFAULT_CLIP_BOUNDS.maxAgeMs} (14 days)**, so a camera that
88916
+ * stopped recording in March does not hold half a gigabyte forever.
88917
+ * - **{@link DEFAULT_CLIP_BOUNDS.maxStoreBytes} (4 GB)** across every camera,
88918
+ * applied after the per-device pass. Per-device bounds alone multiply by a
88919
+ * camera count the operator changes without thinking about this store.
88920
+ *
88921
+ * A file nobody prunes is a disk that fills, and the pruner is the only thing
88922
+ * standing between a permanent prebuffer and that.
88923
+ *
88924
+ * ## The thumbnail is VOUCHED, never promised
88925
+ *
88926
+ * The minter is injected and its verdict is not believed: after it returns the
88927
+ * store stats the JPEG, and `thumbnailFile` is written only if a non-empty file
88928
+ * is really there. A minter that reports success and produced nothing gets
88929
+ * `thumbnailUnavailable.reason: 'decode-failed'` and its zero-byte leftover
88930
+ * deleted — this repo has twice shipped a green test because the fake supplied
88931
+ * what production forgot, and a promise is not a file.
88932
+ */
88933
+ /** The directory under the addon's data dir. Ours, and only ours. */
88934
+ var CLIP_DIR_NAME = "hksv-clips";
88935
+ /** See the class docblock for what each number is, and where it comes from. */
88936
+ var DEFAULT_CLIP_BOUNDS = {
88937
+ maxClipBytes: 256 * 1024 * 1024,
88938
+ maxClipsPerDevice: 200,
88939
+ maxBytesPerDevice: 512 * 1024 * 1024,
88940
+ maxAgeMs: 336 * 60 * 60 * 1e3,
88941
+ maxStoreBytes: 4 * 1024 * 1024 * 1024
88942
+ };
88943
+ var HksvClipStore = class {
88944
+ input;
88945
+ log;
88946
+ bounds;
88947
+ now;
88948
+ inFlight = /* @__PURE__ */ new Map();
88949
+ constructor(input) {
88950
+ this.input = input;
88951
+ this.log = input.logger;
88952
+ this.bounds = {
88953
+ ...DEFAULT_CLIP_BOUNDS,
88954
+ ...input.bounds
88955
+ };
88956
+ this.now = input.now ?? Date.now;
88957
+ }
88958
+ /** The per-clip ceiling the tee enforces while writing. */
88959
+ get maxClipBytes() {
88960
+ return this.bounds.maxClipBytes;
88961
+ }
88962
+ /**
88963
+ * Open a file for one HDS stream. `null` when the directory cannot be made —
88964
+ * the caller must then skip the tee entirely and leave HKSV untouched, which
88965
+ * is why this returns rather than throws.
88966
+ */
88967
+ async open(input) {
88968
+ const clipId = clipIdFor(input.deviceId, input.startedAtMs, input.streamId);
88969
+ const names = clipFileNames(clipId);
88970
+ const dir = this.deviceDir(input.deviceId);
88971
+ const tags = { deviceId: input.deviceId };
88972
+ try {
88973
+ await mkdir(dir, { recursive: true });
88974
+ const sink = await createFileSink(join(dir, names.clip));
88975
+ this.inFlight.set(clipId, {
88976
+ deviceId: input.deviceId,
88977
+ stem: stemOf(names.clip)
88978
+ });
88979
+ return {
88980
+ clipId,
88981
+ sink,
88982
+ complete: (outcome) => this.complete(clipId, input, names, outcome)
88983
+ };
88984
+ } catch (err) {
88985
+ this.inFlight.delete(clipId);
88986
+ this.log.warn("hksv clip store: could NOT open a clip file — nothing is teed for this stream", {
88987
+ tags,
88988
+ meta: {
88989
+ streamId: input.streamId,
88990
+ error: err instanceof Error ? err.message : String(err)
88991
+ }
88992
+ });
88993
+ return null;
88994
+ }
88995
+ }
88996
+ /** Every sidecar for a camera, newest first. What a later provider lists. */
88997
+ async listRecords(deviceId) {
88998
+ const dir = this.deviceDir(deviceId);
88999
+ let names;
89000
+ try {
89001
+ names = await readdir(dir);
89002
+ } catch {
89003
+ return [];
89004
+ }
89005
+ const records = [];
89006
+ for (const name of names) {
89007
+ if (!name.endsWith(".json")) continue;
89008
+ const record = await this.readRecord(join(dir, name), deviceId);
89009
+ if (record !== null) records.push(record);
89010
+ }
89011
+ return records.sort((a, b) => b.startedAtMs - a.startedAtMs);
89012
+ }
89013
+ /** iOS sent `ack`: HomeKit itself kept this clip. Recorded on the sidecar. */
89014
+ async acknowledge(deviceId, clipId) {
89015
+ const path = join(this.deviceDir(deviceId), clipFileNames(clipId).record);
89016
+ const record = await this.readRecord(path, deviceId);
89017
+ if (record === null) return;
89018
+ await this.writeRecord(path, {
89019
+ ...record,
89020
+ acknowledgedByHomeKit: true
89021
+ });
89022
+ }
89023
+ deviceDir(deviceId) {
89024
+ return join(this.input.rootDir, String(deviceId));
89025
+ }
89026
+ async complete(clipId, input, names, outcome) {
89027
+ this.inFlight.delete(clipId);
89028
+ const dir = this.deviceDir(input.deviceId);
89029
+ const tags = { deviceId: input.deviceId };
89030
+ const clipPath = join(dir, names.clip);
89031
+ if (!outcome.sawInit || outcome.bytes === 0) {
89032
+ await rm(clipPath, { force: true });
89033
+ this.log.warn("hksv clip tee: DROPPED the clip — it carries no init segment, so nothing in it is decodable", {
89034
+ tags,
89035
+ meta: {
89036
+ clipId,
89037
+ streamId: input.streamId,
89038
+ bytes: outcome.bytes,
89039
+ fragments: outcome.fragments
89040
+ }
89041
+ });
89042
+ return null;
89043
+ }
89044
+ const thumbnail = await this.mintAndVouch({
89045
+ deviceId: input.deviceId,
89046
+ clipPath,
89047
+ jpegPath: join(dir, names.thumbnail),
89048
+ triggerOffsetSec: input.prebufferSpanMs / 1e3
89049
+ });
89050
+ const record = {
89051
+ schemaVersion: 1,
89052
+ clipId,
89053
+ source: HKSV_CLIP_SOURCE,
89054
+ deviceId: input.deviceId,
89055
+ streamId: input.streamId,
89056
+ startedAtMs: outcome.startedAtMs,
89057
+ endedAtMs: outcome.endedAtMs,
89058
+ durationMs: Math.max(0, outcome.endedAtMs - outcome.startedAtMs),
89059
+ prebufferSpanMs: input.prebufferSpanMs,
89060
+ bytes: outcome.bytes,
89061
+ fragments: outcome.fragments,
89062
+ file: names.clip,
89063
+ ...thumbnail.ok ? { thumbnailFile: names.thumbnail } : { thumbnailUnavailable: { reason: thumbnail.reason } },
89064
+ ...outcome.truncated === null ? {} : { truncated: outcome.truncated },
89065
+ acknowledgedByHomeKit: false,
89066
+ width: input.width,
89067
+ height: input.height,
89068
+ fragmentMs: input.fragmentMs
89069
+ };
89070
+ await this.writeRecord(join(dir, names.record), record);
89071
+ this.log.info("hksv clip tee: a HomeKit clip was KEPT", {
89072
+ tags,
89073
+ meta: {
89074
+ clipId,
89075
+ streamId: input.streamId,
89076
+ bytes: record.bytes,
89077
+ fragments: record.fragments,
89078
+ durationMs: record.durationMs,
89079
+ prebufferSpanMs: record.prebufferSpanMs,
89080
+ truncated: record.truncated ?? null,
89081
+ thumbnail: record.thumbnailFile ?? null,
89082
+ thumbnailUnavailable: record.thumbnailUnavailable?.reason ?? null
89083
+ }
89084
+ });
89085
+ await this.prune(input.deviceId);
89086
+ return record;
89087
+ }
89088
+ /**
89089
+ * Mint, then CHECK. The minter's own verdict decides nothing on its own: a
89090
+ * JPEG is vouched by `stat`, never by a return value.
89091
+ */
89092
+ async mintAndVouch(mint) {
89093
+ const { deviceId, jpegPath } = mint;
89094
+ const minter = this.input.mintThumbnail;
89095
+ if (minter === void 0) return {
89096
+ ok: false,
89097
+ reason: "not-attempted"
89098
+ };
89099
+ let declared;
89100
+ try {
89101
+ declared = await minter(mint);
89102
+ } catch (err) {
89103
+ this.log.warn("hksv clip tee: the thumbnail minter threw — the clip is kept WITHOUT a still", {
89104
+ tags: { deviceId },
89105
+ meta: { error: err instanceof Error ? err.message : String(err) }
89106
+ });
89107
+ declared = {
89108
+ ok: false,
89109
+ reason: "decode-failed"
89110
+ };
89111
+ }
89112
+ if (await fileSize(jpegPath) > 0) return { ok: true };
89113
+ await rm(jpegPath, { force: true });
89114
+ const reason = declared.ok ? "decode-failed" : declared.reason;
89115
+ this.log.warn("hksv clip tee: no thumbnail for this clip", {
89116
+ tags: { deviceId },
89117
+ meta: {
89118
+ reason,
89119
+ declaredOk: declared.ok
89120
+ }
89121
+ });
89122
+ return {
89123
+ ok: false,
89124
+ reason
89125
+ };
89126
+ }
89127
+ /**
89128
+ * Enforce every bound, oldest first. Enumerated from the MP4s rather than the
89129
+ * sidecars, so a stem whose sidecar never landed — a crash mid-write — is
89130
+ * seen and removed instead of occupying the disk invisibly forever.
89131
+ */
89132
+ async prune(deviceId) {
89133
+ const cutoff = this.now() - this.bounds.maxAgeMs;
89134
+ const perDevice = await this.scanDevice(deviceId);
89135
+ await this.enforce(perDevice, cutoff, this.bounds.maxClipsPerDevice, this.bounds.maxBytesPerDevice);
89136
+ let deviceIds;
89137
+ try {
89138
+ deviceIds = (await readdir(this.input.rootDir, { withFileTypes: true })).filter((e) => e.isDirectory()).map((e) => Number.parseInt(e.name, 10)).filter((id) => Number.isFinite(id));
89139
+ } catch {
89140
+ return;
89141
+ }
89142
+ const all = [];
89143
+ for (const id of deviceIds) all.push(...await this.scanDevice(id));
89144
+ await this.enforce(all, cutoff, Number.POSITIVE_INFINITY, this.bounds.maxStoreBytes);
89145
+ }
89146
+ async enforce(clips, ageCutoff, maxCount, maxBytes) {
89147
+ const ordered = [...clips].sort((a, b) => b.startedAtMs - a.startedAtMs);
89148
+ let kept = 0;
89149
+ let bytes = 0;
89150
+ for (const clip of ordered) {
89151
+ const tooOld = clip.startedAtMs < ageCutoff;
89152
+ const overCount = kept >= maxCount;
89153
+ const overBytes = bytes + clip.bytes > maxBytes;
89154
+ if (!tooOld && !overCount && !overBytes) {
89155
+ kept += 1;
89156
+ bytes += clip.bytes;
89157
+ continue;
89158
+ }
89159
+ await this.deleteClip(clip, tooOld ? "age" : overCount ? "count" : "bytes");
89160
+ }
89161
+ }
89162
+ async deleteClip(clip, reason) {
89163
+ for (const name of [
89164
+ `${clip.stem}.mp4`,
89165
+ `${clip.stem}.jpg`,
89166
+ `${clip.stem}.json`
89167
+ ]) await rm(join(clip.dir, name), { force: true });
89168
+ this.log.info("hksv clip store: pruned a clip", {
89169
+ tags: { deviceId: clip.deviceId },
89170
+ meta: {
89171
+ stem: clip.stem,
89172
+ bytes: clip.bytes,
89173
+ reason,
89174
+ startedAtMs: clip.startedAtMs
89175
+ }
89176
+ });
89177
+ }
89178
+ async scanDevice(deviceId) {
89179
+ const dir = this.deviceDir(deviceId);
89180
+ let names;
89181
+ try {
89182
+ names = await readdir(dir);
89183
+ } catch {
89184
+ return [];
89185
+ }
89186
+ const clips = [];
89187
+ for (const name of names) {
89188
+ if (!name.endsWith(".mp4")) continue;
89189
+ const stem = stemOf(name);
89190
+ if (this.isInFlight(deviceId, stem)) continue;
89191
+ const bytes = await fileSize(join(dir, name));
89192
+ const record = await this.readRecord(join(dir, `${stem}.json`), deviceId);
89193
+ if (record === null) {
89194
+ await rm(join(dir, name), { force: true });
89195
+ this.log.warn("hksv clip store: removed an orphaned clip file (no sidecar, not being written)", {
89196
+ tags: { deviceId },
89197
+ meta: {
89198
+ stem,
89199
+ bytes
89200
+ }
89201
+ });
89202
+ continue;
89203
+ }
89204
+ clips.push({
89205
+ deviceId,
89206
+ dir,
89207
+ stem,
89208
+ bytes,
89209
+ startedAtMs: record.startedAtMs
89210
+ });
89211
+ }
89212
+ return clips;
89213
+ }
89214
+ isInFlight(deviceId, stem) {
89215
+ for (const entry of this.inFlight.values()) if (entry.deviceId === deviceId && entry.stem === stem) return true;
89216
+ return false;
89217
+ }
89218
+ async readRecord(path, deviceId) {
89219
+ let raw;
89220
+ try {
89221
+ raw = await readFile(path, "utf8");
89222
+ } catch {
89223
+ return null;
89224
+ }
89225
+ const parsed = HksvClipRecordSchema.safeParse(JSON.parse(raw));
89226
+ if (parsed.success) return parsed.data;
89227
+ this.log.warn("hksv clip store: a sidecar could not be read — the clip is not listed", {
89228
+ tags: { deviceId },
89229
+ meta: {
89230
+ path,
89231
+ error: parsed.error.message
89232
+ }
89233
+ });
89234
+ return null;
89235
+ }
89236
+ async writeRecord(path, record) {
89237
+ await writeFile(path, JSON.stringify(record, null, 2), "utf8");
89238
+ }
89239
+ };
89240
+ function stemOf(fileName) {
89241
+ return fileName.replace(/\.[^.]+$/, "");
89242
+ }
89243
+ async function fileSize(path) {
88431
89244
  try {
88432
- const device = await proxy.deviceManager?.getDevice({});
88433
- const metadata = readMetadata(device);
88434
- info.setCharacteristic(import_dist.Characteristic.Name, device?.name ?? displayName);
88435
- info.setCharacteristic(import_dist.Characteristic.Manufacturer, stringOr(metadata?.manufacturer, "CamStack"));
88436
- info.setCharacteristic(import_dist.Characteristic.Model, stringOr(metadata?.model, modelFallback));
88437
- info.setCharacteristic(import_dist.Characteristic.FirmwareRevision, stringOr(metadata?.firmware, "0.0.0"));
88438
- info.setCharacteristic(import_dist.Characteristic.SerialNumber, stringOr(metadata?.sn, `camstack-${proxy.deviceId}`));
88439
- } catch {}
89245
+ return (await stat(path)).size;
89246
+ } catch {
89247
+ return 0;
89248
+ }
88440
89249
  }
88441
- /** The four fields this module reads, or `null` — the device record's
88442
- * `metadata` is an open bag and nothing else here depends on its shape. */
88443
- function readMetadata(device) {
88444
- const metadata = device?.metadata;
88445
- if (metadata === null || typeof metadata !== "object") return {};
88446
- const entries = new Map(Object.entries(metadata));
89250
+ /**
89251
+ * A sink over a real file that HONOURS backpressure: `write()` returning false
89252
+ * means the kernel has not taken the bytes, and ignoring it is how a stream
89253
+ * grows a queue nobody bounded (D447).
89254
+ */
89255
+ async function createFileSink(path) {
89256
+ const stream = createWriteStream(path);
89257
+ await once(stream, "open");
88447
89258
  return {
88448
- model: stringOrNull(entries.get("model")),
88449
- manufacturer: stringOrNull(entries.get("manufacturer")),
88450
- firmware: stringOrNull(entries.get("firmware")),
88451
- sn: stringOrNull(entries.get("sn"))
89259
+ async write(chunk) {
89260
+ if (!stream.write(chunk)) await once(stream, "drain");
89261
+ },
89262
+ async close() {
89263
+ await new Promise((resolve, reject) => {
89264
+ stream.end((err) => err ? reject(err) : resolve());
89265
+ });
89266
+ }
88452
89267
  };
88453
89268
  }
88454
- function stringOrNull(value) {
88455
- return typeof value === "string" ? value : null;
89269
+ function buildClipThumbnailArgs(input) {
89270
+ return [
89271
+ "-hide_banner",
89272
+ "-loglevel",
89273
+ "error",
89274
+ "-nostdin",
89275
+ "-y",
89276
+ "-ss",
89277
+ String(Math.max(0, Math.floor(input.seekSec))),
89278
+ "-i",
89279
+ input.clipPath,
89280
+ "-frames:v",
89281
+ "1",
89282
+ "-vf",
89283
+ `scale='min(${input.maxWidth},iw)':-2`,
89284
+ "-f",
89285
+ "image2",
89286
+ "-vcodec",
89287
+ "mjpeg",
89288
+ input.jpegPath
89289
+ ];
88456
89290
  }
88457
- function stringOr(value, fallback) {
88458
- return typeof value === "string" && value.length > 0 ? value : fallback;
89291
+ function createClipThumbnailMinter(input) {
89292
+ return async ({ clipPath, jpegPath, triggerOffsetSec }) => {
89293
+ const args = buildClipThumbnailArgs({
89294
+ clipPath,
89295
+ jpegPath,
89296
+ seekSec: triggerOffsetSec,
89297
+ maxWidth: input.maxWidth ?? 640
89298
+ });
89299
+ let run;
89300
+ try {
89301
+ run = await input.runner(args);
89302
+ } catch {
89303
+ return {
89304
+ ok: false,
89305
+ reason: "decode-failed"
89306
+ };
89307
+ }
89308
+ if (run.spawnFailed) return {
89309
+ ok: false,
89310
+ reason: "ffmpeg-missing"
89311
+ };
89312
+ if (run.timedOut) return {
89313
+ ok: false,
89314
+ reason: "timed-out"
89315
+ };
89316
+ if (run.code !== 0) return {
89317
+ ok: false,
89318
+ reason: "decode-failed"
89319
+ };
89320
+ return { ok: true };
89321
+ };
89322
+ }
89323
+ /**
89324
+ * The production runner: one bounded ffmpeg, killed at the deadline.
89325
+ *
89326
+ * Bounded and killed, not merely awaited — an ffmpeg that never exits on a
89327
+ * clip it cannot parse would otherwise hold a process per clip on a hub that
89328
+ * already runs a permanent prebuffer per recorded camera.
89329
+ */
89330
+ function createFfmpegClipThumbnailRunner(input) {
89331
+ const timeoutMs = input.timeoutMs ?? 1e4;
89332
+ return (args) => new Promise((resolve) => {
89333
+ let settled = false;
89334
+ const finish = (run) => {
89335
+ if (settled) return;
89336
+ settled = true;
89337
+ clearTimeout(timer);
89338
+ resolve(run);
89339
+ };
89340
+ const child = input.spawnFn(input.ffmpegBinaryPath, [...args], { stdio: [
89341
+ "ignore",
89342
+ "ignore",
89343
+ "pipe"
89344
+ ] });
89345
+ let stderr = "";
89346
+ child.stderr?.on("data", (chunk) => {
89347
+ if (stderr.length < 2e3) stderr += chunk.toString("utf8");
89348
+ });
89349
+ const timer = setTimeout(() => {
89350
+ child.kill("SIGKILL");
89351
+ finish({
89352
+ code: null,
89353
+ timedOut: true,
89354
+ spawnFailed: false,
89355
+ stderr
89356
+ });
89357
+ }, timeoutMs);
89358
+ timer.unref?.();
89359
+ child.on("error", (err) => {
89360
+ finish({
89361
+ code: null,
89362
+ timedOut: false,
89363
+ spawnFailed: true,
89364
+ stderr: err.message
89365
+ });
89366
+ });
89367
+ child.on("close", (code) => {
89368
+ finish({
89369
+ code,
89370
+ timedOut: false,
89371
+ spawnFailed: false,
89372
+ stderr
89373
+ });
89374
+ });
89375
+ });
88459
89376
  }
88460
89377
  //#endregion
88461
89378
  //#region src/mappers/builders/generic/characteristic-update.ts
@@ -88785,6 +89702,910 @@ function errMsg$14(err) {
88785
89702
  return err instanceof Error ? err.message : String(err);
88786
89703
  }
88787
89704
  //#endregion
89705
+ //#region src/mappers/builders/child-switch.ts
89706
+ /**
89707
+ * Child-switch builder — turns a camstack accessory child device (siren,
89708
+ * floodlight, spotlight, …) into a HomeKit Service ON THE PARENT
89709
+ * camera Accessory.
89710
+ *
89711
+ * The orchestrator (`child-accessory.ts → buildChildServicesFor`)
89712
+ * hands this builder the PARENT accessory + the CHILD's proxy + a
89713
+ * unique subtype so each service round-trips deterministically through
89714
+ * HAP serialisation. The builder:
89715
+ * - Maps `switch` cap → `Service.Switch`.
89716
+ * - Maps `switch` + `brightness` cap → `Service.Lightbulb` with
89717
+ * Brightness characteristic (lighting devices only — see below).
89718
+ * - Hydrates initial state from `getStatus({})`.
89719
+ * - Wires `Characteristic.On` / `Characteristic.Brightness` through
89720
+ * the typed DeviceProxy.
89721
+ * - Subscribes to runtime-state slice updates so iOS Home reflects
89722
+ * external state changes (e.g. another integration toggling the
89723
+ * light).
89724
+ *
89725
+ * Service-kind selection: most cameras expose `brightness` on a siren
89726
+ * because it controls the alarm VOLUME, not luminosity. Mapping a
89727
+ * siren onto `Service.Lightbulb` makes it appear as a regular light in
89728
+ * iOS Home (wrong icon, wrong scenes, wrong Siri intents). We therefore
89729
+ * gate `Service.Lightbulb` on the child's `DeviceType` — only Light /
89730
+ * Generic devices keep the Lightbulb shape; Siren, Switch, and others
89731
+ * fall back to `Service.Switch`. Volume control for sirens is exposed
89732
+ * through the camstack admin UI; iOS Home only needs the on/off toggle.
89733
+ *
89734
+ * Child devices without `switch` / `brightness` cap (status sensors,
89735
+ * IR LED, etc.) return a no-op handle.
89736
+ */
89737
+ async function buildChildSwitch(bctx, subtype, deviceType) {
89738
+ const { ctx, accessory, proxy, numericDeviceId, displayName } = bctx;
89739
+ const log = ctx.logger.withTags({ deviceId: numericDeviceId });
89740
+ const hasSwitch = !!proxy.switch;
89741
+ const hasBrightness = !!proxy.brightness;
89742
+ if (!hasSwitch && !hasBrightness) {
89743
+ log.debug("export-hap: child has no switch/brightness cap — skipping");
89744
+ return { async dispose() {} };
89745
+ }
89746
+ const isLightingDevice = deviceType === DeviceType.Light || deviceType === DeviceType.Generic;
89747
+ const useLightbulb = hasBrightness && isLightingDevice;
89748
+ const service = useLightbulb ? accessory.addService(import_dist.Service.Lightbulb, displayName, subtype) : accessory.addService(import_dist.Service.Switch, displayName, subtype);
89749
+ applyServiceLabel(service, displayName);
89750
+ try {
89751
+ const switchStatus = await proxy.switch?.getStatus({});
89752
+ if (switchStatus && typeof switchStatus.on === "boolean") service.updateCharacteristic(import_dist.Characteristic.On, switchStatus.on);
89753
+ } catch (err) {
89754
+ log.debug("export-hap: child switch.getStatus failed (non-fatal)", { meta: { error: errMsg$13(err) } });
89755
+ }
89756
+ if (useLightbulb) try {
89757
+ const status = await proxy.brightness?.getStatus({});
89758
+ if (status && typeof status.percentage === "number") service.updateCharacteristic(import_dist.Characteristic.Brightness, status.percentage);
89759
+ } catch (err) {
89760
+ log.debug("export-hap: child brightness.getStatus failed (non-fatal)", { meta: { error: errMsg$13(err) } });
89761
+ }
89762
+ if (hasSwitch) service.getCharacteristic(import_dist.Characteristic.On).onSet(async (value) => {
89763
+ const on = value === true;
89764
+ try {
89765
+ await proxy.switch?.setState({ on });
89766
+ } catch (err) {
89767
+ log.warn("export-hap: child switch.setState failed", { meta: {
89768
+ on,
89769
+ error: errMsg$13(err)
89770
+ } });
89771
+ }
89772
+ });
89773
+ if (useLightbulb) service.getCharacteristic(import_dist.Characteristic.Brightness).onSet(async (value) => {
89774
+ const percentage = typeof value === "number" ? value : Number(value);
89775
+ if (!Number.isFinite(percentage)) return;
89776
+ try {
89777
+ await proxy.brightness?.setBrightness({ percentage });
89778
+ } catch (err) {
89779
+ log.warn("export-hap: child brightness.setBrightness failed", { meta: {
89780
+ percentage,
89781
+ error: errMsg$13(err)
89782
+ } });
89783
+ }
89784
+ });
89785
+ const unsubscribes = [];
89786
+ if (proxy.state.switch) {
89787
+ const unsubSwitch = proxy.state.switch.subscribe((value) => {
89788
+ if (value && typeof value.on === "boolean") service.updateCharacteristic(import_dist.Characteristic.On, value.on);
89789
+ });
89790
+ unsubscribes.push(unsubSwitch);
89791
+ }
89792
+ if (useLightbulb && proxy.state.brightness) {
89793
+ const unsubBrightness = proxy.state.brightness.subscribe((value) => {
89794
+ if (value && typeof value.percentage === "number") service.updateCharacteristic(import_dist.Characteristic.Brightness, value.percentage);
89795
+ });
89796
+ unsubscribes.push(unsubBrightness);
89797
+ }
89798
+ return { async dispose() {
89799
+ for (const u of unsubscribes) try {
89800
+ u();
89801
+ } catch {}
89802
+ } };
89803
+ }
89804
+ function errMsg$13(err) {
89805
+ return err instanceof Error ? err.message : String(err);
89806
+ }
89807
+ //#endregion
89808
+ //#region src/mappers/builders/generic/lock.ts
89809
+ /**
89810
+ * `lock-control` → `Service.LockMechanism`.
89811
+ *
89812
+ * Not a sensor row: HomeKit models a lock as two characteristics, a CURRENT
89813
+ * state the accessory owns and a TARGET state the controller writes, and the
89814
+ * cap's five states do not map onto either one alone.
89815
+ *
89816
+ * ```
89817
+ * cap state LockCurrentState LockTargetState
89818
+ * locked SECURED SECURED
89819
+ * unlocked UNSECURED UNSECURED
89820
+ * locking UNKNOWN SECURED (in flight — do not claim SECURED)
89821
+ * unlocking UNKNOWN UNSECURED
89822
+ * jammed JAMMED unchanged (the controller's intent stands)
89823
+ * ```
89824
+ *
89825
+ * Reporting SECURED while the bolt is still moving is the failure worth naming:
89826
+ * iOS renders the lock as closed, the operator walks away, and the motor stalls
89827
+ * behind them. `UNKNOWN` is the honest answer for an in-flight transition.
89828
+ *
89829
+ * `open` (the cap's third method, for locks with a latch/buzzer) has no HomeKit
89830
+ * counterpart on this service and is deliberately not wired — a Switch that
89831
+ * silently buzzes a door open would be a second knob nobody asked for.
89832
+ */
89833
+ var SUBTYPE$1 = "lock-control";
89834
+ var readLock = reader(LockControlStatusSchema.pick({ state: true }), (status) => {
89835
+ 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;
89836
+ 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;
89837
+ return [{
89838
+ characteristic: import_dist.Characteristic.LockCurrentState,
89839
+ value: current
89840
+ }, ...target === null ? [] : [{
89841
+ characteristic: import_dist.Characteristic.LockTargetState,
89842
+ value: target
89843
+ }]];
89844
+ });
89845
+ async function buildLockMechanism(bctx) {
89846
+ const { ctx, accessory, proxy, numericDeviceId, displayName } = bctx;
89847
+ const log = ctx.logger.withTags({ deviceId: numericDeviceId });
89848
+ const label = hapServiceName([displayName], `Lock ${numericDeviceId}`);
89849
+ const service = accessory.addService(import_dist.Service.LockMechanism, label, SUBTYPE$1);
89850
+ applyServiceLabel(service, label);
89851
+ const apply = (status, source) => {
89852
+ const updates = readLock(status);
89853
+ if (updates.length === 0) {
89854
+ log.debug("export-hap: lock status did not match the cap schema — no update", { meta: { source } });
89855
+ return;
89856
+ }
89857
+ applyUpdates(service, updates);
89858
+ };
89859
+ try {
89860
+ const status = await proxy.lockControl?.getStatus({});
89861
+ if (status !== void 0 && status !== null) apply(status, "getStatus");
89862
+ } catch (err) {
89863
+ log.debug("export-hap: lock getStatus hydrate failed (non-fatal)", { meta: { error: errMsg$12(err) } });
89864
+ }
89865
+ service.getCharacteristic(import_dist.Characteristic.LockTargetState).onSet(async (value) => {
89866
+ const secure = value === import_dist.Characteristic.LockTargetState.SECURED;
89867
+ try {
89868
+ if (secure) await proxy.lockControl?.lock({});
89869
+ else await proxy.lockControl?.unlock({});
89870
+ } catch (err) {
89871
+ log.warn("export-hap: lock command failed", { meta: {
89872
+ secure,
89873
+ error: errMsg$12(err)
89874
+ } });
89875
+ }
89876
+ });
89877
+ const unsubscribe = proxy.state.lockControl?.subscribe((value) => {
89878
+ if (value === void 0 || value === null) return;
89879
+ apply(value, "slice");
89880
+ }) ?? null;
89881
+ return { async dispose() {
89882
+ try {
89883
+ unsubscribe?.();
89884
+ } catch {}
89885
+ } };
89886
+ }
89887
+ function errMsg$12(err) {
89888
+ return err instanceof Error ? err.message : String(err);
89889
+ }
89890
+ //#endregion
89891
+ //#region src/mappers/builders/generic/security-state.ts
89892
+ /** Is this a settled arm state worth remembering? */
89893
+ function armedMemoOf(state) {
89894
+ return state === "armed_home" || state === "armed_away" || state === "armed_night" || state === "armed_vacation" || state === "armed_custom_bypass" ? state : null;
89895
+ }
89896
+ function currentForArmed(state) {
89897
+ switch (state) {
89898
+ case "armed_home":
89899
+ case "armed_custom_bypass": return import_dist.Characteristic.SecuritySystemCurrentState.STAY_ARM;
89900
+ case "armed_night": return import_dist.Characteristic.SecuritySystemCurrentState.NIGHT_ARM;
89901
+ default: return import_dist.Characteristic.SecuritySystemCurrentState.AWAY_ARM;
89902
+ }
89903
+ }
89904
+ function targetForArmed(state) {
89905
+ switch (state) {
89906
+ case "armed_home":
89907
+ case "armed_custom_bypass": return import_dist.Characteristic.SecuritySystemTargetState.STAY_ARM;
89908
+ case "armed_night": return import_dist.Characteristic.SecuritySystemTargetState.NIGHT_ARM;
89909
+ default: return import_dist.Characteristic.SecuritySystemTargetState.AWAY_ARM;
89910
+ }
89911
+ }
89912
+ function securityStateFor(state, memo) {
89913
+ const settled = armedMemoOf(state);
89914
+ if (settled !== null) return {
89915
+ current: currentForArmed(settled),
89916
+ target: targetForArmed(settled)
89917
+ };
89918
+ switch (state) {
89919
+ case "disarmed": return {
89920
+ current: import_dist.Characteristic.SecuritySystemCurrentState.DISARMED,
89921
+ target: import_dist.Characteristic.SecuritySystemTargetState.DISARM
89922
+ };
89923
+ case "arming": return {
89924
+ current: import_dist.Characteristic.SecuritySystemCurrentState.DISARMED,
89925
+ target: memo === null ? null : targetForArmed(memo)
89926
+ };
89927
+ case "disarming": return {
89928
+ current: memo === null ? import_dist.Characteristic.SecuritySystemCurrentState.DISARMED : currentForArmed(memo),
89929
+ target: import_dist.Characteristic.SecuritySystemTargetState.DISARM
89930
+ };
89931
+ case "pending": return {
89932
+ current: memo === null ? import_dist.Characteristic.SecuritySystemCurrentState.DISARMED : currentForArmed(memo),
89933
+ target: memo === null ? null : targetForArmed(memo)
89934
+ };
89935
+ default: return {
89936
+ current: import_dist.Characteristic.SecuritySystemCurrentState.ALARM_TRIGGERED,
89937
+ target: memo === null ? null : targetForArmed(memo)
89938
+ };
89939
+ }
89940
+ }
89941
+ /**
89942
+ * The arm mode behind a HomeKit target, or `null` for DISARM and for anything
89943
+ * outside the four values HomeKit defines.
89944
+ *
89945
+ * `null` is not an error the caller can ignore: DISARM is a different COMMAND,
89946
+ * and a value HomeKit never sends is a write this builder refuses to translate.
89947
+ */
89948
+ function alarmArmModeFor(target) {
89949
+ switch (target) {
89950
+ case import_dist.Characteristic.SecuritySystemTargetState.STAY_ARM: return "home";
89951
+ case import_dist.Characteristic.SecuritySystemTargetState.AWAY_ARM: return "away";
89952
+ case import_dist.Characteristic.SecuritySystemTargetState.NIGHT_ARM: return "night";
89953
+ default: return null;
89954
+ }
89955
+ }
89956
+ //#endregion
89957
+ //#region src/mappers/builders/generic/security-target.ts
89958
+ /**
89959
+ * The panel modes behind each HomeKit target, PRIMARY first.
89960
+ *
89961
+ * The primary is the mode `alarmArmModeFor` names; the secondary is the other
89962
+ * mode `security-state.ts` reports through the same HomeKit value, so the two
89963
+ * modules agree in both directions by construction.
89964
+ */
89965
+ var MODES_BY_TARGET = new Map([
89966
+ [import_dist.Characteristic.SecuritySystemTargetState.STAY_ARM, ["home", "custom_bypass"]],
89967
+ [import_dist.Characteristic.SecuritySystemTargetState.AWAY_ARM, ["away", "vacation"]],
89968
+ [import_dist.Characteristic.SecuritySystemTargetState.NIGHT_ARM, ["night"]]
89969
+ ]);
89970
+ /** Arm targets in the order HomeKit numbers them; DISARM is appended last. */
89971
+ var ARM_TARGETS = [
89972
+ import_dist.Characteristic.SecuritySystemTargetState.STAY_ARM,
89973
+ import_dist.Characteristic.SecuritySystemTargetState.AWAY_ARM,
89974
+ import_dist.Characteristic.SecuritySystemTargetState.NIGHT_ARM
89975
+ ];
89976
+ /**
89977
+ * The `validValues` for `SecuritySystemTargetState`, or `null` to leave
89978
+ * HomeKit's own four values in place.
89979
+ *
89980
+ * DISARM is always present: disarming is not one of the panel's arm modes and
89981
+ * a panel that cannot be disarmed is not a thing this cap can express.
89982
+ */
89983
+ function targetValidValuesFor(availableModes) {
89984
+ if (availableModes.length === 0) return null;
89985
+ return [...ARM_TARGETS.filter((target) => (MODES_BY_TARGET.get(target) ?? []).some((mode) => availableModes.includes(mode))), import_dist.Characteristic.SecuritySystemTargetState.DISARM];
89986
+ }
89987
+ /**
89988
+ * The arm mode to send for a HomeKit target, or `null` when there is none.
89989
+ *
89990
+ * `null` is not a shrug the caller may swallow: DISARM is a different command,
89991
+ * and an arm target the panel accepts no mode for is a write this module
89992
+ * refuses to translate rather than guess at.
89993
+ */
89994
+ function armModeForTarget(target, availableModes) {
89995
+ const primary = alarmArmModeFor(target);
89996
+ if (primary === null) return null;
89997
+ if (availableModes.length === 0) return primary;
89998
+ return (MODES_BY_TARGET.get(target) ?? [primary]).find((mode) => availableModes.includes(mode)) ?? null;
89999
+ }
90000
+ //#endregion
90001
+ //#region src/mappers/builders/generic/security-system.ts
90002
+ /**
90003
+ * `alarm-panel` → `Service.SecuritySystem`.
90004
+ *
90005
+ * The lock builder's shape (a current/target pair, a hydrate, an `onSet`, a
90006
+ * slice subscription, a dispose) applied to the one device on the fleet where
90007
+ * reporting a state that is not true has a consequence worse than a stalled
90008
+ * motor. The mapping itself is NOT here: `security-state.ts` owns what the
90009
+ * panel's state looks like to HomeKit and `security-target.ts` owns what
90010
+ * HomeKit may ask for, both pure and both tested on their own. This file is
90011
+ * the service around them.
90012
+ *
90013
+ * ## The memo
90014
+ *
90015
+ * `pending`, `disarming` and `triggered` do not carry the mode the panel was
90016
+ * armed to — only the last settled state knows it. This builder is the caller
90017
+ * `security-state.ts` describes: it remembers the last settled arm and forgets
90018
+ * it on `disarmed`, because a memo that outlives a disarm makes the next
90019
+ * `arming` claim a target nobody asked for.
90020
+ *
90021
+ * The memo is deliberately NOT seeded from the controller's own write. HomeKit
90022
+ * already holds the target the operator just set; not writing it back is
90023
+ * exactly right. The cost is a panel armed from its own keypad during an exit
90024
+ * delay, where the target stays at whatever HomeKit last knew — a stale target
90025
+ * for the length of the delay, against inventing an intent, and the invention
90026
+ * is worse.
90027
+ *
90028
+ * ## `requiresCode`: the arm is REFUSED, not attempted
90029
+ *
90030
+ * HomeKit's SecuritySystem has no PIN prompt — there is no characteristic for
90031
+ * it and no way to ask the operator mid-write. So a panel that requires a code
90032
+ * cannot be armed or disarmed from the Home app, and this builder says so
90033
+ * twice: the write fails with `INSUFFICIENT_AUTHORIZATION` (iOS renders the
90034
+ * tile's failure) and a WARN names the device, the command and the mode.
90035
+ *
90036
+ * Sending the command code-less and letting the panel reject it was the
90037
+ * alternative. It is worse: the refusal costs a round trip, it is
90038
+ * indistinguishable from a broken panel in the log, and repeated code-less
90039
+ * attempts are what locks a real alarm panel out.
90040
+ */
90041
+ var SUBTYPE = "alarm-panel";
90042
+ /** The subset of the status this builder reads. */
90043
+ var PanelReadSchema = AlarmPanelStatusSchema.pick({
90044
+ state: true,
90045
+ availableModes: true,
90046
+ requiresCode: true
90047
+ });
90048
+ /**
90049
+ * Turn a panel status into characteristic writes, folding the memo.
90050
+ *
90051
+ * A payload that does not match the cap's own schema yields NO updates and
90052
+ * leaves the memo alone — it is a status we could not read, not a panel that
90053
+ * changed. The caller logs the drop; a frozen characteristic is otherwise
90054
+ * indistinguishable from a quiet panel.
90055
+ */
90056
+ function securityUpdatesFor(status, memo) {
90057
+ const parsed = PanelReadSchema.safeParse(status);
90058
+ if (!parsed.success) return {
90059
+ updates: [],
90060
+ memo,
90061
+ panel: null
90062
+ };
90063
+ const nextMemo = parsed.data.state === "disarmed" ? null : armedMemoOf(parsed.data.state) ?? memo;
90064
+ const { current, target } = securityStateFor(parsed.data.state, nextMemo);
90065
+ return {
90066
+ updates: [{
90067
+ characteristic: import_dist.Characteristic.SecuritySystemCurrentState,
90068
+ value: current
90069
+ }, ...target === null ? [] : [{
90070
+ characteristic: import_dist.Characteristic.SecuritySystemTargetState,
90071
+ value: target
90072
+ }]],
90073
+ memo: nextMemo,
90074
+ panel: {
90075
+ availableModes: parsed.data.availableModes,
90076
+ requiresCode: parsed.data.requiresCode
90077
+ }
90078
+ };
90079
+ }
90080
+ async function buildSecuritySystem(bctx) {
90081
+ const { ctx, accessory, proxy, numericDeviceId, displayName } = bctx;
90082
+ const log = ctx.logger.withTags({ deviceId: numericDeviceId });
90083
+ const label = hapServiceName([displayName], `Alarm ${numericDeviceId}`);
90084
+ const service = accessory.addService(import_dist.Service.SecuritySystem, label, SUBTYPE);
90085
+ applyServiceLabel(service, label);
90086
+ let memo = null;
90087
+ let availableModes = [];
90088
+ let requiresCode = false;
90089
+ /** Only log the `validValues` narrowing when it actually changes. */
90090
+ let lastValidValues = "";
90091
+ const applyValidValues = () => {
90092
+ const validValues = targetValidValuesFor(availableModes);
90093
+ if (validValues === null) {
90094
+ if (lastValidValues !== "unconstrained") {
90095
+ lastValidValues = "unconstrained";
90096
+ log.info("export-hap: alarm panel named no arm modes — leaving HomeKit’s targets unconstrained", { meta: { subtype: SUBTYPE } });
90097
+ }
90098
+ return;
90099
+ }
90100
+ const key = validValues.join(",");
90101
+ if (key === lastValidValues) return;
90102
+ lastValidValues = key;
90103
+ service.getCharacteristic(import_dist.Characteristic.SecuritySystemTargetState).setProps({ validValues: [...validValues] });
90104
+ log.info("export-hap: alarm panel target constrained to the modes it accepts", { meta: {
90105
+ subtype: SUBTYPE,
90106
+ availableModes: [...availableModes],
90107
+ validValues
90108
+ } });
90109
+ };
90110
+ const apply = (status, source) => {
90111
+ const next = securityUpdatesFor(status, memo);
90112
+ if (next.updates.length === 0) {
90113
+ log.debug("export-hap: alarm panel status did not match the cap schema — no update", { meta: {
90114
+ subtype: SUBTYPE,
90115
+ source
90116
+ } });
90117
+ return;
90118
+ }
90119
+ memo = next.memo;
90120
+ if (next.panel !== null) {
90121
+ availableModes = next.panel.availableModes;
90122
+ requiresCode = next.panel.requiresCode;
90123
+ applyValidValues();
90124
+ }
90125
+ applyUpdates(service, next.updates);
90126
+ };
90127
+ try {
90128
+ const status = await proxy.alarmPanel?.getStatus({});
90129
+ if (status !== void 0 && status !== null) apply(status, "getStatus");
90130
+ } catch (err) {
90131
+ log.debug("export-hap: alarm panel getStatus hydrate failed (non-fatal)", { meta: { error: errMsg$11(err) } });
90132
+ }
90133
+ service.getCharacteristic(import_dist.Characteristic.SecuritySystemTargetState).onSet(async (value) => {
90134
+ const target = typeof value === "number" ? value : Number(value);
90135
+ const disarm = target === import_dist.Characteristic.SecuritySystemTargetState.DISARM;
90136
+ const mode = disarm ? null : armModeForTarget(target, availableModes);
90137
+ if (requiresCode) {
90138
+ log.warn("export-hap: alarm panel requires a PIN — HomeKit cannot supply one, command REFUSED", { meta: {
90139
+ subtype: SUBTYPE,
90140
+ command: disarm ? "disarm" : "arm",
90141
+ mode,
90142
+ target
90143
+ } });
90144
+ throw new import_dist.HapStatusError(import_dist.HAPStatus.INSUFFICIENT_AUTHORIZATION);
90145
+ }
90146
+ if (!disarm && mode === null) {
90147
+ log.warn("export-hap: alarm panel cannot honour this HomeKit arm target — REFUSED", { meta: {
90148
+ subtype: SUBTYPE,
90149
+ target,
90150
+ availableModes: [...availableModes]
90151
+ } });
90152
+ throw new import_dist.HapStatusError(import_dist.HAPStatus.INVALID_VALUE_IN_REQUEST);
90153
+ }
90154
+ try {
90155
+ if (mode === null) await proxy.alarmPanel?.disarm({});
90156
+ else await proxy.alarmPanel?.arm({ mode });
90157
+ } catch (err) {
90158
+ log.warn("export-hap: alarm panel command failed", { meta: {
90159
+ subtype: SUBTYPE,
90160
+ command: mode === null ? "disarm" : "arm",
90161
+ mode,
90162
+ error: errMsg$11(err)
90163
+ } });
90164
+ throw new import_dist.HapStatusError(import_dist.HAPStatus.SERVICE_COMMUNICATION_FAILURE);
90165
+ }
90166
+ });
90167
+ const unsubscribe = proxy.state.alarmPanel?.subscribe((value) => {
90168
+ if (value === void 0 || value === null) return;
90169
+ apply(value, "slice");
90170
+ }) ?? null;
90171
+ return { async dispose() {
90172
+ try {
90173
+ unsubscribe?.();
90174
+ } catch {}
90175
+ } };
90176
+ }
90177
+ function errMsg$11(err) {
90178
+ return err instanceof Error ? err.message : String(err);
90179
+ }
90180
+ //#endregion
90181
+ //#region src/mappers/builders/generic/sensor-service.ts
90182
+ async function buildSensorService(input) {
90183
+ const { bctx, spec, name, subtype } = input;
90184
+ const { ctx, accessory, proxy, numericDeviceId } = bctx;
90185
+ const log = ctx.logger.withTags({ deviceId: numericDeviceId });
90186
+ const label = hapServiceName([name], spec.label);
90187
+ const service = spec.addService(accessory, label, subtype);
90188
+ applyServiceLabel(service, label);
90189
+ const apply = (status, source) => {
90190
+ const updates = spec.read(status);
90191
+ if (updates.length === 0) {
90192
+ log.debug("export-hap: sensor status did not match the cap schema — no update", { meta: {
90193
+ subtype,
90194
+ source
90195
+ } });
90196
+ return;
90197
+ }
90198
+ applyUpdates(service, updates);
90199
+ };
90200
+ try {
90201
+ const status = await spec.getStatus(proxy);
90202
+ if (status !== void 0 && status !== null) apply(status, "getStatus");
90203
+ } catch (err) {
90204
+ log.debug("export-hap: sensor getStatus hydrate failed (non-fatal)", { meta: {
90205
+ subtype,
90206
+ error: errMsg$10(err)
90207
+ } });
90208
+ }
90209
+ const unsubscribe = spec.subscribe(proxy, (value) => {
90210
+ if (value === void 0 || value === null) return;
90211
+ apply(value, "slice");
90212
+ });
90213
+ return { async dispose() {
90214
+ try {
90215
+ unsubscribe?.();
90216
+ } catch {}
90217
+ } };
90218
+ }
90219
+ function errMsg$10(err) {
90220
+ return err instanceof Error ? err.message : String(err);
90221
+ }
90222
+ //#endregion
90223
+ //#region src/mappers/builders/generic/cap-service-table.ts
90224
+ /**
90225
+ * The capability→HomeKit-service TABLE for non-camera devices.
90226
+ *
90227
+ * This is the whole coverage decision for the generic export path, and it is
90228
+ * deliberately a table rather than a `switch` on `DeviceType`. The doctrine is
90229
+ * the one the Home Assistant exporter already writes down: *coverage is decided
90230
+ * by a device's capabilities, not its type — restricting the picker by type is
90231
+ * what confined the previous exporter to cameras.* A `DeviceType` here is only
90232
+ * ever a UI filter (`HAP_EXPORTABLE_DEVICE_TYPES`) or an icon
90233
+ * (`ACCESSORY_CATEGORY_BY_TYPE`); it never decides whether something exports.
90234
+ *
90235
+ * The shape mirrors `CAP_ENTITY_MAP` in
90236
+ * `packages/addon-provider-homeassistant/src/ha-export/entity-catalog.ts` so
90237
+ * the two can be unified later — one row per capability, keyed by the cap name
90238
+ * exactly as it appears in `proxy.binding.entries[].capName`.
90239
+ *
90240
+ * ## Reading a status
90241
+ *
90242
+ * Every row parses the cap's OWN Zod status schema rather than duck-typing the
90243
+ * payload, and it `.pick()`s only the fields it reads: a provider that omits a
90244
+ * timestamp must not silence a sensor, and a provider that sends the wrong
90245
+ * shape must not write a garbage characteristic. A parse that fails yields no
90246
+ * updates, and the builder that owns the service logs the drop — silence reads
90247
+ * as "the sensor never changed".
90248
+ *
90249
+ * ## Tier
90250
+ *
90251
+ * Tier A, plus `alarm-panel`: the caps whose HomeKit service exists today and
90252
+ * needs no feature-dependent shape. `cover` still needs `cover-positionable`
90253
+ * and `climate-control` the dual-setpoint split; those are a separate task,
90254
+ * and adding a row here must never mean adding a service whose semantics this
90255
+ * file cannot fully honour.
90256
+ *
90257
+ * `alarm-panel` earned its row by answering the two questions that kept it
90258
+ * out: a target the panel would refuse is never offered (`availableModes` →
90259
+ * `validValues`, `security-target.ts`) and an arm a PIN-gated panel cannot
90260
+ * accept is refused out loud rather than attempted (`security-system.ts`).
90261
+ */
90262
+ /** HomeKit's floor for `CurrentAmbientLightLevel`; 0 lux is out of range. */
90263
+ var MIN_LUX = 1e-4;
90264
+ var SENSOR_SPECS = {
90265
+ contact: {
90266
+ label: "Contact",
90267
+ addService: (accessory, name, subtype) => accessory.addService(import_dist.Service.ContactSensor, name, subtype),
90268
+ getStatus: (proxy) => proxy.contact?.getStatus({}),
90269
+ subscribe: (proxy, onValue) => proxy.state.contact?.subscribe(onValue) ?? null,
90270
+ read: reader(ContactStatusSchema.pick({ entryOpen: true }), (status) => [{
90271
+ characteristic: import_dist.Characteristic.ContactSensorState,
90272
+ value: status.entryOpen ? import_dist.Characteristic.ContactSensorState.CONTACT_NOT_DETECTED : import_dist.Characteristic.ContactSensorState.CONTACT_DETECTED
90273
+ }])
90274
+ },
90275
+ motion: {
90276
+ label: "Motion",
90277
+ addService: (accessory, name, subtype) => accessory.addService(import_dist.Service.MotionSensor, name, subtype),
90278
+ getStatus: (proxy) => proxy.motion?.getStatus({}),
90279
+ subscribe: (proxy, onValue) => proxy.state.motion?.subscribe(onValue) ?? null,
90280
+ read: reader(MotionStatusSchema.pick({ detected: true }), (status) => [{
90281
+ characteristic: import_dist.Characteristic.MotionDetected,
90282
+ value: status.detected
90283
+ }])
90284
+ },
90285
+ presence: {
90286
+ label: "Presence",
90287
+ addService: (accessory, name, subtype) => accessory.addService(import_dist.Service.OccupancySensor, name, subtype),
90288
+ getStatus: (proxy) => proxy.presence?.getStatus({}),
90289
+ subscribe: (proxy, onValue) => proxy.state.presence?.subscribe(onValue) ?? null,
90290
+ read: reader(PresenceStatusSchema.pick({ state: true }), (status) => [{
90291
+ characteristic: import_dist.Characteristic.OccupancyDetected,
90292
+ value: status.state === "home" ? import_dist.Characteristic.OccupancyDetected.OCCUPANCY_DETECTED : import_dist.Characteristic.OccupancyDetected.OCCUPANCY_NOT_DETECTED
90293
+ }])
90294
+ },
90295
+ smoke: {
90296
+ label: "Smoke",
90297
+ addService: (accessory, name, subtype) => accessory.addService(import_dist.Service.SmokeSensor, name, subtype),
90298
+ getStatus: (proxy) => proxy.smoke?.getStatus({}),
90299
+ subscribe: (proxy, onValue) => proxy.state.smoke?.subscribe(onValue) ?? null,
90300
+ read: reader(SmokeStatusSchema.pick({ detected: true }), (status) => [{
90301
+ characteristic: import_dist.Characteristic.SmokeDetected,
90302
+ value: status.detected ? import_dist.Characteristic.SmokeDetected.SMOKE_DETECTED : import_dist.Characteristic.SmokeDetected.SMOKE_NOT_DETECTED
90303
+ }])
90304
+ },
90305
+ "carbon-monoxide": {
90306
+ label: "Carbon monoxide",
90307
+ addService: (accessory, name, subtype) => accessory.addService(import_dist.Service.CarbonMonoxideSensor, name, subtype),
90308
+ getStatus: (proxy) => proxy.carbonMonoxide?.getStatus({}),
90309
+ subscribe: (proxy, onValue) => proxy.state.carbonMonoxide?.subscribe(onValue) ?? null,
90310
+ read: reader(CarbonMonoxideStatusSchema.pick({ detected: true }), (status) => [{
90311
+ characteristic: import_dist.Characteristic.CarbonMonoxideDetected,
90312
+ value: status.detected ? import_dist.Characteristic.CarbonMonoxideDetected.CO_LEVELS_ABNORMAL : import_dist.Characteristic.CarbonMonoxideDetected.CO_LEVELS_NORMAL
90313
+ }])
90314
+ },
90315
+ flood: {
90316
+ label: "Leak",
90317
+ addService: (accessory, name, subtype) => accessory.addService(import_dist.Service.LeakSensor, name, subtype),
90318
+ getStatus: (proxy) => proxy.flood?.getStatus({}),
90319
+ subscribe: (proxy, onValue) => proxy.state.flood?.subscribe(onValue) ?? null,
90320
+ read: reader(FloodStatusSchema.pick({ flooded: true }), (status) => [{
90321
+ characteristic: import_dist.Characteristic.LeakDetected,
90322
+ value: status.flooded ? import_dist.Characteristic.LeakDetected.LEAK_DETECTED : import_dist.Characteristic.LeakDetected.LEAK_NOT_DETECTED
90323
+ }])
90324
+ },
90325
+ "temperature-sensor": {
90326
+ label: "Temperature",
90327
+ addService: (accessory, name, subtype) => accessory.addService(import_dist.Service.TemperatureSensor, name, subtype),
90328
+ getStatus: (proxy) => proxy.temperatureSensor?.getStatus({}),
90329
+ subscribe: (proxy, onValue) => proxy.state.temperatureSensor?.subscribe(onValue) ?? null,
90330
+ read: reader(TemperatureSensorStatusSchema.pick({ celsius: true }), (status) => [{
90331
+ characteristic: import_dist.Characteristic.CurrentTemperature,
90332
+ value: status.celsius
90333
+ }])
90334
+ },
90335
+ "humidity-sensor": {
90336
+ label: "Humidity",
90337
+ addService: (accessory, name, subtype) => accessory.addService(import_dist.Service.HumiditySensor, name, subtype),
90338
+ getStatus: (proxy) => proxy.humiditySensor?.getStatus({}),
90339
+ subscribe: (proxy, onValue) => proxy.state.humiditySensor?.subscribe(onValue) ?? null,
90340
+ read: reader(HumiditySensorStatusSchema.pick({ percent: true }), (status) => [{
90341
+ characteristic: import_dist.Characteristic.CurrentRelativeHumidity,
90342
+ value: status.percent
90343
+ }])
90344
+ },
90345
+ "ambient-light-sensor": {
90346
+ label: "Light level",
90347
+ addService: (accessory, name, subtype) => accessory.addService(import_dist.Service.LightSensor, name, subtype),
90348
+ getStatus: (proxy) => proxy.ambientLightSensor?.getStatus({}),
90349
+ subscribe: (proxy, onValue) => proxy.state.ambientLightSensor?.subscribe(onValue) ?? null,
90350
+ read: reader(AmbientLightSensorStatusSchema.pick({ lux: true }), (status) => [{
90351
+ characteristic: import_dist.Characteristic.CurrentAmbientLightLevel,
90352
+ value: Math.max(MIN_LUX, status.lux)
90353
+ }])
90354
+ }
90355
+ };
90356
+ /**
90357
+ * The `battery` row reads like a sensor but writes three characteristics from
90358
+ * one status, and the camera path publishes the SAME service from the same cap.
90359
+ * The derivation therefore lives once, in `battery.ts`, and both callers read
90360
+ * it from there.
90361
+ */
90362
+ var BATTERY_SPEC = {
90363
+ label: "Battery",
90364
+ addService: (accessory, name, subtype) => accessory.addService(import_dist.Service.Battery, name, subtype),
90365
+ getStatus: (proxy) => proxy.battery?.getStatus({}),
90366
+ subscribe: (proxy, onValue) => proxy.state.battery?.subscribe(onValue) ?? null,
90367
+ read: batteryCharacteristicUpdates
90368
+ };
90369
+ function sensorRow(capName, spec) {
90370
+ return {
90371
+ caps: [capName],
90372
+ label: spec.label,
90373
+ build: ({ bctx, name }) => buildSensorService({
90374
+ bctx,
90375
+ spec,
90376
+ name,
90377
+ subtype: capName
90378
+ })
90379
+ };
90380
+ }
90381
+ /**
90382
+ * THE table. Order matters only for one thing: the first row that matches a
90383
+ * device decides the accessory's category when the device's own type does not.
90384
+ */
90385
+ var HAP_CAP_SERVICES = [
90386
+ {
90387
+ caps: ["switch", "brightness"],
90388
+ label: "Power",
90389
+ build: ({ bctx, name, deviceType }) => buildChildSwitch({
90390
+ ...bctx,
90391
+ displayName: name
90392
+ }, "switch", deviceType)
90393
+ },
90394
+ {
90395
+ caps: ["lock-control"],
90396
+ label: "Lock",
90397
+ build: ({ bctx, name }) => buildLockMechanism({
90398
+ ...bctx,
90399
+ displayName: name
90400
+ })
90401
+ },
90402
+ {
90403
+ caps: ["alarm-panel"],
90404
+ label: "Alarm",
90405
+ build: ({ bctx, name }) => buildSecuritySystem({
90406
+ ...bctx,
90407
+ displayName: name
90408
+ })
90409
+ },
90410
+ {
90411
+ caps: ["battery"],
90412
+ label: BATTERY_SPEC.label,
90413
+ build: ({ bctx, name }) => buildSensorService({
90414
+ bctx,
90415
+ spec: BATTERY_SPEC,
90416
+ name,
90417
+ subtype: "battery"
90418
+ })
90419
+ },
90420
+ ...Object.entries(SENSOR_SPECS).map(([capName, spec]) => sensorRow(capName, spec))
90421
+ ];
90422
+ /**
90423
+ * The rows a device's bound capabilities select, in table order.
90424
+ *
90425
+ * A row matches when ANY of its caps is bound: a lamp with `switch` but no
90426
+ * `brightness` is still a Lightbulb-shaped row, and the builder degrades on its
90427
+ * own.
90428
+ */
90429
+ function rowsForCaps(capNames) {
90430
+ return HAP_CAP_SERVICES.filter((row) => row.caps.some((cap) => capNames.has(cap)));
90431
+ }
90432
+ /**
90433
+ * The HomeKit accessory category per camstack device type — the icon iOS Home
90434
+ * draws and nothing else. Absent = `OTHER`, which is a valid accessory that
90435
+ * simply gets the generic tile.
90436
+ *
90437
+ * This is NOT a coverage decision. `HAP_EXPORTABLE_DEVICE_TYPES` (in
90438
+ * `mappers/index.ts`) filters the picker; whether a device exports anything is
90439
+ * decided by `rowsForCaps`.
90440
+ */
90441
+ var ACCESSORY_CATEGORY_BY_TYPE = {
90442
+ [DeviceType.Switch]: import_dist.Categories.SWITCH,
90443
+ [DeviceType.Light]: import_dist.Categories.LIGHTBULB,
90444
+ [DeviceType.Siren]: import_dist.Categories.SWITCH,
90445
+ [DeviceType.Lock]: import_dist.Categories.DOOR_LOCK,
90446
+ [DeviceType.Sensor]: import_dist.Categories.SENSOR,
90447
+ [DeviceType.Presence]: import_dist.Categories.SENSOR,
90448
+ [DeviceType.AlarmPanel]: import_dist.Categories.SECURITY_SYSTEM
90449
+ };
90450
+ function accessoryCategoryFor(deviceType) {
90451
+ return ACCESSORY_CATEGORY_BY_TYPE[deviceType] ?? import_dist.Categories.OTHER;
90452
+ }
90453
+ //#endregion
90454
+ //#region src/mappers/builders/stream-hwaccel-memo.ts
90455
+ /**
90456
+ * The two bounded memos HomeKit's decode path owns.
90457
+ *
90458
+ * ## Why they exist
90459
+ *
90460
+ * Everything D67 was actually about — one argv builder, one set of constants,
90461
+ * one hwaccel authority — HomeKit already had. What it did NOT have were the
90462
+ * two things the broker gained alongside them:
90463
+ *
90464
+ * 1. **A memo.** `probeDecoderHwaccel` issued a cross-process
90465
+ * `decoder.getInfo` per SESSION. iOS starts sessions in bursts — one on
90466
+ * record was started three times in 16 s — and every one of those paid a
90467
+ * cap call on a hub whose main thread is the scarce resource.
90468
+ * 2. **Failure feedback.** When HomeKit's hardware child died at init and
90469
+ * `shouldRetryInSoftware` saved the session, HomeKit told nobody. The next
90470
+ * session re-picked the same corpse and paid the same two-second death.
90471
+ * `EgressTranscodeManager` fixed exactly this for its own children
90472
+ * (26a522cd5) by reporting the dead backend into the broker's 60 s memo.
90473
+ *
90474
+ * ## Both ride `HwAccelCache`, deliberately
90475
+ *
90476
+ * `createHwAccelCache` from `@camstack/types` is the primitive the broker's own
90477
+ * `egressHwAccelCache` is built from, and the discipline it encodes is the
90478
+ * point: **caller-owned, never a module global** — a module global would
90479
+ * outlive an addon respawn and survive an operator changing the decoder
90480
+ * backend. Same TTL as the broker's, so "has hardware come back yet" cannot
90481
+ * answer differently depending on which consumer asked.
90482
+ *
90483
+ * ## The cross-process gap, stated honestly
90484
+ *
90485
+ * These memos are scoped to the `export-hap` PROCESS. When HomeKit's vaapi
90486
+ * child dies, the broker's next child still pays its own two-second death, and
90487
+ * vice versa — because addons may never import each other and there is no
90488
+ * capability for "this backend is dead on this node right now". Closing that
90489
+ * would need a new cap surface, which Phase 0 explicitly does not take. What is
90490
+ * closed here is HomeKit's own repetition of the cost, across cameras and
90491
+ * across sessions.
90492
+ */
90493
+ /**
90494
+ * The window both memos answer for.
90495
+ *
90496
+ * 60 s, the same as `stream-broker-manager`'s `egressHwAccelCache`. Long enough
90497
+ * that a burst of session restarts pays one read; short enough that an operator
90498
+ * who changes the decoder backend, or a host whose accelerator recovers, is
90499
+ * obeyed on the next session rather than after an addon respawn.
90500
+ */
90501
+ var HAP_DECODE_MEMO_TTL_MS = 6e4;
90502
+ /**
90503
+ * Separator inside the encoded reading. A control character, because a backend
90504
+ * name is `[a-z0-9]+` and the decoder's non-backend choices are `auto` /
90505
+ * `none` / `''`, none of which can contain one — so the split is total.
90506
+ */
90507
+ var READING_SEPARATOR = "";
90508
+ /**
90509
+ * Marks a `null` FIELD, distinct from an EMPTY one.
90510
+ *
90511
+ * `probedBestHwaccel: ''` means the decoder answered and has never probed
90512
+ * (=> `not-probed`); `null` means the field was absent altogether. Encoding
90513
+ * both as `''` would lose a distinction `selectHwDecode` acts on.
90514
+ */
90515
+ var NULL_FIELD = "\0";
90516
+ function encodeField(value) {
90517
+ return value === null ? NULL_FIELD : value;
90518
+ }
90519
+ function decodeField(value) {
90520
+ return value === NULL_FIELD ? null : value;
90521
+ }
90522
+ /**
90523
+ * A reading as ONE `string | null`, which is what {@link HwAccelCache} stores.
90524
+ *
90525
+ * The cache's three states are exactly the three a memoised reading needs:
90526
+ * `undefined` (never asked, or expired), `null` (asked, and the decoder could
90527
+ * not be reached), and a value. Encoding into the one cache rather than
90528
+ * splitting across two is what keeps those three from skewing — two caches
90529
+ * written together can still be READ across an expiry boundary.
90530
+ */
90531
+ function encodeDecoderReading(reading) {
90532
+ if (reading === null) return null;
90533
+ return `${encodeField(reading.hwaccel)}${READING_SEPARATOR}${encodeField(reading.probedBestHwaccel)}`;
90534
+ }
90535
+ function decodeDecoderReading(value) {
90536
+ if (value === null) return null;
90537
+ const [hwaccel = NULL_FIELD, probed = NULL_FIELD] = value.split(READING_SEPARATOR);
90538
+ return {
90539
+ hwaccel: decodeField(hwaccel),
90540
+ probedBestHwaccel: decodeField(probed)
90541
+ };
90542
+ }
90543
+ function createDecoderReadingMemo(options) {
90544
+ const cache = createHwAccelCache(options);
90545
+ return {
90546
+ read() {
90547
+ const cached = cache.read();
90548
+ return cached === void 0 ? void 0 : decodeDecoderReading(cached);
90549
+ },
90550
+ write(reading) {
90551
+ cache.write(encodeDecoderReading(reading));
90552
+ }
90553
+ };
90554
+ }
90555
+ function createHapDecodeMemos(now) {
90556
+ const options = {
90557
+ ttlMs: HAP_DECODE_MEMO_TTL_MS,
90558
+ ...now ? { now } : {}
90559
+ };
90560
+ return {
90561
+ reading: createDecoderReadingMemo(options),
90562
+ failedBackend: createHwAccelCache(options)
90563
+ };
90564
+ }
90565
+ //#endregion
90566
+ //#region src/mappers/builders/accessory-info.ts
90567
+ /**
90568
+ * `Service.AccessoryInformation` — the manufacturer / model / firmware /
90569
+ * serial block every HomeKit accessory carries.
90570
+ *
90571
+ * One implementation for both accessory shapes (camera and generic): the
90572
+ * fields come from the device's own metadata either way, and a second copy
90573
+ * would be a second answer to "what serial does this device publish".
90574
+ * Metadata is best-effort — a device that cannot answer still publishes.
90575
+ */
90576
+ async function populateAccessoryInfo(accessory, proxy, displayName, modelFallback) {
90577
+ const info = accessory.getService(import_dist.Service.AccessoryInformation);
90578
+ if (!info) return;
90579
+ try {
90580
+ const device = await proxy.deviceManager?.getDevice({});
90581
+ const metadata = readMetadata(device);
90582
+ info.setCharacteristic(import_dist.Characteristic.Name, device?.name ?? displayName);
90583
+ info.setCharacteristic(import_dist.Characteristic.Manufacturer, stringOr(metadata?.manufacturer, "CamStack"));
90584
+ info.setCharacteristic(import_dist.Characteristic.Model, stringOr(metadata?.model, modelFallback));
90585
+ info.setCharacteristic(import_dist.Characteristic.FirmwareRevision, stringOr(metadata?.firmware, "0.0.0"));
90586
+ info.setCharacteristic(import_dist.Characteristic.SerialNumber, stringOr(metadata?.sn, `camstack-${proxy.deviceId}`));
90587
+ } catch {}
90588
+ }
90589
+ /** The four fields this module reads, or `null` — the device record's
90590
+ * `metadata` is an open bag and nothing else here depends on its shape. */
90591
+ function readMetadata(device) {
90592
+ const metadata = device?.metadata;
90593
+ if (metadata === null || typeof metadata !== "object") return {};
90594
+ const entries = new Map(Object.entries(metadata));
90595
+ return {
90596
+ model: stringOrNull(entries.get("model")),
90597
+ manufacturer: stringOrNull(entries.get("manufacturer")),
90598
+ firmware: stringOrNull(entries.get("firmware")),
90599
+ sn: stringOrNull(entries.get("sn"))
90600
+ };
90601
+ }
90602
+ function stringOrNull(value) {
90603
+ return typeof value === "string" ? value : null;
90604
+ }
90605
+ function stringOr(value, fallback) {
90606
+ return typeof value === "string" && value.length > 0 ? value : fallback;
90607
+ }
90608
+ //#endregion
88788
90609
  //#region ../../node_modules/werift-rtp/lib/rtp/src/srtp/const.js
88789
90610
  var require_const = /* @__PURE__ */ __commonJSMin(((exports) => {
88790
90611
  Object.defineProperty(exports, "__esModule", { value: true });
@@ -100101,7 +101922,7 @@ var VIDEO_LOOPBACK_RCVBUF_BYTES = 8 * 1024 * 1024;
100101
101922
  * not burst size.
100102
101923
  */
100103
101924
  var AUDIO_LOOPBACK_RCVBUF_BYTES = 1024 * 1024;
100104
- function errMsg$13(err) {
101925
+ function errMsg$9(err) {
100105
101926
  return err instanceof Error ? err.message : String(err);
100106
101927
  }
100107
101928
  /**
@@ -100116,13 +101937,13 @@ function applyReceiveBuffer(socket, requestedBytes) {
100116
101937
  try {
100117
101938
  socket.setRecvBufferSize(requestedBytes);
100118
101939
  } catch (err) {
100119
- error = errMsg$13(err);
101940
+ error = errMsg$9(err);
100120
101941
  }
100121
101942
  let effectiveBytes = null;
100122
101943
  try {
100123
101944
  effectiveBytes = socket.getRecvBufferSize();
100124
101945
  } catch (err) {
100125
- if (error === null) error = errMsg$13(err);
101946
+ if (error === null) error = errMsg$9(err);
100126
101947
  }
100127
101948
  return {
100128
101949
  requestedBytes,
@@ -100393,20 +102214,20 @@ function buildCameraStreamingDelegate(bctx, advertised) {
100393
102214
  delegate: {
100394
102215
  handleSnapshotRequest(request, callback) {
100395
102216
  handleSnapshot(bctx, request).then((buf) => callback(void 0, buf)).catch((err) => {
100396
- log.warn("export-hap: snapshot failed", { meta: { error: errMsg$12(err) } });
100397
- callback(err instanceof Error ? err : new Error(errMsg$12(err)));
102217
+ log.warn("export-hap: snapshot failed", { meta: { error: errMsg$8(err) } });
102218
+ callback(err instanceof Error ? err : new Error(errMsg$8(err)));
100398
102219
  });
100399
102220
  },
100400
102221
  prepareStream(request, callback) {
100401
102222
  prepareStream(request, sessions, bctx, "classic").then((resp) => callback(void 0, resp)).catch((err) => {
100402
- log.warn("export-hap: prepareStream failed", { meta: { error: errMsg$12(err) } });
100403
- callback(err instanceof Error ? err : new Error(errMsg$12(err)));
102223
+ log.warn("export-hap: prepareStream failed", { meta: { error: errMsg$8(err) } });
102224
+ callback(err instanceof Error ? err : new Error(errMsg$8(err)));
100404
102225
  });
100405
102226
  },
100406
102227
  handleStreamRequest(request, callback) {
100407
102228
  handleStreamRequest(request, sessions, bctx, advertised).then(() => callback()).catch((err) => {
100408
- log.warn("export-hap: handleStreamRequest failed", { meta: { error: errMsg$12(err) } });
100409
- callback(err instanceof Error ? err : new Error(errMsg$12(err)));
102229
+ log.warn("export-hap: handleStreamRequest failed", { meta: { error: errMsg$8(err) } });
102230
+ callback(err instanceof Error ? err : new Error(errMsg$8(err)));
100410
102231
  });
100411
102232
  }
100412
102233
  },
@@ -100545,7 +102366,7 @@ async function prepareStream(request, sessions, bctx, negotiatedVia) {
100545
102366
  closeSocket(audioUdp);
100546
102367
  closeSocket(videoLoopUdp);
100547
102368
  closeSocket(audioLoopUdp);
100548
- throw new Error(`export-hap: outbound SrtpSession init failed: ${errMsg$12(err)}`, { cause: err });
102369
+ throw new Error(`export-hap: outbound SrtpSession init failed: ${errMsg$8(err)}`, { cause: err });
100549
102370
  }
100550
102371
  let upstreamAudioSrtp = null;
100551
102372
  try {
@@ -100559,7 +102380,7 @@ async function prepareStream(request, sessions, bctx, negotiatedVia) {
100559
102380
  profile: import_src.ProtectionProfileAes128CmHmacSha1_80
100560
102381
  });
100561
102382
  } catch (err) {
100562
- bctx.ctx.logger.withTags({ deviceId: bctx.numericDeviceId }).warn("export-hap: SrtpSession init failed (upstream audio decrypt disabled)", { meta: { error: errMsg$12(err) } });
102383
+ bctx.ctx.logger.withTags({ deviceId: bctx.numericDeviceId }).warn("export-hap: SrtpSession init failed (upstream audio decrypt disabled)", { meta: { error: errMsg$8(err) } });
100563
102384
  }
100564
102385
  const videoSsrc = randomSsrc();
100565
102386
  const audioSsrc = randomSsrc();
@@ -100679,7 +102500,7 @@ async function prepareStream(request, sessions, bctx, negotiatedVia) {
100679
102500
  });
100680
102501
  audioUdp.on("message", (packet, rinfo) => {
100681
102502
  handleIncomingAudioRtp(session, packet, rinfo.address, bctx).catch((err) => {
100682
- bctx.ctx.logger.withTags({ deviceId: bctx.numericDeviceId }).debug("export-hap: incoming-audio handler error (dropped)", { meta: { error: errMsg$12(err) } });
102503
+ bctx.ctx.logger.withTags({ deviceId: bctx.numericDeviceId }).debug("export-hap: incoming-audio handler error (dropped)", { meta: { error: errMsg$8(err) } });
100683
102504
  });
100684
102505
  });
100685
102506
  logLoopbackBuffer(tagLog, request.sessionID, "video", videoLoop.buffer);
@@ -100875,7 +102696,7 @@ function readControllerRtcp(session, leg, packet, log) {
100875
102696
  } catch (err) {
100876
102697
  drop(session, "inbound-rtcp-decrypt-failed");
100877
102698
  storeReceiverReports(session, leg, recordUnreadableRtcp(tally));
100878
- logUnreadableRtcp(session, leg, `decrypt: ${errMsg$12(err)}`, log);
102699
+ logUnreadableRtcp(session, leg, `decrypt: ${errMsg$8(err)}`, log);
100879
102700
  return;
100880
102701
  }
100881
102702
  const outcome = ingestDecryptedRtcp(plaintext, tally);
@@ -101693,7 +103514,7 @@ async function handleIncomingAudioRtp(session, packet, sourceAddress, bctx) {
101693
103514
  session.upstreamRtpDecryptFailures += 1;
101694
103515
  if (session.upstreamRtpDecryptFailures % 100 === 1) log.debug("export-hap: SRTP decrypt failed (rate-limited)", { meta: {
101695
103516
  failures: session.upstreamRtpDecryptFailures,
101696
- error: errMsg$12(err)
103517
+ error: errMsg$8(err)
101697
103518
  } });
101698
103519
  return;
101699
103520
  }
@@ -101707,7 +103528,7 @@ async function handleIncomingAudioRtp(session, packet, sourceAddress, bctx) {
101707
103528
  rtpPayloadType = parsedRtp.header.payloadType;
101708
103529
  } catch (err) {
101709
103530
  drop(session, "upstream-parse-failed");
101710
- log.debug("export-hap: RTP parse failed after decrypt", { meta: { error: errMsg$12(err) } });
103531
+ log.debug("export-hap: RTP parse failed after decrypt", { meta: { error: errMsg$8(err) } });
101711
103532
  return;
101712
103533
  }
101713
103534
  const negotiatedAudioPt = session.lastStartParams?.audioPt;
@@ -101731,7 +103552,7 @@ async function handleIncomingAudioRtp(session, packet, sourceAddress, bctx) {
101731
103552
  if (session.intercomTalkSessionId === null) {
101732
103553
  session.intercomTalkSessionId = "";
101733
103554
  const opened = await openIntercomTalkSession(bctx).catch((err) => {
101734
- log.warn("export-hap: intercom.startTalkSession failed", { meta: { error: errMsg$12(err) } });
103555
+ log.warn("export-hap: intercom.startTalkSession failed", { meta: { error: errMsg$8(err) } });
101735
103556
  return null;
101736
103557
  });
101737
103558
  if (opened) {
@@ -101758,7 +103579,7 @@ async function handleIncomingAudioRtp(session, packet, sourceAddress, bctx) {
101758
103579
  drop(session, "upstream-push-failed");
101759
103580
  log.debug("export-hap: intercom.pushTalkAudio failed (will re-open on next frame)", { meta: {
101760
103581
  sequenceNumber: session.intercomPcmSequence,
101761
- error: errMsg$12(err)
103582
+ error: errMsg$8(err)
101762
103583
  } });
101763
103584
  session.intercomTalkSessionId = null;
101764
103585
  }
@@ -101790,7 +103611,7 @@ async function closeIntercomTalkSession(session, bctx) {
101790
103611
  } catch (err) {
101791
103612
  log.debug("export-hap: intercom.endTalkSession failed (continuing)", { meta: {
101792
103613
  sessionId,
101793
- error: errMsg$12(err)
103614
+ error: errMsg$8(err)
101794
103615
  } });
101795
103616
  }
101796
103617
  }
@@ -101815,7 +103636,7 @@ function closeSocket(udp) {
101815
103636
  function randomSsrc() {
101816
103637
  return Math.floor(Math.random() * 2147483646) + 1 | 0;
101817
103638
  }
101818
- function errMsg$12(err) {
103639
+ function errMsg$8(err) {
101819
103640
  return err instanceof Error ? err.message : String(err);
101820
103641
  }
101821
103642
  //#endregion
@@ -101899,14 +103720,14 @@ async function buildDoorbell(input) {
101899
103720
  }
101900
103721
  log.info("export-hap: doorbell SINGLE_PRESS pushed to HomeKit", { meta: { ...delivery } });
101901
103722
  } catch (err) {
101902
- log.warn("export-hap: ringDoorbell() failed", { meta: { error: errMsg$11(err) } });
103723
+ log.warn("export-hap: ringDoorbell() failed", { meta: { error: errMsg$7(err) } });
101903
103724
  }
101904
103725
  });
101905
103726
  return { async dispose() {
101906
103727
  unsubscribe();
101907
103728
  } };
101908
103729
  }
101909
- function errMsg$11(err) {
103730
+ function errMsg$7(err) {
101910
103731
  return err instanceof Error ? err.message : String(err);
101911
103732
  }
101912
103733
  //#endregion
@@ -101954,7 +103775,7 @@ async function buildMotionSensor(bctx, existing = null) {
101954
103775
  const detected = await proxy.motion?.isDetected({});
101955
103776
  if (typeof detected === "boolean") motionService.updateCharacteristic(import_dist.Characteristic.MotionDetected, detected);
101956
103777
  } catch (err) {
101957
- ctx.logger.withTags({ deviceId: numericDeviceId }).debug("export-hap: initial motion hydrate failed (non-fatal)", { meta: { error: errMsg$10(err) } });
103778
+ ctx.logger.withTags({ deviceId: numericDeviceId }).debug("export-hap: initial motion hydrate failed (non-fatal)", { meta: { error: errMsg$6(err) } });
101958
103779
  }
101959
103780
  let resetTimer = null;
101960
103781
  const armReset = () => {
@@ -101988,7 +103809,7 @@ async function buildMotionSensor(bctx, existing = null) {
101988
103809
  }
101989
103810
  } };
101990
103811
  }
101991
- function errMsg$10(err) {
103812
+ function errMsg$6(err) {
101992
103813
  return err instanceof Error ? err.message : String(err);
101993
103814
  }
101994
103815
  //#endregion
@@ -102019,7 +103840,7 @@ async function buildPrivacySwitch(bctx) {
102019
103840
  const status = await proxy.privacyMask?.getStatus({});
102020
103841
  if (status && typeof status.enabled === "boolean") service.updateCharacteristic(import_dist.Characteristic.On, status.enabled);
102021
103842
  } catch (err) {
102022
- log.debug("export-hap: privacy-mask getStatus hydrate failed (non-fatal)", { meta: { error: errMsg$9(err) } });
103843
+ log.debug("export-hap: privacy-mask getStatus hydrate failed (non-fatal)", { meta: { error: errMsg$5(err) } });
102023
103844
  }
102024
103845
  service.getCharacteristic(import_dist.Characteristic.On).onSet(async (value) => {
102025
103846
  const enabled = value === true;
@@ -102028,7 +103849,7 @@ async function buildPrivacySwitch(bctx) {
102028
103849
  } catch (err) {
102029
103850
  log.warn("export-hap: privacy-mask setMask failed", { meta: {
102030
103851
  enabled,
102031
- error: errMsg$9(err)
103852
+ error: errMsg$5(err)
102032
103853
  } });
102033
103854
  }
102034
103855
  });
@@ -102046,7 +103867,7 @@ async function buildPrivacySwitch(bctx) {
102046
103867
  } catch {}
102047
103868
  } };
102048
103869
  }
102049
- function errMsg$9(err) {
103870
+ function errMsg$5(err) {
102050
103871
  return err instanceof Error ? err.message : String(err);
102051
103872
  }
102052
103873
  //#endregion
@@ -102137,7 +103958,7 @@ async function buildPtz(bctx) {
102137
103958
  } catch (err) {
102138
103959
  log.warn("export-hap: ptz.goToPreset failed", { meta: {
102139
103960
  presetId: preset.id,
102140
- error: errMsg$8(err)
103961
+ error: errMsg$4(err)
102141
103962
  } });
102142
103963
  }
102143
103964
  armReset(() => service.updateCharacteristic(import_dist.Characteristic.On, false), MOMENTARY_RESET_MS);
@@ -102155,14 +103976,14 @@ async function buildPtz(bctx) {
102155
103976
  try {
102156
103977
  await proxy.ptz?.stop({});
102157
103978
  } catch (err) {
102158
- log.debug("export-hap: ptz.stop failed (non-fatal)", { meta: { error: errMsg$8(err) } });
103979
+ log.debug("export-hap: ptz.stop failed (non-fatal)", { meta: { error: errMsg$4(err) } });
102159
103980
  }
102160
103981
  service.updateCharacteristic(import_dist.Characteristic.On, false);
102161
103982
  }, options.ptzPulseMs);
102162
103983
  } catch (err) {
102163
103984
  log.warn("export-hap: ptz.continuousMove failed", { meta: {
102164
103985
  dir: dir.label,
102165
- error: errMsg$8(err)
103986
+ error: errMsg$4(err)
102166
103987
  } });
102167
103988
  service.updateCharacteristic(import_dist.Characteristic.On, false);
102168
103989
  }
@@ -102194,7 +104015,7 @@ async function readPresets(bctx) {
102194
104015
  }
102195
104016
  return entries;
102196
104017
  } catch (err) {
102197
- ctx.logger.withTags({ deviceId: numericDeviceId }).debug("export-hap: ptz.getPresets failed (non-fatal)", { meta: { error: errMsg$8(err) } });
104018
+ ctx.logger.withTags({ deviceId: numericDeviceId }).debug("export-hap: ptz.getPresets failed (non-fatal)", { meta: { error: errMsg$4(err) } });
102198
104019
  return [];
102199
104020
  }
102200
104021
  }
@@ -102209,7 +104030,7 @@ async function tryBuildAutotrack(bctx) {
102209
104030
  const status = await proxy.ptzAutotrack.getStatus({});
102210
104031
  if (status && typeof status.enabled === "boolean") service.updateCharacteristic(import_dist.Characteristic.On, status.enabled);
102211
104032
  } catch (err) {
102212
- log.debug("export-hap: ptzAutotrack.getStatus failed (non-fatal)", { meta: { error: errMsg$8(err) } });
104033
+ log.debug("export-hap: ptzAutotrack.getStatus failed (non-fatal)", { meta: { error: errMsg$4(err) } });
102213
104034
  }
102214
104035
  service.getCharacteristic(import_dist.Characteristic.On).onSet(async (value) => {
102215
104036
  const enabled = value === true;
@@ -102218,16 +104039,218 @@ async function tryBuildAutotrack(bctx) {
102218
104039
  } catch (err) {
102219
104040
  log.warn("export-hap: ptzAutotrack.setEnabled failed", { meta: {
102220
104041
  enabled,
102221
- error: errMsg$8(err)
104042
+ error: errMsg$4(err)
102222
104043
  } });
102223
104044
  }
102224
104045
  });
102225
104046
  return { async dispose() {} };
102226
104047
  }
102227
- function errMsg$8(err) {
104048
+ function errMsg$4(err) {
102228
104049
  return err instanceof Error ? err.message : String(err);
102229
104050
  }
102230
104051
  //#endregion
104052
+ //#region src/hksv/clip-writer.ts
104053
+ var HksvClipWriter = class {
104054
+ input;
104055
+ log;
104056
+ started = false;
104057
+ settle = null;
104058
+ outcome;
104059
+ bytes = 0;
104060
+ fragments = 0;
104061
+ sawInit = false;
104062
+ truncated = null;
104063
+ startedAtMs;
104064
+ constructor(input) {
104065
+ this.input = input;
104066
+ this.log = input.logger;
104067
+ this.startedAtMs = input.now();
104068
+ this.outcome = new Promise((resolve) => {
104069
+ this.settle = resolve;
104070
+ });
104071
+ }
104072
+ /** Resolves once the file is closed. NEVER rejects — every failure is named. */
104073
+ get done() {
104074
+ return this.outcome;
104075
+ }
104076
+ /** Launch the loop. Returns immediately: the delegate awaits nothing here. */
104077
+ start() {
104078
+ if (this.started) return;
104079
+ this.started = true;
104080
+ this.run();
104081
+ }
104082
+ /**
104083
+ * The HDS stream ended. Releases the subscription, which drains whatever is
104084
+ * already queued and then completes the loop. Idempotent, synchronous, and
104085
+ * safe to call from the delegate's `finally`.
104086
+ */
104087
+ finish() {
104088
+ this.input.subscription.release();
104089
+ }
104090
+ async run() {
104091
+ const { subscription, sink } = this.input;
104092
+ const tags = { deviceId: this.input.deviceId };
104093
+ try {
104094
+ for await (const packet of subscription.packets()) {
104095
+ if (packet.data.length === 0) continue;
104096
+ if (this.bytes + packet.data.length > this.input.maxBytes) {
104097
+ this.truncated = "size-cap";
104098
+ this.log.warn("hksv clip tee: the per-clip size ceiling was reached — TRUNCATING", {
104099
+ tags,
104100
+ meta: {
104101
+ streamId: this.input.streamId,
104102
+ bytes: this.bytes,
104103
+ maxBytes: this.input.maxBytes,
104104
+ fragments: this.fragments
104105
+ }
104106
+ });
104107
+ break;
104108
+ }
104109
+ await sink.write(packet.data);
104110
+ this.bytes += packet.data.length;
104111
+ if (packet.kind === "init") this.sawInit = true;
104112
+ else this.fragments += 1;
104113
+ }
104114
+ if (this.truncated === null && subscription.closedReason === "slow-consumer") {
104115
+ this.truncated = "slow-consumer";
104116
+ this.log.warn("hksv clip tee: the plane closed the tee for falling behind — the clip is TRUNCATED (HomeKit recording unaffected)", {
104117
+ tags,
104118
+ meta: {
104119
+ streamId: this.input.streamId,
104120
+ bytes: this.bytes,
104121
+ fragments: this.fragments
104122
+ }
104123
+ });
104124
+ }
104125
+ } catch (err) {
104126
+ this.truncated = "write-failed";
104127
+ this.log.warn("hksv clip tee: writing the clip FAILED — the HomeKit recording is unaffected", {
104128
+ tags,
104129
+ meta: {
104130
+ streamId: this.input.streamId,
104131
+ bytes: this.bytes,
104132
+ fragments: this.fragments,
104133
+ error: err instanceof Error ? err.message : String(err)
104134
+ }
104135
+ });
104136
+ } finally {
104137
+ subscription.release();
104138
+ try {
104139
+ await sink.close();
104140
+ } catch (err) {
104141
+ if (this.truncated === null) this.truncated = "write-failed";
104142
+ this.log.warn("hksv clip tee: closing the clip file failed", {
104143
+ tags,
104144
+ meta: {
104145
+ streamId: this.input.streamId,
104146
+ error: err instanceof Error ? err.message : String(err)
104147
+ }
104148
+ });
104149
+ }
104150
+ this.resolve();
104151
+ }
104152
+ }
104153
+ resolve() {
104154
+ const settle = this.settle;
104155
+ this.settle = null;
104156
+ settle?.({
104157
+ bytes: this.bytes,
104158
+ fragments: this.fragments,
104159
+ sawInit: this.sawInit,
104160
+ truncated: this.truncated,
104161
+ startedAtMs: this.startedAtMs,
104162
+ endedAtMs: this.input.now()
104163
+ });
104164
+ }
104165
+ };
104166
+ //#endregion
104167
+ //#region src/hksv/clip-tee.ts
104168
+ function createClipTeeFactory(input) {
104169
+ return (request) => new ClipTeeRunner(input, request);
104170
+ }
104171
+ var ClipTeeRunner = class {
104172
+ input;
104173
+ request;
104174
+ writer = null;
104175
+ /** `finish()` arriving before the file is open — the short stream case. */
104176
+ finishPending = false;
104177
+ acknowledgePending = false;
104178
+ clipId = null;
104179
+ completed;
104180
+ constructor(input, request) {
104181
+ this.input = input;
104182
+ this.request = request;
104183
+ this.completed = this.run();
104184
+ }
104185
+ finish() {
104186
+ this.finishPending = true;
104187
+ this.writer?.finish();
104188
+ }
104189
+ acknowledge() {
104190
+ this.acknowledgePending = true;
104191
+ this.completed.then(() => this.persistAcknowledgement());
104192
+ }
104193
+ async run() {
104194
+ const { store, deviceId } = this.input;
104195
+ const now = this.input.now ?? Date.now;
104196
+ const tags = { deviceId };
104197
+ const open = await store.open({
104198
+ deviceId,
104199
+ streamId: this.request.streamId,
104200
+ startedAtMs: now(),
104201
+ prebufferSpanMs: this.request.prebufferSpanMs,
104202
+ width: this.input.width,
104203
+ height: this.input.height,
104204
+ fragmentMs: this.input.fragmentMs
104205
+ });
104206
+ if (open === null) {
104207
+ this.request.subscription.release();
104208
+ return;
104209
+ }
104210
+ this.clipId = open.clipId;
104211
+ const writer = new HksvClipWriter({
104212
+ logger: this.input.logger,
104213
+ deviceId,
104214
+ streamId: this.request.streamId,
104215
+ subscription: this.request.subscription,
104216
+ sink: open.sink,
104217
+ maxBytes: store.maxClipBytes,
104218
+ now
104219
+ });
104220
+ this.writer = writer;
104221
+ writer.start();
104222
+ if (this.finishPending) writer.finish();
104223
+ const outcome = await writer.done;
104224
+ try {
104225
+ await open.complete(outcome);
104226
+ } catch (err) {
104227
+ this.input.logger.warn("hksv clip tee: the clip could not be completed", {
104228
+ tags,
104229
+ meta: {
104230
+ streamId: this.request.streamId,
104231
+ error: err instanceof Error ? err.message : String(err)
104232
+ }
104233
+ });
104234
+ }
104235
+ }
104236
+ async persistAcknowledgement() {
104237
+ const clipId = this.clipId;
104238
+ if (clipId === null || !this.acknowledgePending) return;
104239
+ this.acknowledgePending = false;
104240
+ try {
104241
+ await this.input.store.acknowledge(this.input.deviceId, clipId);
104242
+ } catch (err) {
104243
+ this.input.logger.warn("hksv clip tee: recording the acknowledgement failed", {
104244
+ tags: { deviceId: this.input.deviceId },
104245
+ meta: {
104246
+ clipId,
104247
+ error: err instanceof Error ? err.message : String(err)
104248
+ }
104249
+ });
104250
+ }
104251
+ }
104252
+ };
104253
+ //#endregion
102231
104254
  //#region src/hksv/controller-census.ts
102232
104255
  var EMPTY_CENSUS = {
102233
104256
  serverPublished: false,
@@ -102549,8 +104572,13 @@ var HksvFragmentSource = class {
102549
104572
  * Returns `null` when there is no plane — the caller MUST treat that as
102550
104573
  * "cannot serve this recording" rather than opening an HDS stream it cannot
102551
104574
  * feed, which is the D50 failure exactly.
104575
+ *
104576
+ * Called ONCE PER CONSUMER, never shared: the delegate takes one for HomeKit
104577
+ * and the clip tee takes a second. `queueCapacity` is how much slack this
104578
+ * subscriber gets before the plane closes it (D80 closes, never gaps) — the
104579
+ * tee asks for less than HomeKit's stream on purpose.
102552
104580
  */
102553
- subscribe(tag) {
104581
+ subscribe(tag, queueCapacity) {
102554
104582
  const plane = this.plane;
102555
104583
  if (plane === null || plane.isEnded) return null;
102556
104584
  const stats = plane.prebufferStats();
@@ -102565,7 +104593,8 @@ var HksvFragmentSource = class {
102565
104593
  });
102566
104594
  return plane.subscribe({
102567
104595
  tag,
102568
- withPrebuffer: true
104596
+ withPrebuffer: true,
104597
+ ...queueCapacity === void 0 ? {} : { queueCapacity }
102569
104598
  });
102570
104599
  }
102571
104600
  /** What the ring holds — surfaced so the delegate can log what it served. */
@@ -102689,6 +104718,12 @@ var HksvFragmentSource = class {
102689
104718
  //#endregion
102690
104719
  //#region src/hksv/recording-delegate.ts
102691
104720
  /**
104721
+ * How many finished clips may wait for an acknowledgement iOS may never send.
104722
+ * Eight is more concurrent HKSV streams than one camera ever has; the number
104723
+ * exists so the map cannot grow, not to be tuned.
104724
+ */
104725
+ var MAX_TEES_AWAITING_ACK = 8;
104726
+ /**
102692
104727
  * `HDSProtocolSpecificErrorReason` is a `const enum`, so there is no reverse
102693
104728
  * map to index — and a bare number in the log is the difference between "iOS
102694
104729
  * closed it normally" and "iOS rejected our data", which is the whole reason
@@ -102717,6 +104752,12 @@ var HksvRecordingDelegate = class {
102717
104752
  log;
102718
104753
  /** The stream currently being yielded, so `closeRecordingStream` can end it. */
102719
104754
  open = null;
104755
+ /**
104756
+ * Tees whose stream has ended, still waiting for iOS's `ack`. BOUNDED: the
104757
+ * ack may never come (a dropped connection looks identical), and a map that
104758
+ * only ever grows is the unbounded buffer D9/D18 names in its smallest form.
104759
+ */
104760
+ tees = /* @__PURE__ */ new Map();
102720
104761
  constructor(input) {
102721
104762
  this.input = input;
102722
104763
  this.log = input.logger;
@@ -102797,6 +104838,7 @@ var HksvRecordingDelegate = class {
102797
104838
  };
102798
104839
  const startedAt = this.input.now();
102799
104840
  const prebufferSpanMs = source.prebufferSpanMs();
104841
+ const tee = this.openTee(streamId, source, prebufferSpanMs);
102800
104842
  let packets = 0;
102801
104843
  let bytes = 0;
102802
104844
  let markedLast = false;
@@ -102848,6 +104890,7 @@ var HksvRecordingDelegate = class {
102848
104890
  }
102849
104891
  } finally {
102850
104892
  subscription.release();
104893
+ this.finishTee(streamId, tee);
102851
104894
  if (this.open?.streamId === streamId) this.open = null;
102852
104895
  const fragmentOverrun = longestFragmentGapMs > this.input.advertisedFragmentMs * 1.5;
102853
104896
  this.log.info("hksv: recording stream finished", {
@@ -102878,6 +104921,20 @@ var HksvRecordingDelegate = class {
102878
104921
  tags: { deviceId: this.input.deviceId },
102879
104922
  meta: { streamId }
102880
104923
  });
104924
+ const tee = this.tees.get(streamId);
104925
+ this.tees.delete(streamId);
104926
+ if (tee === void 0) return;
104927
+ try {
104928
+ tee.acknowledge();
104929
+ } catch (err) {
104930
+ this.log.warn("hksv clip tee: recording the acknowledgement failed", {
104931
+ tags: { deviceId: this.input.deviceId },
104932
+ meta: {
104933
+ streamId,
104934
+ error: err instanceof Error ? err.message : String(err)
104935
+ }
104936
+ });
104937
+ }
102881
104938
  }
102882
104939
  closeRecordingStream(streamId, reason) {
102883
104940
  this.log.info("hksv: the recording stream was closed by the controller", {
@@ -102897,11 +104954,82 @@ var HksvRecordingDelegate = class {
102897
104954
  async dispose() {
102898
104955
  this.open?.subscription.release();
102899
104956
  this.open = null;
104957
+ this.tees.clear();
102900
104958
  const source = this.source;
102901
104959
  this.source = null;
102902
104960
  if (source) await source.stop("accessory disposed");
102903
104961
  }
102904
104962
  /**
104963
+ * A second subscription on the same plane, handed to the tee.
104964
+ *
104965
+ * Every failure here returns `null`: no tee, a warn naming the camera, and a
104966
+ * HomeKit recording that proceeds exactly as it would have. The one thing
104967
+ * this must never do is let a clip problem reach the generator above.
104968
+ */
104969
+ openTee(streamId, source, prebufferSpanMs) {
104970
+ const factory = this.input.openClipTee;
104971
+ if (factory === void 0) return null;
104972
+ let subscription = null;
104973
+ try {
104974
+ subscription = source.subscribe(`clip/${this.input.deviceId}#${streamId}`, 2);
104975
+ if (subscription === null) {
104976
+ this.log.warn("hksv clip tee: the plane refused a second subscription — this clip is NOT kept", {
104977
+ tags: { deviceId: this.input.deviceId },
104978
+ meta: { streamId }
104979
+ });
104980
+ return null;
104981
+ }
104982
+ const tee = factory({
104983
+ streamId,
104984
+ subscription,
104985
+ prebufferSpanMs
104986
+ });
104987
+ if (tee === null) {
104988
+ subscription.release();
104989
+ return null;
104990
+ }
104991
+ this.rememberTee(streamId, tee);
104992
+ return tee;
104993
+ } catch (err) {
104994
+ subscription?.release();
104995
+ this.log.warn("hksv clip tee: could NOT be opened — the HomeKit recording continues untouched", {
104996
+ tags: { deviceId: this.input.deviceId },
104997
+ meta: {
104998
+ streamId,
104999
+ error: err instanceof Error ? err.message : String(err)
105000
+ }
105001
+ });
105002
+ return null;
105003
+ }
105004
+ }
105005
+ finishTee(streamId, tee) {
105006
+ if (tee === null) return;
105007
+ try {
105008
+ tee.finish();
105009
+ } catch (err) {
105010
+ this.log.warn("hksv clip tee: closing the clip failed", {
105011
+ tags: { deviceId: this.input.deviceId },
105012
+ meta: {
105013
+ streamId,
105014
+ error: err instanceof Error ? err.message : String(err)
105015
+ }
105016
+ });
105017
+ }
105018
+ }
105019
+ /**
105020
+ * Hold the tee until iOS acknowledges, dropping the oldest once the map is
105021
+ * full. An ack that never arrives is the normal case for a dropped
105022
+ * connection, so this map's size is the only thing bounding it.
105023
+ */
105024
+ rememberTee(streamId, tee) {
105025
+ this.tees.set(streamId, tee);
105026
+ while (this.tees.size > MAX_TEES_AWAITING_ACK) {
105027
+ const oldest = this.tees.keys().next();
105028
+ if (oldest.done === true) break;
105029
+ this.tees.delete(oldest.value);
105030
+ }
105031
+ }
105032
+ /**
102905
105033
  * Start the prebuffer when iOS wants recording AND has chosen how, stop it
102906
105034
  * otherwise. Called from every state edge rather than each edge deciding for
102907
105035
  * itself: the two characteristics arrive in an order hap-nodejs explicitly
@@ -103071,10 +105199,20 @@ async function buildHksvRecording(input) {
103071
105199
  fps,
103072
105200
  fragmentLengthMs
103073
105201
  });
105202
+ const clipStore = bctx.options.hksvClipStore;
105203
+ const openClipTee = clipStore === null ? void 0 : createClipTeeFactory({
105204
+ logger: log,
105205
+ deviceId: numericDeviceId,
105206
+ store: clipStore,
105207
+ width: source.width,
105208
+ height: source.height,
105209
+ fragmentMs: fragmentLengthMs
105210
+ });
103074
105211
  const delegate = new HksvRecordingDelegate({
103075
105212
  logger: log,
103076
105213
  deviceId: numericDeviceId,
103077
105214
  isAudioActive: input.isAudioActive,
105215
+ ...openClipTee === void 0 ? {} : { openClipTee },
103078
105216
  advertisedFragmentMs: fragmentLengthMs,
103079
105217
  now: () => Date.now(),
103080
105218
  describeControllers: () => describeHapControllers(bctx.accessory),
@@ -103096,7 +105234,8 @@ async function buildHksvRecording(input) {
103096
105234
  measuredFps: fps,
103097
105235
  advertisedFps: advertisedResolution?.[2] ?? null,
103098
105236
  fragmentLengthMs,
103099
- sourceGopMs: gopMs ?? "unknown"
105237
+ sourceGopMs: gopMs ?? "unknown",
105238
+ clipsKept: openClipTee !== void 0
103100
105239
  } });
103101
105240
  return {
103102
105241
  options,
@@ -103121,133 +105260,30 @@ async function readProfileEntries(bctx) {
103121
105260
  * on 615 `ext` is the 1280×720 slot the broker calls `mid` — a name-based match
103122
105261
  * would silently read the 4K slot's GOP for a 720p recording.
103123
105262
  *
103124
- * `undefined` when the cap is not bound, which is most non-Hikvision providers.
103125
- */
103126
- async function readSourceGopMs(bctx, width, height) {
103127
- try {
103128
- const status = await bctx.proxy.streamParams?.getStatus({});
103129
- if (!status) return void 0;
103130
- for (const profile of [
103131
- status.main,
103132
- status.sub,
103133
- status.ext
103134
- ]) {
103135
- if (!profile) continue;
103136
- if (profile.width !== width || profile.height !== height) continue;
103137
- const { gop, framerate } = profile;
103138
- if (gop === void 0 || gop <= 0 || framerate <= 0) return void 0;
103139
- return Math.round(gop / framerate * 1e3);
103140
- }
103141
- return;
103142
- } catch {
103143
- return;
103144
- }
103145
- }
103146
- function resolveFps(fpsByProfile, profile) {
103147
- return fpsByProfile.get(profile)?.fps ?? ASSUMED_RECORDING_FPS;
103148
- }
103149
- //#endregion
103150
- //#region src/mappers/builders/child-switch.ts
103151
- /**
103152
- * Child-switch builder — turns a camstack accessory child device (siren,
103153
- * floodlight, spotlight, …) into a HomeKit Service ON THE PARENT
103154
- * camera Accessory.
103155
- *
103156
- * The orchestrator (`child-accessory.ts → buildChildServicesFor`)
103157
- * hands this builder the PARENT accessory + the CHILD's proxy + a
103158
- * unique subtype so each service round-trips deterministically through
103159
- * HAP serialisation. The builder:
103160
- * - Maps `switch` cap → `Service.Switch`.
103161
- * - Maps `switch` + `brightness` cap → `Service.Lightbulb` with
103162
- * Brightness characteristic (lighting devices only — see below).
103163
- * - Hydrates initial state from `getStatus({})`.
103164
- * - Wires `Characteristic.On` / `Characteristic.Brightness` through
103165
- * the typed DeviceProxy.
103166
- * - Subscribes to runtime-state slice updates so iOS Home reflects
103167
- * external state changes (e.g. another integration toggling the
103168
- * light).
103169
- *
103170
- * Service-kind selection: most cameras expose `brightness` on a siren
103171
- * because it controls the alarm VOLUME, not luminosity. Mapping a
103172
- * siren onto `Service.Lightbulb` makes it appear as a regular light in
103173
- * iOS Home (wrong icon, wrong scenes, wrong Siri intents). We therefore
103174
- * gate `Service.Lightbulb` on the child's `DeviceType` — only Light /
103175
- * Generic devices keep the Lightbulb shape; Siren, Switch, and others
103176
- * fall back to `Service.Switch`. Volume control for sirens is exposed
103177
- * through the camstack admin UI; iOS Home only needs the on/off toggle.
103178
- *
103179
- * Child devices without `switch` / `brightness` cap (status sensors,
103180
- * IR LED, etc.) return a no-op handle.
105263
+ * `undefined` when the cap is not bound, which is most non-Hikvision providers.
103181
105264
  */
103182
- async function buildChildSwitch(bctx, subtype, deviceType) {
103183
- const { ctx, accessory, proxy, numericDeviceId, displayName } = bctx;
103184
- const log = ctx.logger.withTags({ deviceId: numericDeviceId });
103185
- const hasSwitch = !!proxy.switch;
103186
- const hasBrightness = !!proxy.brightness;
103187
- if (!hasSwitch && !hasBrightness) {
103188
- log.debug("export-hap: child has no switch/brightness cap — skipping");
103189
- return { async dispose() {} };
103190
- }
103191
- const isLightingDevice = deviceType === DeviceType.Light || deviceType === DeviceType.Generic;
103192
- const useLightbulb = hasBrightness && isLightingDevice;
103193
- const service = useLightbulb ? accessory.addService(import_dist.Service.Lightbulb, displayName, subtype) : accessory.addService(import_dist.Service.Switch, displayName, subtype);
103194
- applyServiceLabel(service, displayName);
105265
+ async function readSourceGopMs(bctx, width, height) {
103195
105266
  try {
103196
- const switchStatus = await proxy.switch?.getStatus({});
103197
- if (switchStatus && typeof switchStatus.on === "boolean") service.updateCharacteristic(import_dist.Characteristic.On, switchStatus.on);
103198
- } catch (err) {
103199
- log.debug("export-hap: child switch.getStatus failed (non-fatal)", { meta: { error: errMsg$7(err) } });
103200
- }
103201
- if (useLightbulb) try {
103202
- const status = await proxy.brightness?.getStatus({});
103203
- if (status && typeof status.percentage === "number") service.updateCharacteristic(import_dist.Characteristic.Brightness, status.percentage);
103204
- } catch (err) {
103205
- log.debug("export-hap: child brightness.getStatus failed (non-fatal)", { meta: { error: errMsg$7(err) } });
103206
- }
103207
- if (hasSwitch) service.getCharacteristic(import_dist.Characteristic.On).onSet(async (value) => {
103208
- const on = value === true;
103209
- try {
103210
- await proxy.switch?.setState({ on });
103211
- } catch (err) {
103212
- log.warn("export-hap: child switch.setState failed", { meta: {
103213
- on,
103214
- error: errMsg$7(err)
103215
- } });
103216
- }
103217
- });
103218
- if (useLightbulb) service.getCharacteristic(import_dist.Characteristic.Brightness).onSet(async (value) => {
103219
- const percentage = typeof value === "number" ? value : Number(value);
103220
- if (!Number.isFinite(percentage)) return;
103221
- try {
103222
- await proxy.brightness?.setBrightness({ percentage });
103223
- } catch (err) {
103224
- log.warn("export-hap: child brightness.setBrightness failed", { meta: {
103225
- percentage,
103226
- error: errMsg$7(err)
103227
- } });
105267
+ const status = await bctx.proxy.streamParams?.getStatus({});
105268
+ if (!status) return void 0;
105269
+ for (const profile of [
105270
+ status.main,
105271
+ status.sub,
105272
+ status.ext
105273
+ ]) {
105274
+ if (!profile) continue;
105275
+ if (profile.width !== width || profile.height !== height) continue;
105276
+ const { gop, framerate } = profile;
105277
+ if (gop === void 0 || gop <= 0 || framerate <= 0) return void 0;
105278
+ return Math.round(gop / framerate * 1e3);
103228
105279
  }
103229
- });
103230
- const unsubscribes = [];
103231
- if (proxy.state.switch) {
103232
- const unsubSwitch = proxy.state.switch.subscribe((value) => {
103233
- if (value && typeof value.on === "boolean") service.updateCharacteristic(import_dist.Characteristic.On, value.on);
103234
- });
103235
- unsubscribes.push(unsubSwitch);
103236
- }
103237
- if (useLightbulb && proxy.state.brightness) {
103238
- const unsubBrightness = proxy.state.brightness.subscribe((value) => {
103239
- if (value && typeof value.percentage === "number") service.updateCharacteristic(import_dist.Characteristic.Brightness, value.percentage);
103240
- });
103241
- unsubscribes.push(unsubBrightness);
105280
+ return;
105281
+ } catch {
105282
+ return;
103242
105283
  }
103243
- return { async dispose() {
103244
- for (const u of unsubscribes) try {
103245
- u();
103246
- } catch {}
103247
- } };
103248
105284
  }
103249
- function errMsg$7(err) {
103250
- return err instanceof Error ? err.message : String(err);
105285
+ function resolveFps(fpsByProfile, profile) {
105286
+ return fpsByProfile.get(profile)?.fps ?? ASSUMED_RECORDING_FPS;
103251
105287
  }
103252
105288
  //#endregion
103253
105289
  //#region src/mappers/child-accessory.ts
@@ -103273,7 +105309,7 @@ async function buildChildServicesFor(input) {
103273
105309
  for (const h of handles) try {
103274
105310
  await h.dispose();
103275
105311
  } catch (err) {
103276
- ctx.logger.withTags({ deviceId: parentNumericId }).debug("export-hap: child service dispose failed (continuing)", { meta: { error: errMsg$6(err) } });
105312
+ ctx.logger.withTags({ deviceId: parentNumericId }).debug("export-hap: child service dispose failed (continuing)", { meta: { error: errMsg$3(err) } });
103277
105313
  }
103278
105314
  } };
103279
105315
  }
@@ -103289,7 +105325,7 @@ async function listChildren(ctx, parentNumericId) {
103289
105325
  features: Array.isArray(c.features) ? c.features.filter((f) => typeof f === "string") : []
103290
105326
  }));
103291
105327
  } catch (err) {
103292
- ctx.logger.withTags({ deviceId: parentNumericId }).debug("export-hap: deviceManager.getChildren failed (non-fatal)", { meta: { error: errMsg$6(err) } });
105328
+ ctx.logger.withTags({ deviceId: parentNumericId }).debug("export-hap: deviceManager.getChildren failed (non-fatal)", { meta: { error: errMsg$3(err) } });
103293
105329
  return [];
103294
105330
  }
103295
105331
  }
@@ -103305,7 +105341,7 @@ function asDeviceType(raw) {
103305
105341
  for (const value of Object.values(DeviceType)) if (value === lower) return value;
103306
105342
  return DeviceType.Generic;
103307
105343
  }
103308
- function errMsg$6(err) {
105344
+ function errMsg$3(err) {
103309
105345
  return err instanceof Error ? err.message : String(err);
103310
105346
  }
103311
105347
  //#endregion
@@ -103902,1035 +105938,389 @@ function buildMultiTierServices(input) {
103902
105938
  logger.info("hap multi-tier: refused a setup-endpoints write", {
103903
105939
  tags: { deviceId },
103904
105940
  meta: { reason: input.negotiation === void 0 ? "no-negotiator" : "not-a-string" }
103905
- });
103906
- return;
103907
- }
103908
- await input.negotiation.handleSetupEndpointsWrite(value);
103909
- });
103910
- streamManagement.addCharacteristic(setupEndpoints);
103911
- const streamingControl = new import_dist.Characteristic("RTP Streaming Control", CHAR_RTP_STREAMING_CONTROL, {
103912
- format: import_dist.Formats.TLV8,
103913
- perms: [import_dist.Perms.PAIRED_READ, import_dist.Perms.PAIRED_WRITE]
103914
- });
103915
- streamingControl.onGet(() => {
103916
- note("rtp-streaming-control");
103917
- return input.negotiation?.readStreamingControl() ?? encodeRtpStreamingControlStatus(null, RtpStreamingStatus.ERROR);
103918
- });
103919
- streamingControl.onSet(async (value) => {
103920
- if (input.negotiation === void 0 || typeof value !== "string") {
103921
- logger.info("hap multi-tier: refused an rtp streaming control write", {
103922
- tags: { deviceId },
103923
- meta: { reason: input.negotiation === void 0 ? "no-negotiator" : "not-a-string" }
103924
- });
103925
- return;
103926
- }
103927
- await input.negotiation.handleStreamingControlWrite(value);
103928
- });
103929
- streamManagement.addCharacteristic(streamingControl);
103930
- const sensor = new import_dist.Characteristic("Sensor UUID", CHAR_SENSOR_UUID, {
103931
- format: import_dist.Formats.DATA,
103932
- perms: [import_dist.Perms.PAIRED_READ]
103933
- });
103934
- const sensorValue = sensorUuid.toString("base64");
103935
- sensor.onGet(() => {
103936
- note("sensor-uuid");
103937
- return sensorValue;
103938
- });
103939
- streamManagement.addCharacteristic(sensor);
103940
- return {
103941
- capabilities,
103942
- globalOperatingMode,
103943
- streamManagement
103944
- };
103945
- }
103946
- //#endregion
103947
- //#region src/multi-tier/negotiation.ts
103948
- /**
103949
- * The adapter between Apple's new characteristics and the streaming engine this
103950
- * addon already has.
103951
- *
103952
- * ## Why an adapter and not a second engine
103953
- *
103954
- * The multi-tier RTP service owns its OWN `Setup Endpoints` and `RTP Streaming
103955
- * Control`, so hap-nodejs's `CameraController` never negotiates for it — and
103956
- * the library's own handlers are all `private`, so its engine cannot be
103957
- * borrowed either. But the sockets, the SRTP contexts, the ffmpeg pipeline and
103958
- * the teardown are the same ones the classic service uses. A second copy would
103959
- * be two engines to keep in agreement, and this repo has paid for that shape
103960
- * before. So: new characteristics, one engine, through `StreamNegotiator`.
103961
- *
103962
- * ## What is different about the new flow
103963
- *
103964
- * The classic `Selected RTP Stream Configuration` carries the whole selected
103965
- * configuration in its start — codec, profile, level, resolution, bitrate.
103966
- * `RTP Streaming Control` carries a **tier identifier**, because the
103967
- * configuration was agreed when we published `Supported Video Stream Tiers`.
103968
- * So this maps an identifier back to the tier WE published, and a tier we never
103969
- * published is `No Such Stream` — improvising a geometry there would serve a
103970
- * stream the controller did not ask for and it would look like it worked.
103971
- *
103972
- * ## Every path answers
103973
- *
103974
- * A read of either characteristic always produces a valid TLV8. The Home app
103975
- * reads a camera's characteristics in one batch, and one member that answers
103976
- * nothing took the kitchen camera's whole read down and left it on "loading".
103977
- * So an engine failure, a malformed write and "nothing has happened yet" are
103978
- * all statuses, never silence.
103979
- */
103980
- /** RTP payload types. 96 is the first dynamic one (RFC 3551); 110 is what our
103981
- * own Opus offers use. Both are echoed in the tier declaration. */
103982
- var VIDEO_PAYLOAD_TYPE = 96;
103983
- var AUDIO_PAYLOAD_TYPE = 110;
103984
- /** RTCP interval in SECONDS, which is the unit the engine reads. */
103985
- var RTCP_INTERVAL_SECONDS = 5;
103986
- /** The MTU the classic path uses for an Ethernet-sized path. */
103987
- var DEFAULT_MTU = 1378;
103988
- function errorMessage(error) {
103989
- return error instanceof Error ? error.message : String(error);
103990
- }
103991
- function buildMultiTierNegotiation(input) {
103992
- const { deviceId, logger, negotiator } = input;
103993
- const log = logger.withTags({ deviceId });
103994
- let setupResponse = encodeSetupEndpointsRefusal(SetupEndpointsStatus.ERROR);
103995
- let controlResponse = encodeRtpStreamingControlStatus(null, RtpStreamingStatus.ERROR);
103996
- /** Sessions we have STARTED, so an End can tell "not started" from "unknown". */
103997
- const started = /* @__PURE__ */ new Set();
103998
- async function handleSetupEndpointsWrite(base64) {
103999
- let sessionId = null;
104000
- try {
104001
- const request = decodeSetupEndpointsRequest(base64);
104002
- sessionId = request.sessionId;
104003
- const prepareRequest = {
104004
- sessionID: request.sessionId,
104005
- sourceAddress: request.controllerAddress,
104006
- targetAddress: request.controllerAddress,
104007
- addressVersion: request.addressVersion,
104008
- video: {
104009
- port: request.videoPort,
104010
- srtpCryptoSuite: import_dist.SRTPCryptoSuites.AES_CM_128_HMAC_SHA1_80,
104011
- srtp_key: request.videoSrtp.key,
104012
- srtp_salt: request.videoSrtp.salt
104013
- },
104014
- audio: {
104015
- port: request.audioPort,
104016
- srtpCryptoSuite: import_dist.SRTPCryptoSuites.AES_CM_128_HMAC_SHA1_80,
104017
- srtp_key: request.audioSrtp.key,
104018
- srtp_salt: request.audioSrtp.salt
104019
- }
104020
- };
104021
- const prepared = await negotiator.prepare(prepareRequest);
104022
- setupResponse = encodeSetupEndpointsResponse({
104023
- sessionId: request.sessionId,
104024
- addressVersion: request.addressVersion,
104025
- accessoryAddress: prepared.localAddress,
104026
- videoPort: prepared.videoPort,
104027
- audioPort: prepared.audioPort,
104028
- videoSrtp: {
104029
- key: request.videoSrtp.key,
104030
- salt: request.videoSrtp.salt
104031
- },
104032
- audioSrtp: {
104033
- key: request.audioSrtp.key,
104034
- salt: request.audioSrtp.salt
104035
- },
104036
- videoSsrc: prepared.videoSsrc,
104037
- audioSsrc: prepared.audioSsrc
104038
- });
104039
- log.info("hap multi-tier: endpoints prepared", { meta: {
104040
- sessionId: request.sessionId,
104041
- controller: `${request.controllerAddress}:${request.videoPort}/${request.audioPort}`,
104042
- accessory: `${prepared.localAddress}:${prepared.videoPort}/${prepared.audioPort}`,
104043
- videoSsrc: prepared.videoSsrc,
104044
- audioSsrc: prepared.audioSsrc
104045
- } });
104046
- } catch (error) {
104047
- setupResponse = encodeSetupEndpointsRefusal(SetupEndpointsStatus.ERROR);
104048
- log.warn("hap multi-tier: setup endpoints refused", { meta: {
104049
- sessionId,
104050
- error: errorMessage(error)
104051
- } });
104052
- }
104053
- }
104054
- async function handleStreamingControlWrite(base64) {
104055
- let sessionId = null;
104056
- try {
104057
- const command = decodeRtpStreamingControl(base64);
104058
- sessionId = command.sessionId;
104059
- if (!negotiator.has(command.sessionId)) {
104060
- controlResponse = encodeRtpStreamingControlStatus(command.sessionId, RtpStreamingStatus.UNKNOWN_SESSION_IDENTIFIER);
104061
- log.warn("hap multi-tier: control write for a session that was never prepared", { meta: {
104062
- sessionId: command.sessionId,
104063
- command: command.command
104064
- } });
104065
- return;
104066
- }
104067
- if (command.command === RtpStreamingCommand.END) {
104068
- if (!started.has(command.sessionId)) {
104069
- controlResponse = encodeRtpStreamingControlStatus(command.sessionId, RtpStreamingStatus.NO_SUCH_STREAM);
104070
- return;
104071
- }
104072
- const stop = {
104073
- sessionID: command.sessionId,
104074
- type: import_dist.StreamRequestTypes.STOP
104075
- };
104076
- await negotiator.control(stop);
104077
- started.delete(command.sessionId);
104078
- controlResponse = encodeRtpStreamingControlStatus(command.sessionId, RtpStreamingStatus.SUCCESS);
104079
- log.info("hap multi-tier: stream ended", { meta: { sessionId: command.sessionId } });
104080
- return;
104081
- }
104082
- const tier = input.tiers.find((t) => t.identifier === command.videoTier);
104083
- if (tier === void 0) {
104084
- controlResponse = encodeRtpStreamingControlStatus(command.sessionId, RtpStreamingStatus.NO_SUCH_STREAM);
104085
- log.warn("hap multi-tier: start named a tier we never published", { meta: {
104086
- sessionId: command.sessionId,
104087
- requested: command.videoTier,
104088
- published: input.tiers.map((t) => t.identifier)
104089
- } });
104090
- return;
104091
- }
104092
- log.info("hap multi-tier: start ssrc comparison", { meta: {
104093
- sessionId: command.sessionId,
104094
- controllerVideoSsrc: command.videoSsrc ?? null,
104095
- controllerAudioSsrc: command.audioSsrc ?? null,
104096
- note: "accessory keeps the ssrcs it advertised at setup"
104097
- } });
104098
- const start = {
104099
- sessionID: command.sessionId,
104100
- type: import_dist.StreamRequestTypes.START,
104101
- video: {
104102
- codec: import_dist.VideoCodecType.H264,
104103
- profile: import_dist.H264Profile.HIGH,
104104
- level: import_dist.H264Level.LEVEL4_0,
104105
- packetizationMode: import_dist.VideoCodecPacketizationMode.NON_INTERLEAVED,
104106
- width: tier.width,
104107
- height: tier.height,
104108
- fps: tier.frameRate,
104109
- pt: VIDEO_PAYLOAD_TYPE,
104110
- ssrc: command.videoSsrc ?? 0,
104111
- max_bit_rate: tier.targetAverageBitrateKbps,
104112
- rtcp_interval: RTCP_INTERVAL_SECONDS,
104113
- mtu: DEFAULT_MTU
104114
- },
104115
- audio: {
104116
- codec: import_dist.AudioStreamingCodecType.OPUS,
104117
- channel: 1,
104118
- bit_rate: 24,
104119
- sample_rate: import_dist.AudioStreamingSamplerate.KHZ_24,
104120
- packet_time: 20,
104121
- pt: AUDIO_PAYLOAD_TYPE,
104122
- ssrc: command.audioSsrc ?? 0,
104123
- max_bit_rate: 24,
104124
- rtcp_interval: RTCP_INTERVAL_SECONDS,
104125
- comfort_pt: 13,
104126
- comfortNoiseEnabled: false
104127
- }
104128
- };
104129
- await negotiator.control(start);
104130
- started.add(command.sessionId);
104131
- controlResponse = encodeRtpStreamingControlStatus(command.sessionId, RtpStreamingStatus.SUCCESS);
104132
- log.info("hap multi-tier: stream started", { meta: {
104133
- sessionId: command.sessionId,
104134
- tier: tier.identifier,
104135
- geometry: `${tier.width}x${tier.height}@${tier.frameRate}`,
104136
- targetKbps: tier.targetAverageBitrateKbps,
104137
- codec: input.codec
104138
- } });
104139
- } catch (error) {
104140
- controlResponse = encodeRtpStreamingControlStatus(sessionId, RtpStreamingStatus.ERROR);
104141
- log.warn("hap multi-tier: control write refused", { meta: {
104142
- sessionId,
104143
- error: errorMessage(error)
104144
- } });
104145
- }
104146
- }
104147
- return {
104148
- handleSetupEndpointsWrite,
104149
- readSetupEndpoints: () => setupResponse,
104150
- handleStreamingControlWrite,
104151
- readStreamingControl: () => controlResponse
104152
- };
104153
- }
104154
- //#endregion
104155
- //#region src/mappers/camera-accessory.ts
104156
- /**
104157
- * Camera accessory orchestrator — given a camstack deviceId, builds one
104158
- * HomeKit Accessory representing the camera (or doorbell) plus a
104159
- * separate Accessory per child device (siren, floodlight, …).
104160
- *
104161
- * Flow:
104162
- * 1. Resolve a typed `DeviceProxy` via `ctx.fetchDevice(deviceId)`.
104163
- * 2. Inspect `proxy.binding.entries` (or fall back to `getDevice`
104164
- * features) to decide whether the device is a doorbell vs a
104165
- * regular camera. Doorbells get `Categories.VIDEO_DOORBELL` +
104166
- * `DoorbellController`; otherwise `Categories.IP_CAMERA` +
104167
- * `CameraController`.
104168
- * 3. Build the streaming delegate via the `camera-streams` builder.
104169
- * 4. `configureController(controller)` to attach the streaming
104170
- * services to the Accessory.
104171
- * 5. Run each feature builder gated on cap presence (motion sensor,
104172
- * intercom, PTZ, doorbell).
104173
- * 6. Enumerate children via `proxy.deviceManager.getChildren({...})`
104174
- * and build one Accessory per child (see `child-accessory.ts`).
104175
- * 7. Return a `Mapper` whose `accessories[]` lists every accessory
104176
- * this device produced so the bridge can register them all and
104177
- * remove them all on unexpose.
104178
- */
104179
- async function buildCameraAccessory(input) {
104180
- const { ctx, deviceId, displayName, options } = input;
104181
- const numericId = Number.parseInt(deviceId, 10);
104182
- if (!Number.isFinite(numericId)) throw new Error(`export-hap: cannot map device '${deviceId}' — id is not numeric`);
104183
- const proxy = await ctx.fetchDevice(numericId);
104184
- const capNames = new Set(proxy.binding?.entries.map((e) => e.capName) ?? []);
104185
- const isDoorbell = capNames.has("doorbell");
104186
- const category = isDoorbell ? import_dist.Categories.VIDEO_DOORBELL : import_dist.Categories.IP_CAMERA;
104187
- const accessory = new import_dist.Accessory(displayName, accessoryUuidFor("camera", numericId));
104188
- accessory.category = category;
104189
- await populateAccessoryInfo(accessory, proxy, displayName, "Camera");
104190
- const bctx = {
104191
- ctx,
104192
- accessory,
104193
- proxy,
104194
- numericDeviceId: numericId,
104195
- displayName,
104196
- options
104197
- };
104198
- const advertisedVideo = await probeAdvertisedVideoProfile(bctx);
104199
- const streams = buildCameraStreamingDelegate(bctx, advertisedVideo);
104200
- const handles = [];
104201
- if (capNames.has("intercom")) handles.push(await buildIntercom({
104202
- bctx,
104203
- streamingOptions: streams.streamingOptions
104204
- }));
104205
- const recordingEnabled = options.hapDeviceSettings.hksvRecording === true;
104206
- let recordingAudioActive = true;
104207
- const recording = recordingEnabled ? await buildHksvRecording({
104208
- bctx,
104209
- fpsByProfile: advertisedVideo.fpsByProfile,
104210
- isAudioActive: () => recordingAudioActive
104211
- }) : null;
104212
- const controller = new (isDoorbell ? import_dist.DoorbellController : import_dist.CameraController)({
104213
- delegate: streams.delegate,
104214
- streamingOptions: streams.streamingOptions,
104215
- cameraStreamCount: 2,
104216
- ...recording === null ? {} : { recording },
104217
- ...recording === null || !capNames.has("motion-detection") ? {} : { sensors: { motion: true } }
104218
- });
104219
- accessory.configureController(controller);
104220
- if (options.hapDeviceSettings.multiTierService !== false) {
104221
- const sensor = advertisedVideo.sensor;
104222
- const services = buildMultiTierServices({
104223
- deviceId: numericId,
104224
- logger: ctx.logger,
104225
- width: sensor.width,
104226
- height: sensor.height,
104227
- codec: sensor.codec,
104228
- audioCaptureRateHz: 16e3,
104229
- negotiation: buildMultiTierNegotiation({
104230
- deviceId: numericId,
104231
- logger: ctx.logger,
104232
- negotiator: streams.negotiator,
104233
- tiers: tiersForSensor(sensor.width, sensor.height),
104234
- codec: sensor.codec
104235
- })
104236
- });
104237
- accessory.addService(services.capabilities);
104238
- accessory.addService(services.globalOperatingMode);
104239
- accessory.addService(services.streamManagement);
104240
- ctx.logger.info("hap multi-tier: services declared on this accessory", {
104241
- tags: { deviceId: numericId },
104242
- meta: {
104243
- serviceUuid: MULTI_TIER_RTP_SERVICE_UUID,
104244
- codec: sensor.codec === TierVideoCodec.H265 ? "h265" : "h264",
104245
- sensor: `${sensor.width}x${sensor.height}`
104246
- }
104247
- });
104248
- }
104249
- if (recording !== null) {
104250
- handles.push({ dispose: () => recording.dispose() });
104251
- const audioCharacteristic = (controller.recordingManagement?.operatingModeService)?.getCharacteristic(import_dist.Characteristic.RecordingAudioActive);
104252
- if (audioCharacteristic) {
104253
- recordingAudioActive = audioCharacteristic.value !== 0 && audioCharacteristic.value !== false;
104254
- audioCharacteristic.on("change", ({ newValue }) => {
104255
- recordingAudioActive = newValue !== 0 && newValue !== false;
104256
- });
104257
- }
104258
- }
104259
- if (capNames.has("motion-detection")) handles.push(await buildMotionSensor(bctx, recording === null ? null : controller.motionService ?? null));
104260
- if (isDoorbell && controller instanceof import_dist.DoorbellController) handles.push(await buildDoorbell({
104261
- bctx,
104262
- controller
104263
- }));
104264
- if (capNames.has("ptz") || capNames.has("ptz-autotrack")) handles.push(await buildPtz(bctx));
104265
- if (capNames.has("battery")) handles.push(await buildBattery(bctx));
104266
- if (capNames.has("privacy-mask")) handles.push(await buildPrivacySwitch(bctx));
104267
- handles.push(await buildChildServicesFor({ parentCtx: bctx }));
104268
- return {
104269
- accessory,
104270
- accessories: [accessory],
104271
- async dispose() {
104272
- const log = ctx.logger.withTags({ deviceId: numericId });
104273
- for (const h of handles) try {
104274
- await h.dispose();
104275
- } catch (err) {
104276
- log.debug("export-hap: builder dispose failed (continuing)", { meta: { error: errMsg$5(err) } });
104277
- }
104278
- try {
104279
- await streams.dispose();
104280
- } catch (err) {
104281
- log.debug("export-hap: streams dispose failed", { meta: { error: errMsg$5(err) } });
104282
- }
104283
- }
104284
- };
104285
- }
104286
- function errMsg$5(err) {
104287
- return err instanceof Error ? err.message : String(err);
104288
- }
104289
- //#endregion
104290
- //#region src/mappers/builders/generic/lock.ts
104291
- /**
104292
- * `lock-control` → `Service.LockMechanism`.
104293
- *
104294
- * Not a sensor row: HomeKit models a lock as two characteristics, a CURRENT
104295
- * state the accessory owns and a TARGET state the controller writes, and the
104296
- * cap's five states do not map onto either one alone.
104297
- *
104298
- * ```
104299
- * cap state LockCurrentState LockTargetState
104300
- * locked SECURED SECURED
104301
- * unlocked UNSECURED UNSECURED
104302
- * locking UNKNOWN SECURED (in flight — do not claim SECURED)
104303
- * unlocking UNKNOWN UNSECURED
104304
- * jammed JAMMED unchanged (the controller's intent stands)
104305
- * ```
104306
- *
104307
- * Reporting SECURED while the bolt is still moving is the failure worth naming:
104308
- * iOS renders the lock as closed, the operator walks away, and the motor stalls
104309
- * behind them. `UNKNOWN` is the honest answer for an in-flight transition.
104310
- *
104311
- * `open` (the cap's third method, for locks with a latch/buzzer) has no HomeKit
104312
- * counterpart on this service and is deliberately not wired — a Switch that
104313
- * silently buzzes a door open would be a second knob nobody asked for.
104314
- */
104315
- var SUBTYPE$1 = "lock-control";
104316
- var readLock = reader(LockControlStatusSchema.pick({ state: true }), (status) => {
104317
- 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;
104318
- 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;
104319
- return [{
104320
- characteristic: import_dist.Characteristic.LockCurrentState,
104321
- value: current
104322
- }, ...target === null ? [] : [{
104323
- characteristic: import_dist.Characteristic.LockTargetState,
104324
- value: target
104325
- }]];
104326
- });
104327
- async function buildLockMechanism(bctx) {
104328
- const { ctx, accessory, proxy, numericDeviceId, displayName } = bctx;
104329
- const log = ctx.logger.withTags({ deviceId: numericDeviceId });
104330
- const label = hapServiceName([displayName], `Lock ${numericDeviceId}`);
104331
- const service = accessory.addService(import_dist.Service.LockMechanism, label, SUBTYPE$1);
104332
- applyServiceLabel(service, label);
104333
- const apply = (status, source) => {
104334
- const updates = readLock(status);
104335
- if (updates.length === 0) {
104336
- log.debug("export-hap: lock status did not match the cap schema — no update", { meta: { source } });
105941
+ });
104337
105942
  return;
104338
105943
  }
104339
- applyUpdates(service, updates);
104340
- };
104341
- try {
104342
- const status = await proxy.lockControl?.getStatus({});
104343
- if (status !== void 0 && status !== null) apply(status, "getStatus");
104344
- } catch (err) {
104345
- log.debug("export-hap: lock getStatus hydrate failed (non-fatal)", { meta: { error: errMsg$4(err) } });
104346
- }
104347
- service.getCharacteristic(import_dist.Characteristic.LockTargetState).onSet(async (value) => {
104348
- const secure = value === import_dist.Characteristic.LockTargetState.SECURED;
104349
- try {
104350
- if (secure) await proxy.lockControl?.lock({});
104351
- else await proxy.lockControl?.unlock({});
104352
- } catch (err) {
104353
- log.warn("export-hap: lock command failed", { meta: {
104354
- secure,
104355
- error: errMsg$4(err)
104356
- } });
105944
+ await input.negotiation.handleSetupEndpointsWrite(value);
105945
+ });
105946
+ streamManagement.addCharacteristic(setupEndpoints);
105947
+ const streamingControl = new import_dist.Characteristic("RTP Streaming Control", CHAR_RTP_STREAMING_CONTROL, {
105948
+ format: import_dist.Formats.TLV8,
105949
+ perms: [import_dist.Perms.PAIRED_READ, import_dist.Perms.PAIRED_WRITE]
105950
+ });
105951
+ streamingControl.onGet(() => {
105952
+ note("rtp-streaming-control");
105953
+ return input.negotiation?.readStreamingControl() ?? encodeRtpStreamingControlStatus(null, RtpStreamingStatus.ERROR);
105954
+ });
105955
+ streamingControl.onSet(async (value) => {
105956
+ if (input.negotiation === void 0 || typeof value !== "string") {
105957
+ logger.info("hap multi-tier: refused an rtp streaming control write", {
105958
+ tags: { deviceId },
105959
+ meta: { reason: input.negotiation === void 0 ? "no-negotiator" : "not-a-string" }
105960
+ });
105961
+ return;
104357
105962
  }
105963
+ await input.negotiation.handleStreamingControlWrite(value);
104358
105964
  });
104359
- const unsubscribe = proxy.state.lockControl?.subscribe((value) => {
104360
- if (value === void 0 || value === null) return;
104361
- apply(value, "slice");
104362
- }) ?? null;
104363
- return { async dispose() {
104364
- try {
104365
- unsubscribe?.();
104366
- } catch {}
104367
- } };
104368
- }
104369
- function errMsg$4(err) {
104370
- return err instanceof Error ? err.message : String(err);
104371
- }
104372
- //#endregion
104373
- //#region src/mappers/builders/generic/security-state.ts
104374
- /** Is this a settled arm state worth remembering? */
104375
- function armedMemoOf(state) {
104376
- return state === "armed_home" || state === "armed_away" || state === "armed_night" || state === "armed_vacation" || state === "armed_custom_bypass" ? state : null;
104377
- }
104378
- function currentForArmed(state) {
104379
- switch (state) {
104380
- case "armed_home":
104381
- case "armed_custom_bypass": return import_dist.Characteristic.SecuritySystemCurrentState.STAY_ARM;
104382
- case "armed_night": return import_dist.Characteristic.SecuritySystemCurrentState.NIGHT_ARM;
104383
- default: return import_dist.Characteristic.SecuritySystemCurrentState.AWAY_ARM;
104384
- }
104385
- }
104386
- function targetForArmed(state) {
104387
- switch (state) {
104388
- case "armed_home":
104389
- case "armed_custom_bypass": return import_dist.Characteristic.SecuritySystemTargetState.STAY_ARM;
104390
- case "armed_night": return import_dist.Characteristic.SecuritySystemTargetState.NIGHT_ARM;
104391
- default: return import_dist.Characteristic.SecuritySystemTargetState.AWAY_ARM;
104392
- }
104393
- }
104394
- function securityStateFor(state, memo) {
104395
- const settled = armedMemoOf(state);
104396
- if (settled !== null) return {
104397
- current: currentForArmed(settled),
104398
- target: targetForArmed(settled)
105965
+ streamManagement.addCharacteristic(streamingControl);
105966
+ const sensor = new import_dist.Characteristic("Sensor UUID", CHAR_SENSOR_UUID, {
105967
+ format: import_dist.Formats.DATA,
105968
+ perms: [import_dist.Perms.PAIRED_READ]
105969
+ });
105970
+ const sensorValue = sensorUuid.toString("base64");
105971
+ sensor.onGet(() => {
105972
+ note("sensor-uuid");
105973
+ return sensorValue;
105974
+ });
105975
+ streamManagement.addCharacteristic(sensor);
105976
+ return {
105977
+ capabilities,
105978
+ globalOperatingMode,
105979
+ streamManagement
104399
105980
  };
104400
- switch (state) {
104401
- case "disarmed": return {
104402
- current: import_dist.Characteristic.SecuritySystemCurrentState.DISARMED,
104403
- target: import_dist.Characteristic.SecuritySystemTargetState.DISARM
104404
- };
104405
- case "arming": return {
104406
- current: import_dist.Characteristic.SecuritySystemCurrentState.DISARMED,
104407
- target: memo === null ? null : targetForArmed(memo)
104408
- };
104409
- case "disarming": return {
104410
- current: memo === null ? import_dist.Characteristic.SecuritySystemCurrentState.DISARMED : currentForArmed(memo),
104411
- target: import_dist.Characteristic.SecuritySystemTargetState.DISARM
104412
- };
104413
- case "pending": return {
104414
- current: memo === null ? import_dist.Characteristic.SecuritySystemCurrentState.DISARMED : currentForArmed(memo),
104415
- target: memo === null ? null : targetForArmed(memo)
104416
- };
104417
- default: return {
104418
- current: import_dist.Characteristic.SecuritySystemCurrentState.ALARM_TRIGGERED,
104419
- target: memo === null ? null : targetForArmed(memo)
104420
- };
104421
- }
104422
- }
104423
- /**
104424
- * The arm mode behind a HomeKit target, or `null` for DISARM and for anything
104425
- * outside the four values HomeKit defines.
104426
- *
104427
- * `null` is not an error the caller can ignore: DISARM is a different COMMAND,
104428
- * and a value HomeKit never sends is a write this builder refuses to translate.
104429
- */
104430
- function alarmArmModeFor(target) {
104431
- switch (target) {
104432
- case import_dist.Characteristic.SecuritySystemTargetState.STAY_ARM: return "home";
104433
- case import_dist.Characteristic.SecuritySystemTargetState.AWAY_ARM: return "away";
104434
- case import_dist.Characteristic.SecuritySystemTargetState.NIGHT_ARM: return "night";
104435
- default: return null;
104436
- }
104437
- }
104438
- //#endregion
104439
- //#region src/mappers/builders/generic/security-target.ts
104440
- /**
104441
- * The panel modes behind each HomeKit target, PRIMARY first.
104442
- *
104443
- * The primary is the mode `alarmArmModeFor` names; the secondary is the other
104444
- * mode `security-state.ts` reports through the same HomeKit value, so the two
104445
- * modules agree in both directions by construction.
104446
- */
104447
- var MODES_BY_TARGET = new Map([
104448
- [import_dist.Characteristic.SecuritySystemTargetState.STAY_ARM, ["home", "custom_bypass"]],
104449
- [import_dist.Characteristic.SecuritySystemTargetState.AWAY_ARM, ["away", "vacation"]],
104450
- [import_dist.Characteristic.SecuritySystemTargetState.NIGHT_ARM, ["night"]]
104451
- ]);
104452
- /** Arm targets in the order HomeKit numbers them; DISARM is appended last. */
104453
- var ARM_TARGETS = [
104454
- import_dist.Characteristic.SecuritySystemTargetState.STAY_ARM,
104455
- import_dist.Characteristic.SecuritySystemTargetState.AWAY_ARM,
104456
- import_dist.Characteristic.SecuritySystemTargetState.NIGHT_ARM
104457
- ];
104458
- /**
104459
- * The `validValues` for `SecuritySystemTargetState`, or `null` to leave
104460
- * HomeKit's own four values in place.
104461
- *
104462
- * DISARM is always present: disarming is not one of the panel's arm modes and
104463
- * a panel that cannot be disarmed is not a thing this cap can express.
104464
- */
104465
- function targetValidValuesFor(availableModes) {
104466
- if (availableModes.length === 0) return null;
104467
- return [...ARM_TARGETS.filter((target) => (MODES_BY_TARGET.get(target) ?? []).some((mode) => availableModes.includes(mode))), import_dist.Characteristic.SecuritySystemTargetState.DISARM];
104468
- }
104469
- /**
104470
- * The arm mode to send for a HomeKit target, or `null` when there is none.
104471
- *
104472
- * `null` is not a shrug the caller may swallow: DISARM is a different command,
104473
- * and an arm target the panel accepts no mode for is a write this module
104474
- * refuses to translate rather than guess at.
104475
- */
104476
- function armModeForTarget(target, availableModes) {
104477
- const primary = alarmArmModeFor(target);
104478
- if (primary === null) return null;
104479
- if (availableModes.length === 0) return primary;
104480
- return (MODES_BY_TARGET.get(target) ?? [primary]).find((mode) => availableModes.includes(mode)) ?? null;
104481
105981
  }
104482
105982
  //#endregion
104483
- //#region src/mappers/builders/generic/security-system.ts
105983
+ //#region src/multi-tier/negotiation.ts
104484
105984
  /**
104485
- * `alarm-panel` → `Service.SecuritySystem`.
104486
- *
104487
- * The lock builder's shape (a current/target pair, a hydrate, an `onSet`, a
104488
- * slice subscription, a dispose) applied to the one device on the fleet where
104489
- * reporting a state that is not true has a consequence worse than a stalled
104490
- * motor. The mapping itself is NOT here: `security-state.ts` owns what the
104491
- * panel's state looks like to HomeKit and `security-target.ts` owns what
104492
- * HomeKit may ask for, both pure and both tested on their own. This file is
104493
- * the service around them.
104494
- *
104495
- * ## The memo
105985
+ * The adapter between Apple's new characteristics and the streaming engine this
105986
+ * addon already has.
104496
105987
  *
104497
- * `pending`, `disarming` and `triggered` do not carry the mode the panel was
104498
- * armed to — only the last settled state knows it. This builder is the caller
104499
- * `security-state.ts` describes: it remembers the last settled arm and forgets
104500
- * it on `disarmed`, because a memo that outlives a disarm makes the next
104501
- * `arming` claim a target nobody asked for.
105988
+ * ## Why an adapter and not a second engine
104502
105989
  *
104503
- * The memo is deliberately NOT seeded from the controller's own write. HomeKit
104504
- * already holds the target the operator just set; not writing it back is
104505
- * exactly right. The cost is a panel armed from its own keypad during an exit
104506
- * delay, where the target stays at whatever HomeKit last knew — a stale target
104507
- * for the length of the delay, against inventing an intent, and the invention
104508
- * is worse.
105990
+ * The multi-tier RTP service owns its OWN `Setup Endpoints` and `RTP Streaming
105991
+ * Control`, so hap-nodejs's `CameraController` never negotiates for it — and
105992
+ * the library's own handlers are all `private`, so its engine cannot be
105993
+ * borrowed either. But the sockets, the SRTP contexts, the ffmpeg pipeline and
105994
+ * the teardown are the same ones the classic service uses. A second copy would
105995
+ * be two engines to keep in agreement, and this repo has paid for that shape
105996
+ * before. So: new characteristics, one engine, through `StreamNegotiator`.
104509
105997
  *
104510
- * ## `requiresCode`: the arm is REFUSED, not attempted
105998
+ * ## What is different about the new flow
104511
105999
  *
104512
- * HomeKit's SecuritySystem has no PIN prompt — there is no characteristic for
104513
- * it and no way to ask the operator mid-write. So a panel that requires a code
104514
- * cannot be armed or disarmed from the Home app, and this builder says so
104515
- * twice: the write fails with `INSUFFICIENT_AUTHORIZATION` (iOS renders the
104516
- * tile's failure) and a WARN names the device, the command and the mode.
106000
+ * The classic `Selected RTP Stream Configuration` carries the whole selected
106001
+ * configuration in its start — codec, profile, level, resolution, bitrate.
106002
+ * `RTP Streaming Control` carries a **tier identifier**, because the
106003
+ * configuration was agreed when we published `Supported Video Stream Tiers`.
106004
+ * So this maps an identifier back to the tier WE published, and a tier we never
106005
+ * published is `No Such Stream` — improvising a geometry there would serve a
106006
+ * stream the controller did not ask for and it would look like it worked.
104517
106007
  *
104518
- * Sending the command code-less and letting the panel reject it was the
104519
- * alternative. It is worse: the refusal costs a round trip, it is
104520
- * indistinguishable from a broken panel in the log, and repeated code-less
104521
- * attempts are what locks a real alarm panel out.
104522
- */
104523
- var SUBTYPE = "alarm-panel";
104524
- /** The subset of the status this builder reads. */
104525
- var PanelReadSchema = AlarmPanelStatusSchema.pick({
104526
- state: true,
104527
- availableModes: true,
104528
- requiresCode: true
104529
- });
104530
- /**
104531
- * Turn a panel status into characteristic writes, folding the memo.
106008
+ * ## Every path answers
104532
106009
  *
104533
- * A payload that does not match the cap's own schema yields NO updates and
104534
- * leaves the memo alone — it is a status we could not read, not a panel that
104535
- * changed. The caller logs the drop; a frozen characteristic is otherwise
104536
- * indistinguishable from a quiet panel.
106010
+ * A read of either characteristic always produces a valid TLV8. The Home app
106011
+ * reads a camera's characteristics in one batch, and one member that answers
106012
+ * nothing took the kitchen camera's whole read down and left it on "loading".
106013
+ * So an engine failure, a malformed write and "nothing has happened yet" are
106014
+ * all statuses, never silence.
104537
106015
  */
104538
- function securityUpdatesFor(status, memo) {
104539
- const parsed = PanelReadSchema.safeParse(status);
104540
- if (!parsed.success) return {
104541
- updates: [],
104542
- memo,
104543
- panel: null
104544
- };
104545
- const nextMemo = parsed.data.state === "disarmed" ? null : armedMemoOf(parsed.data.state) ?? memo;
104546
- const { current, target } = securityStateFor(parsed.data.state, nextMemo);
104547
- return {
104548
- updates: [{
104549
- characteristic: import_dist.Characteristic.SecuritySystemCurrentState,
104550
- value: current
104551
- }, ...target === null ? [] : [{
104552
- characteristic: import_dist.Characteristic.SecuritySystemTargetState,
104553
- value: target
104554
- }]],
104555
- memo: nextMemo,
104556
- panel: {
104557
- availableModes: parsed.data.availableModes,
104558
- requiresCode: parsed.data.requiresCode
104559
- }
104560
- };
106016
+ /** RTP payload types. 96 is the first dynamic one (RFC 3551); 110 is what our
106017
+ * own Opus offers use. Both are echoed in the tier declaration. */
106018
+ var VIDEO_PAYLOAD_TYPE = 96;
106019
+ var AUDIO_PAYLOAD_TYPE = 110;
106020
+ /** RTCP interval in SECONDS, which is the unit the engine reads. */
106021
+ var RTCP_INTERVAL_SECONDS = 5;
106022
+ /** The MTU the classic path uses for an Ethernet-sized path. */
106023
+ var DEFAULT_MTU = 1378;
106024
+ function errorMessage(error) {
106025
+ return error instanceof Error ? error.message : String(error);
104561
106026
  }
104562
- async function buildSecuritySystem(bctx) {
104563
- const { ctx, accessory, proxy, numericDeviceId, displayName } = bctx;
104564
- const log = ctx.logger.withTags({ deviceId: numericDeviceId });
104565
- const label = hapServiceName([displayName], `Alarm ${numericDeviceId}`);
104566
- const service = accessory.addService(import_dist.Service.SecuritySystem, label, SUBTYPE);
104567
- applyServiceLabel(service, label);
104568
- let memo = null;
104569
- let availableModes = [];
104570
- let requiresCode = false;
104571
- /** Only log the `validValues` narrowing when it actually changes. */
104572
- let lastValidValues = "";
104573
- const applyValidValues = () => {
104574
- const validValues = targetValidValuesFor(availableModes);
104575
- if (validValues === null) {
104576
- if (lastValidValues !== "unconstrained") {
104577
- lastValidValues = "unconstrained";
104578
- log.info("export-hap: alarm panel named no arm modes — leaving HomeKit’s targets unconstrained", { meta: { subtype: SUBTYPE } });
104579
- }
104580
- return;
104581
- }
104582
- const key = validValues.join(",");
104583
- if (key === lastValidValues) return;
104584
- lastValidValues = key;
104585
- service.getCharacteristic(import_dist.Characteristic.SecuritySystemTargetState).setProps({ validValues: [...validValues] });
104586
- log.info("export-hap: alarm panel target constrained to the modes it accepts", { meta: {
104587
- subtype: SUBTYPE,
104588
- availableModes: [...availableModes],
104589
- validValues
104590
- } });
104591
- };
104592
- const apply = (status, source) => {
104593
- const next = securityUpdatesFor(status, memo);
104594
- if (next.updates.length === 0) {
104595
- log.debug("export-hap: alarm panel status did not match the cap schema — no update", { meta: {
104596
- subtype: SUBTYPE,
104597
- source
104598
- } });
104599
- return;
104600
- }
104601
- memo = next.memo;
104602
- if (next.panel !== null) {
104603
- availableModes = next.panel.availableModes;
104604
- requiresCode = next.panel.requiresCode;
104605
- applyValidValues();
104606
- }
104607
- applyUpdates(service, next.updates);
104608
- };
104609
- try {
104610
- const status = await proxy.alarmPanel?.getStatus({});
104611
- if (status !== void 0 && status !== null) apply(status, "getStatus");
104612
- } catch (err) {
104613
- log.debug("export-hap: alarm panel getStatus hydrate failed (non-fatal)", { meta: { error: errMsg$3(err) } });
104614
- }
104615
- service.getCharacteristic(import_dist.Characteristic.SecuritySystemTargetState).onSet(async (value) => {
104616
- const target = typeof value === "number" ? value : Number(value);
104617
- const disarm = target === import_dist.Characteristic.SecuritySystemTargetState.DISARM;
104618
- const mode = disarm ? null : armModeForTarget(target, availableModes);
104619
- if (requiresCode) {
104620
- log.warn("export-hap: alarm panel requires a PIN — HomeKit cannot supply one, command REFUSED", { meta: {
104621
- subtype: SUBTYPE,
104622
- command: disarm ? "disarm" : "arm",
104623
- mode,
104624
- target
106027
+ function buildMultiTierNegotiation(input) {
106028
+ const { deviceId, logger, negotiator } = input;
106029
+ const log = logger.withTags({ deviceId });
106030
+ let setupResponse = encodeSetupEndpointsRefusal(SetupEndpointsStatus.ERROR);
106031
+ let controlResponse = encodeRtpStreamingControlStatus(null, RtpStreamingStatus.ERROR);
106032
+ /** Sessions we have STARTED, so an End can tell "not started" from "unknown". */
106033
+ const started = /* @__PURE__ */ new Set();
106034
+ async function handleSetupEndpointsWrite(base64) {
106035
+ let sessionId = null;
106036
+ try {
106037
+ const request = decodeSetupEndpointsRequest(base64);
106038
+ sessionId = request.sessionId;
106039
+ const prepareRequest = {
106040
+ sessionID: request.sessionId,
106041
+ sourceAddress: request.controllerAddress,
106042
+ targetAddress: request.controllerAddress,
106043
+ addressVersion: request.addressVersion,
106044
+ video: {
106045
+ port: request.videoPort,
106046
+ srtpCryptoSuite: import_dist.SRTPCryptoSuites.AES_CM_128_HMAC_SHA1_80,
106047
+ srtp_key: request.videoSrtp.key,
106048
+ srtp_salt: request.videoSrtp.salt
106049
+ },
106050
+ audio: {
106051
+ port: request.audioPort,
106052
+ srtpCryptoSuite: import_dist.SRTPCryptoSuites.AES_CM_128_HMAC_SHA1_80,
106053
+ srtp_key: request.audioSrtp.key,
106054
+ srtp_salt: request.audioSrtp.salt
106055
+ }
106056
+ };
106057
+ const prepared = await negotiator.prepare(prepareRequest);
106058
+ setupResponse = encodeSetupEndpointsResponse({
106059
+ sessionId: request.sessionId,
106060
+ addressVersion: request.addressVersion,
106061
+ accessoryAddress: prepared.localAddress,
106062
+ videoPort: prepared.videoPort,
106063
+ audioPort: prepared.audioPort,
106064
+ videoSrtp: {
106065
+ key: request.videoSrtp.key,
106066
+ salt: request.videoSrtp.salt
106067
+ },
106068
+ audioSrtp: {
106069
+ key: request.audioSrtp.key,
106070
+ salt: request.audioSrtp.salt
106071
+ },
106072
+ videoSsrc: prepared.videoSsrc,
106073
+ audioSsrc: prepared.audioSsrc
106074
+ });
106075
+ log.info("hap multi-tier: endpoints prepared", { meta: {
106076
+ sessionId: request.sessionId,
106077
+ controller: `${request.controllerAddress}:${request.videoPort}/${request.audioPort}`,
106078
+ accessory: `${prepared.localAddress}:${prepared.videoPort}/${prepared.audioPort}`,
106079
+ videoSsrc: prepared.videoSsrc,
106080
+ audioSsrc: prepared.audioSsrc
104625
106081
  } });
104626
- throw new import_dist.HapStatusError(import_dist.HAPStatus.INSUFFICIENT_AUTHORIZATION);
104627
- }
104628
- if (!disarm && mode === null) {
104629
- log.warn("export-hap: alarm panel cannot honour this HomeKit arm target — REFUSED", { meta: {
104630
- subtype: SUBTYPE,
104631
- target,
104632
- availableModes: [...availableModes]
106082
+ } catch (error) {
106083
+ setupResponse = encodeSetupEndpointsRefusal(SetupEndpointsStatus.ERROR);
106084
+ log.warn("hap multi-tier: setup endpoints refused", { meta: {
106085
+ sessionId,
106086
+ error: errorMessage(error)
104633
106087
  } });
104634
- throw new import_dist.HapStatusError(import_dist.HAPStatus.INVALID_VALUE_IN_REQUEST);
104635
106088
  }
106089
+ }
106090
+ async function handleStreamingControlWrite(base64) {
106091
+ let sessionId = null;
104636
106092
  try {
104637
- if (mode === null) await proxy.alarmPanel?.disarm({});
104638
- else await proxy.alarmPanel?.arm({ mode });
104639
- } catch (err) {
104640
- log.warn("export-hap: alarm panel command failed", { meta: {
104641
- subtype: SUBTYPE,
104642
- command: mode === null ? "disarm" : "arm",
104643
- mode,
104644
- error: errMsg$3(err)
106093
+ const command = decodeRtpStreamingControl(base64);
106094
+ sessionId = command.sessionId;
106095
+ if (!negotiator.has(command.sessionId)) {
106096
+ controlResponse = encodeRtpStreamingControlStatus(command.sessionId, RtpStreamingStatus.UNKNOWN_SESSION_IDENTIFIER);
106097
+ log.warn("hap multi-tier: control write for a session that was never prepared", { meta: {
106098
+ sessionId: command.sessionId,
106099
+ command: command.command
106100
+ } });
106101
+ return;
106102
+ }
106103
+ if (command.command === RtpStreamingCommand.END) {
106104
+ if (!started.has(command.sessionId)) {
106105
+ controlResponse = encodeRtpStreamingControlStatus(command.sessionId, RtpStreamingStatus.NO_SUCH_STREAM);
106106
+ return;
106107
+ }
106108
+ const stop = {
106109
+ sessionID: command.sessionId,
106110
+ type: import_dist.StreamRequestTypes.STOP
106111
+ };
106112
+ await negotiator.control(stop);
106113
+ started.delete(command.sessionId);
106114
+ controlResponse = encodeRtpStreamingControlStatus(command.sessionId, RtpStreamingStatus.SUCCESS);
106115
+ log.info("hap multi-tier: stream ended", { meta: { sessionId: command.sessionId } });
106116
+ return;
106117
+ }
106118
+ const tier = input.tiers.find((t) => t.identifier === command.videoTier);
106119
+ if (tier === void 0) {
106120
+ controlResponse = encodeRtpStreamingControlStatus(command.sessionId, RtpStreamingStatus.NO_SUCH_STREAM);
106121
+ log.warn("hap multi-tier: start named a tier we never published", { meta: {
106122
+ sessionId: command.sessionId,
106123
+ requested: command.videoTier,
106124
+ published: input.tiers.map((t) => t.identifier)
106125
+ } });
106126
+ return;
106127
+ }
106128
+ log.info("hap multi-tier: start ssrc comparison", { meta: {
106129
+ sessionId: command.sessionId,
106130
+ controllerVideoSsrc: command.videoSsrc ?? null,
106131
+ controllerAudioSsrc: command.audioSsrc ?? null,
106132
+ note: "accessory keeps the ssrcs it advertised at setup"
104645
106133
  } });
104646
- throw new import_dist.HapStatusError(import_dist.HAPStatus.SERVICE_COMMUNICATION_FAILURE);
104647
- }
104648
- });
104649
- const unsubscribe = proxy.state.alarmPanel?.subscribe((value) => {
104650
- if (value === void 0 || value === null) return;
104651
- apply(value, "slice");
104652
- }) ?? null;
104653
- return { async dispose() {
104654
- try {
104655
- unsubscribe?.();
104656
- } catch {}
104657
- } };
104658
- }
104659
- function errMsg$3(err) {
104660
- return err instanceof Error ? err.message : String(err);
104661
- }
104662
- //#endregion
104663
- //#region src/mappers/builders/generic/sensor-service.ts
104664
- async function buildSensorService(input) {
104665
- const { bctx, spec, name, subtype } = input;
104666
- const { ctx, accessory, proxy, numericDeviceId } = bctx;
104667
- const log = ctx.logger.withTags({ deviceId: numericDeviceId });
104668
- const label = hapServiceName([name], spec.label);
104669
- const service = spec.addService(accessory, label, subtype);
104670
- applyServiceLabel(service, label);
104671
- const apply = (status, source) => {
104672
- const updates = spec.read(status);
104673
- if (updates.length === 0) {
104674
- log.debug("export-hap: sensor status did not match the cap schema — no update", { meta: {
104675
- subtype,
104676
- source
106134
+ const start = {
106135
+ sessionID: command.sessionId,
106136
+ type: import_dist.StreamRequestTypes.START,
106137
+ video: {
106138
+ codec: import_dist.VideoCodecType.H264,
106139
+ profile: import_dist.H264Profile.HIGH,
106140
+ level: import_dist.H264Level.LEVEL4_0,
106141
+ packetizationMode: import_dist.VideoCodecPacketizationMode.NON_INTERLEAVED,
106142
+ width: tier.width,
106143
+ height: tier.height,
106144
+ fps: tier.frameRate,
106145
+ pt: VIDEO_PAYLOAD_TYPE,
106146
+ ssrc: command.videoSsrc ?? 0,
106147
+ max_bit_rate: tier.targetAverageBitrateKbps,
106148
+ rtcp_interval: RTCP_INTERVAL_SECONDS,
106149
+ mtu: DEFAULT_MTU
106150
+ },
106151
+ audio: {
106152
+ codec: import_dist.AudioStreamingCodecType.OPUS,
106153
+ channel: 1,
106154
+ bit_rate: 24,
106155
+ sample_rate: import_dist.AudioStreamingSamplerate.KHZ_24,
106156
+ packet_time: 20,
106157
+ pt: AUDIO_PAYLOAD_TYPE,
106158
+ ssrc: command.audioSsrc ?? 0,
106159
+ max_bit_rate: 24,
106160
+ rtcp_interval: RTCP_INTERVAL_SECONDS,
106161
+ comfort_pt: 13,
106162
+ comfortNoiseEnabled: false
106163
+ }
106164
+ };
106165
+ await negotiator.control(start);
106166
+ started.add(command.sessionId);
106167
+ controlResponse = encodeRtpStreamingControlStatus(command.sessionId, RtpStreamingStatus.SUCCESS);
106168
+ log.info("hap multi-tier: stream started", { meta: {
106169
+ sessionId: command.sessionId,
106170
+ tier: tier.identifier,
106171
+ geometry: `${tier.width}x${tier.height}@${tier.frameRate}`,
106172
+ targetKbps: tier.targetAverageBitrateKbps,
106173
+ codec: input.codec
106174
+ } });
106175
+ } catch (error) {
106176
+ controlResponse = encodeRtpStreamingControlStatus(sessionId, RtpStreamingStatus.ERROR);
106177
+ log.warn("hap multi-tier: control write refused", { meta: {
106178
+ sessionId,
106179
+ error: errorMessage(error)
104677
106180
  } });
104678
- return;
104679
106181
  }
104680
- applyUpdates(service, updates);
104681
- };
104682
- try {
104683
- const status = await spec.getStatus(proxy);
104684
- if (status !== void 0 && status !== null) apply(status, "getStatus");
104685
- } catch (err) {
104686
- log.debug("export-hap: sensor getStatus hydrate failed (non-fatal)", { meta: {
104687
- subtype,
104688
- error: errMsg$2(err)
104689
- } });
104690
106182
  }
104691
- const unsubscribe = spec.subscribe(proxy, (value) => {
104692
- if (value === void 0 || value === null) return;
104693
- apply(value, "slice");
104694
- });
104695
- return { async dispose() {
104696
- try {
104697
- unsubscribe?.();
104698
- } catch {}
104699
- } };
104700
- }
104701
- function errMsg$2(err) {
104702
- return err instanceof Error ? err.message : String(err);
106183
+ return {
106184
+ handleSetupEndpointsWrite,
106185
+ readSetupEndpoints: () => setupResponse,
106186
+ handleStreamingControlWrite,
106187
+ readStreamingControl: () => controlResponse
106188
+ };
104703
106189
  }
104704
106190
  //#endregion
104705
- //#region src/mappers/builders/generic/cap-service-table.ts
106191
+ //#region src/mappers/camera-accessory.ts
104706
106192
  /**
104707
- * The capability→HomeKit-service TABLE for non-camera devices.
104708
- *
104709
- * This is the whole coverage decision for the generic export path, and it is
104710
- * deliberately a table rather than a `switch` on `DeviceType`. The doctrine is
104711
- * the one the Home Assistant exporter already writes down: *coverage is decided
104712
- * by a device's capabilities, not its type — restricting the picker by type is
104713
- * what confined the previous exporter to cameras.* A `DeviceType` here is only
104714
- * ever a UI filter (`HAP_EXPORTABLE_DEVICE_TYPES`) or an icon
104715
- * (`ACCESSORY_CATEGORY_BY_TYPE`); it never decides whether something exports.
104716
- *
104717
- * The shape mirrors `CAP_ENTITY_MAP` in
104718
- * `packages/addon-provider-homeassistant/src/ha-export/entity-catalog.ts` so
104719
- * the two can be unified later — one row per capability, keyed by the cap name
104720
- * exactly as it appears in `proxy.binding.entries[].capName`.
104721
- *
104722
- * ## Reading a status
104723
- *
104724
- * Every row parses the cap's OWN Zod status schema rather than duck-typing the
104725
- * payload, and it `.pick()`s only the fields it reads: a provider that omits a
104726
- * timestamp must not silence a sensor, and a provider that sends the wrong
104727
- * shape must not write a garbage characteristic. A parse that fails yields no
104728
- * updates, and the builder that owns the service logs the drop — silence reads
104729
- * as "the sensor never changed".
104730
- *
104731
- * ## Tier
104732
- *
104733
- * Tier A, plus `alarm-panel`: the caps whose HomeKit service exists today and
104734
- * needs no feature-dependent shape. `cover` still needs `cover-positionable`
104735
- * and `climate-control` the dual-setpoint split; those are a separate task,
104736
- * and adding a row here must never mean adding a service whose semantics this
104737
- * file cannot fully honour.
106193
+ * Camera accessory orchestrator — given a camstack deviceId, builds one
106194
+ * HomeKit Accessory representing the camera (or doorbell) plus a
106195
+ * separate Accessory per child device (siren, floodlight, …).
104738
106196
  *
104739
- * `alarm-panel` earned its row by answering the two questions that kept it
104740
- * out: a target the panel would refuse is never offered (`availableModes` →
104741
- * `validValues`, `security-target.ts`) and an arm a PIN-gated panel cannot
104742
- * accept is refused out loud rather than attempted (`security-system.ts`).
106197
+ * Flow:
106198
+ * 1. Resolve a typed `DeviceProxy` via `ctx.fetchDevice(deviceId)`.
106199
+ * 2. Inspect `proxy.binding.entries` (or fall back to `getDevice`
106200
+ * features) to decide whether the device is a doorbell vs a
106201
+ * regular camera. Doorbells get `Categories.VIDEO_DOORBELL` +
106202
+ * `DoorbellController`; otherwise `Categories.IP_CAMERA` +
106203
+ * `CameraController`.
106204
+ * 3. Build the streaming delegate via the `camera-streams` builder.
106205
+ * 4. `configureController(controller)` to attach the streaming
106206
+ * services to the Accessory.
106207
+ * 5. Run each feature builder gated on cap presence (motion sensor,
106208
+ * intercom, PTZ, doorbell).
106209
+ * 6. Enumerate children via `proxy.deviceManager.getChildren({...})`
106210
+ * and build one Accessory per child (see `child-accessory.ts`).
106211
+ * 7. Return a `Mapper` whose `accessories[]` lists every accessory
106212
+ * this device produced so the bridge can register them all and
106213
+ * remove them all on unexpose.
104743
106214
  */
104744
- /** HomeKit's floor for `CurrentAmbientLightLevel`; 0 lux is out of range. */
104745
- var MIN_LUX = 1e-4;
104746
- var SENSOR_SPECS = {
104747
- contact: {
104748
- label: "Contact",
104749
- addService: (accessory, name, subtype) => accessory.addService(import_dist.Service.ContactSensor, name, subtype),
104750
- getStatus: (proxy) => proxy.contact?.getStatus({}),
104751
- subscribe: (proxy, onValue) => proxy.state.contact?.subscribe(onValue) ?? null,
104752
- read: reader(ContactStatusSchema.pick({ entryOpen: true }), (status) => [{
104753
- characteristic: import_dist.Characteristic.ContactSensorState,
104754
- value: status.entryOpen ? import_dist.Characteristic.ContactSensorState.CONTACT_NOT_DETECTED : import_dist.Characteristic.ContactSensorState.CONTACT_DETECTED
104755
- }])
104756
- },
104757
- motion: {
104758
- label: "Motion",
104759
- addService: (accessory, name, subtype) => accessory.addService(import_dist.Service.MotionSensor, name, subtype),
104760
- getStatus: (proxy) => proxy.motion?.getStatus({}),
104761
- subscribe: (proxy, onValue) => proxy.state.motion?.subscribe(onValue) ?? null,
104762
- read: reader(MotionStatusSchema.pick({ detected: true }), (status) => [{
104763
- characteristic: import_dist.Characteristic.MotionDetected,
104764
- value: status.detected
104765
- }])
104766
- },
104767
- presence: {
104768
- label: "Presence",
104769
- addService: (accessory, name, subtype) => accessory.addService(import_dist.Service.OccupancySensor, name, subtype),
104770
- getStatus: (proxy) => proxy.presence?.getStatus({}),
104771
- subscribe: (proxy, onValue) => proxy.state.presence?.subscribe(onValue) ?? null,
104772
- read: reader(PresenceStatusSchema.pick({ state: true }), (status) => [{
104773
- characteristic: import_dist.Characteristic.OccupancyDetected,
104774
- value: status.state === "home" ? import_dist.Characteristic.OccupancyDetected.OCCUPANCY_DETECTED : import_dist.Characteristic.OccupancyDetected.OCCUPANCY_NOT_DETECTED
104775
- }])
104776
- },
104777
- smoke: {
104778
- label: "Smoke",
104779
- addService: (accessory, name, subtype) => accessory.addService(import_dist.Service.SmokeSensor, name, subtype),
104780
- getStatus: (proxy) => proxy.smoke?.getStatus({}),
104781
- subscribe: (proxy, onValue) => proxy.state.smoke?.subscribe(onValue) ?? null,
104782
- read: reader(SmokeStatusSchema.pick({ detected: true }), (status) => [{
104783
- characteristic: import_dist.Characteristic.SmokeDetected,
104784
- value: status.detected ? import_dist.Characteristic.SmokeDetected.SMOKE_DETECTED : import_dist.Characteristic.SmokeDetected.SMOKE_NOT_DETECTED
104785
- }])
104786
- },
104787
- "carbon-monoxide": {
104788
- label: "Carbon monoxide",
104789
- addService: (accessory, name, subtype) => accessory.addService(import_dist.Service.CarbonMonoxideSensor, name, subtype),
104790
- getStatus: (proxy) => proxy.carbonMonoxide?.getStatus({}),
104791
- subscribe: (proxy, onValue) => proxy.state.carbonMonoxide?.subscribe(onValue) ?? null,
104792
- read: reader(CarbonMonoxideStatusSchema.pick({ detected: true }), (status) => [{
104793
- characteristic: import_dist.Characteristic.CarbonMonoxideDetected,
104794
- value: status.detected ? import_dist.Characteristic.CarbonMonoxideDetected.CO_LEVELS_ABNORMAL : import_dist.Characteristic.CarbonMonoxideDetected.CO_LEVELS_NORMAL
104795
- }])
104796
- },
104797
- flood: {
104798
- label: "Leak",
104799
- addService: (accessory, name, subtype) => accessory.addService(import_dist.Service.LeakSensor, name, subtype),
104800
- getStatus: (proxy) => proxy.flood?.getStatus({}),
104801
- subscribe: (proxy, onValue) => proxy.state.flood?.subscribe(onValue) ?? null,
104802
- read: reader(FloodStatusSchema.pick({ flooded: true }), (status) => [{
104803
- characteristic: import_dist.Characteristic.LeakDetected,
104804
- value: status.flooded ? import_dist.Characteristic.LeakDetected.LEAK_DETECTED : import_dist.Characteristic.LeakDetected.LEAK_NOT_DETECTED
104805
- }])
104806
- },
104807
- "temperature-sensor": {
104808
- label: "Temperature",
104809
- addService: (accessory, name, subtype) => accessory.addService(import_dist.Service.TemperatureSensor, name, subtype),
104810
- getStatus: (proxy) => proxy.temperatureSensor?.getStatus({}),
104811
- subscribe: (proxy, onValue) => proxy.state.temperatureSensor?.subscribe(onValue) ?? null,
104812
- read: reader(TemperatureSensorStatusSchema.pick({ celsius: true }), (status) => [{
104813
- characteristic: import_dist.Characteristic.CurrentTemperature,
104814
- value: status.celsius
104815
- }])
104816
- },
104817
- "humidity-sensor": {
104818
- label: "Humidity",
104819
- addService: (accessory, name, subtype) => accessory.addService(import_dist.Service.HumiditySensor, name, subtype),
104820
- getStatus: (proxy) => proxy.humiditySensor?.getStatus({}),
104821
- subscribe: (proxy, onValue) => proxy.state.humiditySensor?.subscribe(onValue) ?? null,
104822
- read: reader(HumiditySensorStatusSchema.pick({ percent: true }), (status) => [{
104823
- characteristic: import_dist.Characteristic.CurrentRelativeHumidity,
104824
- value: status.percent
104825
- }])
104826
- },
104827
- "ambient-light-sensor": {
104828
- label: "Light level",
104829
- addService: (accessory, name, subtype) => accessory.addService(import_dist.Service.LightSensor, name, subtype),
104830
- getStatus: (proxy) => proxy.ambientLightSensor?.getStatus({}),
104831
- subscribe: (proxy, onValue) => proxy.state.ambientLightSensor?.subscribe(onValue) ?? null,
104832
- read: reader(AmbientLightSensorStatusSchema.pick({ lux: true }), (status) => [{
104833
- characteristic: import_dist.Characteristic.CurrentAmbientLightLevel,
104834
- value: Math.max(MIN_LUX, status.lux)
104835
- }])
106215
+ async function buildCameraAccessory(input) {
106216
+ const { ctx, deviceId, displayName, options } = input;
106217
+ const numericId = Number.parseInt(deviceId, 10);
106218
+ if (!Number.isFinite(numericId)) throw new Error(`export-hap: cannot map device '${deviceId}' — id is not numeric`);
106219
+ const proxy = await ctx.fetchDevice(numericId);
106220
+ const capNames = new Set(proxy.binding?.entries.map((e) => e.capName) ?? []);
106221
+ const isDoorbell = capNames.has("doorbell");
106222
+ const category = isDoorbell ? import_dist.Categories.VIDEO_DOORBELL : import_dist.Categories.IP_CAMERA;
106223
+ const accessory = new import_dist.Accessory(displayName, accessoryUuidFor("camera", numericId));
106224
+ accessory.category = category;
106225
+ await populateAccessoryInfo(accessory, proxy, displayName, "Camera");
106226
+ const bctx = {
106227
+ ctx,
106228
+ accessory,
106229
+ proxy,
106230
+ numericDeviceId: numericId,
106231
+ displayName,
106232
+ options
106233
+ };
106234
+ const advertisedVideo = await probeAdvertisedVideoProfile(bctx);
106235
+ const streams = buildCameraStreamingDelegate(bctx, advertisedVideo);
106236
+ const handles = [];
106237
+ if (capNames.has("intercom")) handles.push(await buildIntercom({
106238
+ bctx,
106239
+ streamingOptions: streams.streamingOptions
106240
+ }));
106241
+ const recordingEnabled = options.hapDeviceSettings.hksvRecording === true;
106242
+ let recordingAudioActive = true;
106243
+ const recording = recordingEnabled ? await buildHksvRecording({
106244
+ bctx,
106245
+ fpsByProfile: advertisedVideo.fpsByProfile,
106246
+ isAudioActive: () => recordingAudioActive
106247
+ }) : null;
106248
+ const controller = new (isDoorbell ? import_dist.DoorbellController : import_dist.CameraController)({
106249
+ delegate: streams.delegate,
106250
+ streamingOptions: streams.streamingOptions,
106251
+ cameraStreamCount: 2,
106252
+ ...recording === null ? {} : { recording },
106253
+ ...recording === null || !capNames.has("motion-detection") ? {} : { sensors: { motion: true } }
106254
+ });
106255
+ accessory.configureController(controller);
106256
+ if (options.hapDeviceSettings.multiTierService !== false) {
106257
+ const sensor = advertisedVideo.sensor;
106258
+ const services = buildMultiTierServices({
106259
+ deviceId: numericId,
106260
+ logger: ctx.logger,
106261
+ width: sensor.width,
106262
+ height: sensor.height,
106263
+ codec: sensor.codec,
106264
+ audioCaptureRateHz: 16e3,
106265
+ negotiation: buildMultiTierNegotiation({
106266
+ deviceId: numericId,
106267
+ logger: ctx.logger,
106268
+ negotiator: streams.negotiator,
106269
+ tiers: tiersForSensor(sensor.width, sensor.height),
106270
+ codec: sensor.codec
106271
+ })
106272
+ });
106273
+ accessory.addService(services.capabilities);
106274
+ accessory.addService(services.globalOperatingMode);
106275
+ accessory.addService(services.streamManagement);
106276
+ ctx.logger.info("hap multi-tier: services declared on this accessory", {
106277
+ tags: { deviceId: numericId },
106278
+ meta: {
106279
+ serviceUuid: MULTI_TIER_RTP_SERVICE_UUID,
106280
+ codec: sensor.codec === TierVideoCodec.H265 ? "h265" : "h264",
106281
+ sensor: `${sensor.width}x${sensor.height}`
106282
+ }
106283
+ });
104836
106284
  }
104837
- };
104838
- /**
104839
- * The `battery` row reads like a sensor but writes three characteristics from
104840
- * one status, and the camera path publishes the SAME service from the same cap.
104841
- * The derivation therefore lives once, in `battery.ts`, and both callers read
104842
- * it from there.
104843
- */
104844
- var BATTERY_SPEC = {
104845
- label: "Battery",
104846
- addService: (accessory, name, subtype) => accessory.addService(import_dist.Service.Battery, name, subtype),
104847
- getStatus: (proxy) => proxy.battery?.getStatus({}),
104848
- subscribe: (proxy, onValue) => proxy.state.battery?.subscribe(onValue) ?? null,
104849
- read: batteryCharacteristicUpdates
104850
- };
104851
- function sensorRow(capName, spec) {
106285
+ if (recording !== null) {
106286
+ handles.push({ dispose: () => recording.dispose() });
106287
+ const audioCharacteristic = (controller.recordingManagement?.operatingModeService)?.getCharacteristic(import_dist.Characteristic.RecordingAudioActive);
106288
+ if (audioCharacteristic) {
106289
+ recordingAudioActive = audioCharacteristic.value !== 0 && audioCharacteristic.value !== false;
106290
+ audioCharacteristic.on("change", ({ newValue }) => {
106291
+ recordingAudioActive = newValue !== 0 && newValue !== false;
106292
+ });
106293
+ }
106294
+ }
106295
+ if (capNames.has("motion-detection")) handles.push(await buildMotionSensor(bctx, recording === null ? null : controller.motionService ?? null));
106296
+ if (isDoorbell && controller instanceof import_dist.DoorbellController) handles.push(await buildDoorbell({
106297
+ bctx,
106298
+ controller
106299
+ }));
106300
+ if (capNames.has("ptz") || capNames.has("ptz-autotrack")) handles.push(await buildPtz(bctx));
106301
+ if (capNames.has("battery")) handles.push(await buildBattery(bctx));
106302
+ if (capNames.has("privacy-mask")) handles.push(await buildPrivacySwitch(bctx));
106303
+ handles.push(await buildChildServicesFor({ parentCtx: bctx }));
104852
106304
  return {
104853
- caps: [capName],
104854
- label: spec.label,
104855
- build: ({ bctx, name }) => buildSensorService({
104856
- bctx,
104857
- spec,
104858
- name,
104859
- subtype: capName
104860
- })
106305
+ accessory,
106306
+ accessories: [accessory],
106307
+ async dispose() {
106308
+ const log = ctx.logger.withTags({ deviceId: numericId });
106309
+ for (const h of handles) try {
106310
+ await h.dispose();
106311
+ } catch (err) {
106312
+ log.debug("export-hap: builder dispose failed (continuing)", { meta: { error: errMsg$2(err) } });
106313
+ }
106314
+ try {
106315
+ await streams.dispose();
106316
+ } catch (err) {
106317
+ log.debug("export-hap: streams dispose failed", { meta: { error: errMsg$2(err) } });
106318
+ }
106319
+ }
104861
106320
  };
104862
106321
  }
104863
- /**
104864
- * THE table. Order matters only for one thing: the first row that matches a
104865
- * device decides the accessory's category when the device's own type does not.
104866
- */
104867
- var HAP_CAP_SERVICES = [
104868
- {
104869
- caps: ["switch", "brightness"],
104870
- label: "Power",
104871
- build: ({ bctx, name, deviceType }) => buildChildSwitch({
104872
- ...bctx,
104873
- displayName: name
104874
- }, "switch", deviceType)
104875
- },
104876
- {
104877
- caps: ["lock-control"],
104878
- label: "Lock",
104879
- build: ({ bctx, name }) => buildLockMechanism({
104880
- ...bctx,
104881
- displayName: name
104882
- })
104883
- },
104884
- {
104885
- caps: ["alarm-panel"],
104886
- label: "Alarm",
104887
- build: ({ bctx, name }) => buildSecuritySystem({
104888
- ...bctx,
104889
- displayName: name
104890
- })
104891
- },
104892
- {
104893
- caps: ["battery"],
104894
- label: BATTERY_SPEC.label,
104895
- build: ({ bctx, name }) => buildSensorService({
104896
- bctx,
104897
- spec: BATTERY_SPEC,
104898
- name,
104899
- subtype: "battery"
104900
- })
104901
- },
104902
- ...Object.entries(SENSOR_SPECS).map(([capName, spec]) => sensorRow(capName, spec))
104903
- ];
104904
- /**
104905
- * The rows a device's bound capabilities select, in table order.
104906
- *
104907
- * A row matches when ANY of its caps is bound: a lamp with `switch` but no
104908
- * `brightness` is still a Lightbulb-shaped row, and the builder degrades on its
104909
- * own.
104910
- */
104911
- function rowsForCaps(capNames) {
104912
- return HAP_CAP_SERVICES.filter((row) => row.caps.some((cap) => capNames.has(cap)));
104913
- }
104914
- /**
104915
- * The HomeKit accessory category per camstack device type — the icon iOS Home
104916
- * draws and nothing else. Absent = `OTHER`, which is a valid accessory that
104917
- * simply gets the generic tile.
104918
- *
104919
- * This is NOT a coverage decision. `HAP_EXPORTABLE_DEVICE_TYPES` (in
104920
- * `mappers/index.ts`) filters the picker; whether a device exports anything is
104921
- * decided by `rowsForCaps`.
104922
- */
104923
- var ACCESSORY_CATEGORY_BY_TYPE = {
104924
- [DeviceType.Switch]: import_dist.Categories.SWITCH,
104925
- [DeviceType.Light]: import_dist.Categories.LIGHTBULB,
104926
- [DeviceType.Siren]: import_dist.Categories.SWITCH,
104927
- [DeviceType.Lock]: import_dist.Categories.DOOR_LOCK,
104928
- [DeviceType.Sensor]: import_dist.Categories.SENSOR,
104929
- [DeviceType.Presence]: import_dist.Categories.SENSOR,
104930
- [DeviceType.AlarmPanel]: import_dist.Categories.SECURITY_SYSTEM
104931
- };
104932
- function accessoryCategoryFor(deviceType) {
104933
- return ACCESSORY_CATEGORY_BY_TYPE[deviceType] ?? import_dist.Categories.OTHER;
106322
+ function errMsg$2(err) {
106323
+ return err instanceof Error ? err.message : String(err);
104934
106324
  }
104935
106325
  //#endregion
104936
106326
  //#region src/mappers/generic-accessory.ts
@@ -105034,118 +106424,6 @@ function getMapperFactory(kind) {
105034
106424
  return factory;
105035
106425
  }
105036
106426
  //#endregion
105037
- //#region src/mappers/builders/stream-hwaccel-memo.ts
105038
- /**
105039
- * The two bounded memos HomeKit's decode path owns.
105040
- *
105041
- * ## Why they exist
105042
- *
105043
- * Everything D67 was actually about — one argv builder, one set of constants,
105044
- * one hwaccel authority — HomeKit already had. What it did NOT have were the
105045
- * two things the broker gained alongside them:
105046
- *
105047
- * 1. **A memo.** `probeDecoderHwaccel` issued a cross-process
105048
- * `decoder.getInfo` per SESSION. iOS starts sessions in bursts — one on
105049
- * record was started three times in 16 s — and every one of those paid a
105050
- * cap call on a hub whose main thread is the scarce resource.
105051
- * 2. **Failure feedback.** When HomeKit's hardware child died at init and
105052
- * `shouldRetryInSoftware` saved the session, HomeKit told nobody. The next
105053
- * session re-picked the same corpse and paid the same two-second death.
105054
- * `EgressTranscodeManager` fixed exactly this for its own children
105055
- * (26a522cd5) by reporting the dead backend into the broker's 60 s memo.
105056
- *
105057
- * ## Both ride `HwAccelCache`, deliberately
105058
- *
105059
- * `createHwAccelCache` from `@camstack/types` is the primitive the broker's own
105060
- * `egressHwAccelCache` is built from, and the discipline it encodes is the
105061
- * point: **caller-owned, never a module global** — a module global would
105062
- * outlive an addon respawn and survive an operator changing the decoder
105063
- * backend. Same TTL as the broker's, so "has hardware come back yet" cannot
105064
- * answer differently depending on which consumer asked.
105065
- *
105066
- * ## The cross-process gap, stated honestly
105067
- *
105068
- * These memos are scoped to the `export-hap` PROCESS. When HomeKit's vaapi
105069
- * child dies, the broker's next child still pays its own two-second death, and
105070
- * vice versa — because addons may never import each other and there is no
105071
- * capability for "this backend is dead on this node right now". Closing that
105072
- * would need a new cap surface, which Phase 0 explicitly does not take. What is
105073
- * closed here is HomeKit's own repetition of the cost, across cameras and
105074
- * across sessions.
105075
- */
105076
- /**
105077
- * The window both memos answer for.
105078
- *
105079
- * 60 s, the same as `stream-broker-manager`'s `egressHwAccelCache`. Long enough
105080
- * that a burst of session restarts pays one read; short enough that an operator
105081
- * who changes the decoder backend, or a host whose accelerator recovers, is
105082
- * obeyed on the next session rather than after an addon respawn.
105083
- */
105084
- var HAP_DECODE_MEMO_TTL_MS = 6e4;
105085
- /**
105086
- * Separator inside the encoded reading. A control character, because a backend
105087
- * name is `[a-z0-9]+` and the decoder's non-backend choices are `auto` /
105088
- * `none` / `''`, none of which can contain one — so the split is total.
105089
- */
105090
- var READING_SEPARATOR = "";
105091
- /**
105092
- * Marks a `null` FIELD, distinct from an EMPTY one.
105093
- *
105094
- * `probedBestHwaccel: ''` means the decoder answered and has never probed
105095
- * (=> `not-probed`); `null` means the field was absent altogether. Encoding
105096
- * both as `''` would lose a distinction `selectHwDecode` acts on.
105097
- */
105098
- var NULL_FIELD = "\0";
105099
- function encodeField(value) {
105100
- return value === null ? NULL_FIELD : value;
105101
- }
105102
- function decodeField(value) {
105103
- return value === NULL_FIELD ? null : value;
105104
- }
105105
- /**
105106
- * A reading as ONE `string | null`, which is what {@link HwAccelCache} stores.
105107
- *
105108
- * The cache's three states are exactly the three a memoised reading needs:
105109
- * `undefined` (never asked, or expired), `null` (asked, and the decoder could
105110
- * not be reached), and a value. Encoding into the one cache rather than
105111
- * splitting across two is what keeps those three from skewing — two caches
105112
- * written together can still be READ across an expiry boundary.
105113
- */
105114
- function encodeDecoderReading(reading) {
105115
- if (reading === null) return null;
105116
- return `${encodeField(reading.hwaccel)}${READING_SEPARATOR}${encodeField(reading.probedBestHwaccel)}`;
105117
- }
105118
- function decodeDecoderReading(value) {
105119
- if (value === null) return null;
105120
- const [hwaccel = NULL_FIELD, probed = NULL_FIELD] = value.split(READING_SEPARATOR);
105121
- return {
105122
- hwaccel: decodeField(hwaccel),
105123
- probedBestHwaccel: decodeField(probed)
105124
- };
105125
- }
105126
- function createDecoderReadingMemo(options) {
105127
- const cache = createHwAccelCache(options);
105128
- return {
105129
- read() {
105130
- const cached = cache.read();
105131
- return cached === void 0 ? void 0 : decodeDecoderReading(cached);
105132
- },
105133
- write(reading) {
105134
- cache.write(encodeDecoderReading(reading));
105135
- }
105136
- };
105137
- }
105138
- function createHapDecodeMemos(now) {
105139
- const options = {
105140
- ttlMs: HAP_DECODE_MEMO_TTL_MS,
105141
- ...now ? { now } : {}
105142
- };
105143
- return {
105144
- reading: createDecoderReadingMemo(options),
105145
- failedBackend: createHwAccelCache(options)
105146
- };
105147
- }
105148
- //#endregion
105149
106427
  //#region src/reconcile/sync-state.ts
105150
106428
  function syncStateFromJson(json) {
105151
106429
  const map = /* @__PURE__ */ new Map();
@@ -105331,6 +106609,16 @@ var ExportHapAddon = class extends BaseAddon {
105331
106609
  */
105332
106610
  decodeMemos = createHapDecodeMemos();
105333
106611
  /**
106612
+ * Where HomeKit's own clips are kept.
106613
+ *
106614
+ * ONE per addon: HomeKit Secure Video is a SINK — HAP has no read-back, so a
106615
+ * clip is ours only if we kept a copy of what we SENT — and the copy has to
106616
+ * be pruned against bounds that span every camera, which a per-camera store
106617
+ * could not see. Created at boot rather than lazily so the directory's
106618
+ * failure to exist is one line at boot and not a surprise mid-recording.
106619
+ */
106620
+ hksvClipStore = null;
106621
+ /**
105334
106622
  * THE bridge. Lazily created and published the first time a non-camera
105335
106623
  * accessory needs it; never torn down while the addon runs, because an
105336
106624
  * unpublish would drop a pairing the operator already entered a code for.
@@ -105356,6 +106644,14 @@ var ExportHapAddon = class extends BaseAddon {
105356
106644
  this.lastError = errMsg(err);
105357
106645
  this.ctx.logger.error("export-hap: HAP storage init failed", { meta: { error: this.lastError } });
105358
106646
  }
106647
+ this.hksvClipStore = new HksvClipStore({
106648
+ logger: this.ctx.logger.child("hksv-clips"),
106649
+ rootDir: path.join(this.ctx.dataDir, CLIP_DIR_NAME),
106650
+ mintThumbnail: createClipThumbnailMinter({ runner: createFfmpegClipThumbnailRunner({
106651
+ ffmpegBinaryPath: "ffmpeg",
106652
+ spawnFn: spawn
106653
+ }) })
106654
+ });
105359
106655
  const validKinds = new Set(SUPPORTED_MAPPER_KINDS);
105360
106656
  const cleaned = this.config.exposed.filter((entry) => {
105361
106657
  if (validKinds.has(entry.mapperKind)) return true;
@@ -105548,6 +106844,7 @@ var ExportHapAddon = class extends BaseAddon {
105548
106844
  options: {
105549
106845
  ptzPulseMs: this.config.ptzPulseMs,
105550
106846
  decodeMemos: this.decodeMemos,
106847
+ hksvClipStore: this.hksvClipStore,
105551
106848
  hapDeviceSettings: {
105552
106849
  streamPreference: entrySettings.streamPreference ?? "auto",
105553
106850
  hksvRecording: resolveHksvRecording(entrySettings),