@camstack/addon-export-hap 1.2.126 → 1.2.128

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -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-Bs3xPtSh.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(),
@@ -28135,11 +28362,15 @@ DeviceType.Light, DeviceType.Siren, DeviceType.Switch, method(object({
28135
28362
  *
28136
28363
  * Adding a fifth name to that list would have been the wrong fix twice over:
28137
28364
  * that page is per-camera DETECTION tuning, and a grid's geometry belongs
28138
- * beside PTZ and motion zones on the camera itself — which is exactly where the
28139
- * framework already puts a widget, when the widget is declared on a CAPABILITY.
28140
- * `deviceConfig.ui` is the mechanism (`motion-zones`, `ptz`, `ptz-autotrack`
28141
- * all use it): the framework emits the structural section and the widget
28142
- * self-persists through the cap's own mutations.
28365
+ * beside PTZ and motion zones on the camera itself. The device page is
28366
+ * BINDING-driven (D12), so the way in is a capability bound to the device —
28367
+ * and this cap carries its section the way `recording` does, by RETURNING it
28368
+ * from `getDeviceSettingsContribution`.
28369
+ *
28370
+ * Seven other widgets are still declared the other way, through a
28371
+ * `deviceConfig.ui` block the framework derives a section from. That route
28372
+ * gives the addon no say in where its own panel lands and no way to decline
28373
+ * for a device the panel does not suit, which is why this one does not use it.
28143
28374
  *
28144
28375
  * ## Why one addon may implement it
28145
28376
  *
@@ -28195,16 +28426,37 @@ var GridProfileOfferSchema = object({
28195
28426
  ]),
28196
28427
  offered: boolean(),
28197
28428
  /** Sources that cannot serve it. Empty when it is offered, or when there are no cells. */
28198
- 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()
28199
28447
  });
28200
28448
  var GridLayoutViewSchema = object({
28201
28449
  /** The persisted grid row this camera was declared from. */
28202
28450
  instanceId: string(),
28203
28451
  deviceId: number().int().nonnegative(),
28204
28452
  name: string(),
28205
- /** The canvas the operator laid out — this grid's `high`. mid/low are scaled from it. */
28206
- width: number().int(),
28207
- 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
+ */
28208
28460
  fps: number().int(),
28209
28461
  cells: array(GridLayoutCellSchema),
28210
28462
  /** What the catalog will publish, and what it refuses to. Read-only. */
@@ -28213,9 +28465,13 @@ var GridLayoutViewSchema = object({
28213
28465
  var GridLayoutPatchSchema = object({
28214
28466
  deviceId: number().int().nonnegative(),
28215
28467
  name: string().min(1).max(160).optional(),
28216
- width: number().int().min(160).max(7680).optional(),
28217
- height: number().int().min(120).max(4320).optional(),
28218
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(),
28219
28475
  /**
28220
28476
  * The whole cell list at once. A per-cell patch would need an ordering the
28221
28477
  * editor does not have, and a half-applied layout is a picture nobody asked
@@ -38795,6 +39051,12 @@ Object.freeze({
38795
39051
  addonId: null,
38796
39052
  access: "view"
38797
39053
  },
39054
+ "webrtcSession.getSessionDebug": {
39055
+ capName: "webrtc-session",
39056
+ capScope: "device",
39057
+ addonId: null,
39058
+ access: "view"
39059
+ },
38798
39060
  "webrtcSession.getSessionState": {
38799
39061
  capName: "webrtc-session",
38800
39062
  capScope: "device",
@@ -40778,6 +41040,11 @@ Object.freeze({
40778
41040
  form: "single",
40779
41041
  optional: false
40780
41042
  }],
41043
+ "webrtcSession.getSessionDebug": [{
41044
+ name: "deviceId",
41045
+ form: "single",
41046
+ optional: false
41047
+ }],
40781
41048
  "webrtcSession.getSessionState": [{
40782
41049
  name: "deviceId",
40783
41050
  form: "single",
@@ -41396,6 +41663,60 @@ DEFAULT_NATIVE_LEASE_SETTINGS.sceneBudgetMb;
41396
41663
  DEFAULT_NATIVE_LEASE_SETTINGS.admission;
41397
41664
  var MB = 1024 * 1024;
41398
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");
41399
41720
  var DEFAULT_FIRST_UNIT_TIMEOUT_MS = 12e3;
41400
41721
  /** Heartbeat cadence — ~2 minutes of 4 s fragments. */
41401
41722
  var FRAGMENT_LOG_EVERY = 30;
@@ -88377,6 +88698,43 @@ var BridgeHost = class {
88377
88698
  }
88378
88699
  };
88379
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
88380
88738
  //#region src/hap-setup-uri.ts
88381
88739
  function errMsg$15(e) {
88382
88740
  return e instanceof Error ? e.message : String(e);
@@ -88411,47 +88769,610 @@ function firstExposedAccessorySetupUri(exposed, logger) {
88411
88769
  }
88412
88770
  }
88413
88771
  //#endregion
88414
- //#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
+ });
88415
88851
  /**
88416
- * `Service.AccessoryInformation` — the manufacturer / model / firmware /
88417
- * serial block every HomeKit accessory carries.
88418
- *
88419
- * One implementation for both accessory shapes (camera and generic): the
88420
- * fields come from the device's own metadata either way, and a second copy
88421
- * would be a second answer to "what serial does this device publish".
88422
- * 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.
88423
88854
  */
88424
- async function populateAccessoryInfo(accessory, proxy, displayName, modelFallback) {
88425
- const info = accessory.getService(import_dist.Service.AccessoryInformation);
88426
- 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) {
88427
89244
  try {
88428
- const device = await proxy.deviceManager?.getDevice({});
88429
- const metadata = readMetadata(device);
88430
- info.setCharacteristic(import_dist.Characteristic.Name, device?.name ?? displayName);
88431
- info.setCharacteristic(import_dist.Characteristic.Manufacturer, stringOr(metadata?.manufacturer, "CamStack"));
88432
- info.setCharacteristic(import_dist.Characteristic.Model, stringOr(metadata?.model, modelFallback));
88433
- info.setCharacteristic(import_dist.Characteristic.FirmwareRevision, stringOr(metadata?.firmware, "0.0.0"));
88434
- info.setCharacteristic(import_dist.Characteristic.SerialNumber, stringOr(metadata?.sn, `camstack-${proxy.deviceId}`));
88435
- } catch {}
89245
+ return (await stat(path)).size;
89246
+ } catch {
89247
+ return 0;
89248
+ }
88436
89249
  }
88437
- /** The four fields this module reads, or `null` — the device record's
88438
- * `metadata` is an open bag and nothing else here depends on its shape. */
88439
- function readMetadata(device) {
88440
- const metadata = device?.metadata;
88441
- if (metadata === null || typeof metadata !== "object") return {};
88442
- 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");
88443
89258
  return {
88444
- model: stringOrNull(entries.get("model")),
88445
- manufacturer: stringOrNull(entries.get("manufacturer")),
88446
- firmware: stringOrNull(entries.get("firmware")),
88447
- 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
+ }
88448
89267
  };
88449
89268
  }
88450
- function stringOrNull(value) {
88451
- 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
+ ];
88452
89290
  }
88453
- function stringOr(value, fallback) {
88454
- 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
+ });
88455
89376
  }
88456
89377
  //#endregion
88457
89378
  //#region src/mappers/builders/generic/characteristic-update.ts
@@ -88781,6 +89702,910 @@ function errMsg$14(err) {
88781
89702
  return err instanceof Error ? err.message : String(err);
88782
89703
  }
88783
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
88784
90609
  //#region ../../node_modules/werift-rtp/lib/rtp/src/srtp/const.js
88785
90610
  var require_const = /* @__PURE__ */ __commonJSMin(((exports) => {
88786
90611
  Object.defineProperty(exports, "__esModule", { value: true });
@@ -100097,7 +101922,7 @@ var VIDEO_LOOPBACK_RCVBUF_BYTES = 8 * 1024 * 1024;
100097
101922
  * not burst size.
100098
101923
  */
100099
101924
  var AUDIO_LOOPBACK_RCVBUF_BYTES = 1024 * 1024;
100100
- function errMsg$13(err) {
101925
+ function errMsg$9(err) {
100101
101926
  return err instanceof Error ? err.message : String(err);
100102
101927
  }
100103
101928
  /**
@@ -100112,13 +101937,13 @@ function applyReceiveBuffer(socket, requestedBytes) {
100112
101937
  try {
100113
101938
  socket.setRecvBufferSize(requestedBytes);
100114
101939
  } catch (err) {
100115
- error = errMsg$13(err);
101940
+ error = errMsg$9(err);
100116
101941
  }
100117
101942
  let effectiveBytes = null;
100118
101943
  try {
100119
101944
  effectiveBytes = socket.getRecvBufferSize();
100120
101945
  } catch (err) {
100121
- if (error === null) error = errMsg$13(err);
101946
+ if (error === null) error = errMsg$9(err);
100122
101947
  }
100123
101948
  return {
100124
101949
  requestedBytes,
@@ -100389,20 +102214,20 @@ function buildCameraStreamingDelegate(bctx, advertised) {
100389
102214
  delegate: {
100390
102215
  handleSnapshotRequest(request, callback) {
100391
102216
  handleSnapshot(bctx, request).then((buf) => callback(void 0, buf)).catch((err) => {
100392
- log.warn("export-hap: snapshot failed", { meta: { error: errMsg$12(err) } });
100393
- 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)));
100394
102219
  });
100395
102220
  },
100396
102221
  prepareStream(request, callback) {
100397
102222
  prepareStream(request, sessions, bctx, "classic").then((resp) => callback(void 0, resp)).catch((err) => {
100398
- log.warn("export-hap: prepareStream failed", { meta: { error: errMsg$12(err) } });
100399
- 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)));
100400
102225
  });
100401
102226
  },
100402
102227
  handleStreamRequest(request, callback) {
100403
102228
  handleStreamRequest(request, sessions, bctx, advertised).then(() => callback()).catch((err) => {
100404
- log.warn("export-hap: handleStreamRequest failed", { meta: { error: errMsg$12(err) } });
100405
- 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)));
100406
102231
  });
100407
102232
  }
100408
102233
  },
@@ -100541,7 +102366,7 @@ async function prepareStream(request, sessions, bctx, negotiatedVia) {
100541
102366
  closeSocket(audioUdp);
100542
102367
  closeSocket(videoLoopUdp);
100543
102368
  closeSocket(audioLoopUdp);
100544
- 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 });
100545
102370
  }
100546
102371
  let upstreamAudioSrtp = null;
100547
102372
  try {
@@ -100555,7 +102380,7 @@ async function prepareStream(request, sessions, bctx, negotiatedVia) {
100555
102380
  profile: import_src.ProtectionProfileAes128CmHmacSha1_80
100556
102381
  });
100557
102382
  } catch (err) {
100558
- 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) } });
100559
102384
  }
100560
102385
  const videoSsrc = randomSsrc();
100561
102386
  const audioSsrc = randomSsrc();
@@ -100675,7 +102500,7 @@ async function prepareStream(request, sessions, bctx, negotiatedVia) {
100675
102500
  });
100676
102501
  audioUdp.on("message", (packet, rinfo) => {
100677
102502
  handleIncomingAudioRtp(session, packet, rinfo.address, bctx).catch((err) => {
100678
- 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) } });
100679
102504
  });
100680
102505
  });
100681
102506
  logLoopbackBuffer(tagLog, request.sessionID, "video", videoLoop.buffer);
@@ -100871,7 +102696,7 @@ function readControllerRtcp(session, leg, packet, log) {
100871
102696
  } catch (err) {
100872
102697
  drop(session, "inbound-rtcp-decrypt-failed");
100873
102698
  storeReceiverReports(session, leg, recordUnreadableRtcp(tally));
100874
- logUnreadableRtcp(session, leg, `decrypt: ${errMsg$12(err)}`, log);
102699
+ logUnreadableRtcp(session, leg, `decrypt: ${errMsg$8(err)}`, log);
100875
102700
  return;
100876
102701
  }
100877
102702
  const outcome = ingestDecryptedRtcp(plaintext, tally);
@@ -101689,7 +103514,7 @@ async function handleIncomingAudioRtp(session, packet, sourceAddress, bctx) {
101689
103514
  session.upstreamRtpDecryptFailures += 1;
101690
103515
  if (session.upstreamRtpDecryptFailures % 100 === 1) log.debug("export-hap: SRTP decrypt failed (rate-limited)", { meta: {
101691
103516
  failures: session.upstreamRtpDecryptFailures,
101692
- error: errMsg$12(err)
103517
+ error: errMsg$8(err)
101693
103518
  } });
101694
103519
  return;
101695
103520
  }
@@ -101703,7 +103528,7 @@ async function handleIncomingAudioRtp(session, packet, sourceAddress, bctx) {
101703
103528
  rtpPayloadType = parsedRtp.header.payloadType;
101704
103529
  } catch (err) {
101705
103530
  drop(session, "upstream-parse-failed");
101706
- 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) } });
101707
103532
  return;
101708
103533
  }
101709
103534
  const negotiatedAudioPt = session.lastStartParams?.audioPt;
@@ -101727,7 +103552,7 @@ async function handleIncomingAudioRtp(session, packet, sourceAddress, bctx) {
101727
103552
  if (session.intercomTalkSessionId === null) {
101728
103553
  session.intercomTalkSessionId = "";
101729
103554
  const opened = await openIntercomTalkSession(bctx).catch((err) => {
101730
- 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) } });
101731
103556
  return null;
101732
103557
  });
101733
103558
  if (opened) {
@@ -101754,7 +103579,7 @@ async function handleIncomingAudioRtp(session, packet, sourceAddress, bctx) {
101754
103579
  drop(session, "upstream-push-failed");
101755
103580
  log.debug("export-hap: intercom.pushTalkAudio failed (will re-open on next frame)", { meta: {
101756
103581
  sequenceNumber: session.intercomPcmSequence,
101757
- error: errMsg$12(err)
103582
+ error: errMsg$8(err)
101758
103583
  } });
101759
103584
  session.intercomTalkSessionId = null;
101760
103585
  }
@@ -101786,7 +103611,7 @@ async function closeIntercomTalkSession(session, bctx) {
101786
103611
  } catch (err) {
101787
103612
  log.debug("export-hap: intercom.endTalkSession failed (continuing)", { meta: {
101788
103613
  sessionId,
101789
- error: errMsg$12(err)
103614
+ error: errMsg$8(err)
101790
103615
  } });
101791
103616
  }
101792
103617
  }
@@ -101811,7 +103636,7 @@ function closeSocket(udp) {
101811
103636
  function randomSsrc() {
101812
103637
  return Math.floor(Math.random() * 2147483646) + 1 | 0;
101813
103638
  }
101814
- function errMsg$12(err) {
103639
+ function errMsg$8(err) {
101815
103640
  return err instanceof Error ? err.message : String(err);
101816
103641
  }
101817
103642
  //#endregion
@@ -101895,14 +103720,14 @@ async function buildDoorbell(input) {
101895
103720
  }
101896
103721
  log.info("export-hap: doorbell SINGLE_PRESS pushed to HomeKit", { meta: { ...delivery } });
101897
103722
  } catch (err) {
101898
- log.warn("export-hap: ringDoorbell() failed", { meta: { error: errMsg$11(err) } });
103723
+ log.warn("export-hap: ringDoorbell() failed", { meta: { error: errMsg$7(err) } });
101899
103724
  }
101900
103725
  });
101901
103726
  return { async dispose() {
101902
103727
  unsubscribe();
101903
103728
  } };
101904
103729
  }
101905
- function errMsg$11(err) {
103730
+ function errMsg$7(err) {
101906
103731
  return err instanceof Error ? err.message : String(err);
101907
103732
  }
101908
103733
  //#endregion
@@ -101950,7 +103775,7 @@ async function buildMotionSensor(bctx, existing = null) {
101950
103775
  const detected = await proxy.motion?.isDetected({});
101951
103776
  if (typeof detected === "boolean") motionService.updateCharacteristic(import_dist.Characteristic.MotionDetected, detected);
101952
103777
  } catch (err) {
101953
- 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) } });
101954
103779
  }
101955
103780
  let resetTimer = null;
101956
103781
  const armReset = () => {
@@ -101984,7 +103809,7 @@ async function buildMotionSensor(bctx, existing = null) {
101984
103809
  }
101985
103810
  } };
101986
103811
  }
101987
- function errMsg$10(err) {
103812
+ function errMsg$6(err) {
101988
103813
  return err instanceof Error ? err.message : String(err);
101989
103814
  }
101990
103815
  //#endregion
@@ -102015,7 +103840,7 @@ async function buildPrivacySwitch(bctx) {
102015
103840
  const status = await proxy.privacyMask?.getStatus({});
102016
103841
  if (status && typeof status.enabled === "boolean") service.updateCharacteristic(import_dist.Characteristic.On, status.enabled);
102017
103842
  } catch (err) {
102018
- 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) } });
102019
103844
  }
102020
103845
  service.getCharacteristic(import_dist.Characteristic.On).onSet(async (value) => {
102021
103846
  const enabled = value === true;
@@ -102024,7 +103849,7 @@ async function buildPrivacySwitch(bctx) {
102024
103849
  } catch (err) {
102025
103850
  log.warn("export-hap: privacy-mask setMask failed", { meta: {
102026
103851
  enabled,
102027
- error: errMsg$9(err)
103852
+ error: errMsg$5(err)
102028
103853
  } });
102029
103854
  }
102030
103855
  });
@@ -102042,7 +103867,7 @@ async function buildPrivacySwitch(bctx) {
102042
103867
  } catch {}
102043
103868
  } };
102044
103869
  }
102045
- function errMsg$9(err) {
103870
+ function errMsg$5(err) {
102046
103871
  return err instanceof Error ? err.message : String(err);
102047
103872
  }
102048
103873
  //#endregion
@@ -102133,7 +103958,7 @@ async function buildPtz(bctx) {
102133
103958
  } catch (err) {
102134
103959
  log.warn("export-hap: ptz.goToPreset failed", { meta: {
102135
103960
  presetId: preset.id,
102136
- error: errMsg$8(err)
103961
+ error: errMsg$4(err)
102137
103962
  } });
102138
103963
  }
102139
103964
  armReset(() => service.updateCharacteristic(import_dist.Characteristic.On, false), MOMENTARY_RESET_MS);
@@ -102151,14 +103976,14 @@ async function buildPtz(bctx) {
102151
103976
  try {
102152
103977
  await proxy.ptz?.stop({});
102153
103978
  } catch (err) {
102154
- 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) } });
102155
103980
  }
102156
103981
  service.updateCharacteristic(import_dist.Characteristic.On, false);
102157
103982
  }, options.ptzPulseMs);
102158
103983
  } catch (err) {
102159
103984
  log.warn("export-hap: ptz.continuousMove failed", { meta: {
102160
103985
  dir: dir.label,
102161
- error: errMsg$8(err)
103986
+ error: errMsg$4(err)
102162
103987
  } });
102163
103988
  service.updateCharacteristic(import_dist.Characteristic.On, false);
102164
103989
  }
@@ -102190,7 +104015,7 @@ async function readPresets(bctx) {
102190
104015
  }
102191
104016
  return entries;
102192
104017
  } catch (err) {
102193
- 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) } });
102194
104019
  return [];
102195
104020
  }
102196
104021
  }
@@ -102205,7 +104030,7 @@ async function tryBuildAutotrack(bctx) {
102205
104030
  const status = await proxy.ptzAutotrack.getStatus({});
102206
104031
  if (status && typeof status.enabled === "boolean") service.updateCharacteristic(import_dist.Characteristic.On, status.enabled);
102207
104032
  } catch (err) {
102208
- 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) } });
102209
104034
  }
102210
104035
  service.getCharacteristic(import_dist.Characteristic.On).onSet(async (value) => {
102211
104036
  const enabled = value === true;
@@ -102214,16 +104039,218 @@ async function tryBuildAutotrack(bctx) {
102214
104039
  } catch (err) {
102215
104040
  log.warn("export-hap: ptzAutotrack.setEnabled failed", { meta: {
102216
104041
  enabled,
102217
- error: errMsg$8(err)
104042
+ error: errMsg$4(err)
102218
104043
  } });
102219
104044
  }
102220
104045
  });
102221
104046
  return { async dispose() {} };
102222
104047
  }
102223
- function errMsg$8(err) {
104048
+ function errMsg$4(err) {
102224
104049
  return err instanceof Error ? err.message : String(err);
102225
104050
  }
102226
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
102227
104254
  //#region src/hksv/controller-census.ts
102228
104255
  var EMPTY_CENSUS = {
102229
104256
  serverPublished: false,
@@ -102545,8 +104572,13 @@ var HksvFragmentSource = class {
102545
104572
  * Returns `null` when there is no plane — the caller MUST treat that as
102546
104573
  * "cannot serve this recording" rather than opening an HDS stream it cannot
102547
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.
102548
104580
  */
102549
- subscribe(tag) {
104581
+ subscribe(tag, queueCapacity) {
102550
104582
  const plane = this.plane;
102551
104583
  if (plane === null || plane.isEnded) return null;
102552
104584
  const stats = plane.prebufferStats();
@@ -102561,7 +104593,8 @@ var HksvFragmentSource = class {
102561
104593
  });
102562
104594
  return plane.subscribe({
102563
104595
  tag,
102564
- withPrebuffer: true
104596
+ withPrebuffer: true,
104597
+ ...queueCapacity === void 0 ? {} : { queueCapacity }
102565
104598
  });
102566
104599
  }
102567
104600
  /** What the ring holds — surfaced so the delegate can log what it served. */
@@ -102685,6 +104718,12 @@ var HksvFragmentSource = class {
102685
104718
  //#endregion
102686
104719
  //#region src/hksv/recording-delegate.ts
102687
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
+ /**
102688
104727
  * `HDSProtocolSpecificErrorReason` is a `const enum`, so there is no reverse
102689
104728
  * map to index — and a bare number in the log is the difference between "iOS
102690
104729
  * closed it normally" and "iOS rejected our data", which is the whole reason
@@ -102713,6 +104752,12 @@ var HksvRecordingDelegate = class {
102713
104752
  log;
102714
104753
  /** The stream currently being yielded, so `closeRecordingStream` can end it. */
102715
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();
102716
104761
  constructor(input) {
102717
104762
  this.input = input;
102718
104763
  this.log = input.logger;
@@ -102793,6 +104838,7 @@ var HksvRecordingDelegate = class {
102793
104838
  };
102794
104839
  const startedAt = this.input.now();
102795
104840
  const prebufferSpanMs = source.prebufferSpanMs();
104841
+ const tee = this.openTee(streamId, source, prebufferSpanMs);
102796
104842
  let packets = 0;
102797
104843
  let bytes = 0;
102798
104844
  let markedLast = false;
@@ -102844,6 +104890,7 @@ var HksvRecordingDelegate = class {
102844
104890
  }
102845
104891
  } finally {
102846
104892
  subscription.release();
104893
+ this.finishTee(streamId, tee);
102847
104894
  if (this.open?.streamId === streamId) this.open = null;
102848
104895
  const fragmentOverrun = longestFragmentGapMs > this.input.advertisedFragmentMs * 1.5;
102849
104896
  this.log.info("hksv: recording stream finished", {
@@ -102874,6 +104921,20 @@ var HksvRecordingDelegate = class {
102874
104921
  tags: { deviceId: this.input.deviceId },
102875
104922
  meta: { streamId }
102876
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
+ }
102877
104938
  }
102878
104939
  closeRecordingStream(streamId, reason) {
102879
104940
  this.log.info("hksv: the recording stream was closed by the controller", {
@@ -102893,11 +104954,82 @@ var HksvRecordingDelegate = class {
102893
104954
  async dispose() {
102894
104955
  this.open?.subscription.release();
102895
104956
  this.open = null;
104957
+ this.tees.clear();
102896
104958
  const source = this.source;
102897
104959
  this.source = null;
102898
104960
  if (source) await source.stop("accessory disposed");
102899
104961
  }
102900
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
+ /**
102901
105033
  * Start the prebuffer when iOS wants recording AND has chosen how, stop it
102902
105034
  * otherwise. Called from every state edge rather than each edge deciding for
102903
105035
  * itself: the two characteristics arrive in an order hap-nodejs explicitly
@@ -103067,10 +105199,20 @@ async function buildHksvRecording(input) {
103067
105199
  fps,
103068
105200
  fragmentLengthMs
103069
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
+ });
103070
105211
  const delegate = new HksvRecordingDelegate({
103071
105212
  logger: log,
103072
105213
  deviceId: numericDeviceId,
103073
105214
  isAudioActive: input.isAudioActive,
105215
+ ...openClipTee === void 0 ? {} : { openClipTee },
103074
105216
  advertisedFragmentMs: fragmentLengthMs,
103075
105217
  now: () => Date.now(),
103076
105218
  describeControllers: () => describeHapControllers(bctx.accessory),
@@ -103092,7 +105234,8 @@ async function buildHksvRecording(input) {
103092
105234
  measuredFps: fps,
103093
105235
  advertisedFps: advertisedResolution?.[2] ?? null,
103094
105236
  fragmentLengthMs,
103095
- sourceGopMs: gopMs ?? "unknown"
105237
+ sourceGopMs: gopMs ?? "unknown",
105238
+ clipsKept: openClipTee !== void 0
103096
105239
  } });
103097
105240
  return {
103098
105241
  options,
@@ -103117,133 +105260,30 @@ async function readProfileEntries(bctx) {
103117
105260
  * on 615 `ext` is the 1280×720 slot the broker calls `mid` — a name-based match
103118
105261
  * would silently read the 4K slot's GOP for a 720p recording.
103119
105262
  *
103120
- * `undefined` when the cap is not bound, which is most non-Hikvision providers.
103121
- */
103122
- async function readSourceGopMs(bctx, width, height) {
103123
- try {
103124
- const status = await bctx.proxy.streamParams?.getStatus({});
103125
- if (!status) return void 0;
103126
- for (const profile of [
103127
- status.main,
103128
- status.sub,
103129
- status.ext
103130
- ]) {
103131
- if (!profile) continue;
103132
- if (profile.width !== width || profile.height !== height) continue;
103133
- const { gop, framerate } = profile;
103134
- if (gop === void 0 || gop <= 0 || framerate <= 0) return void 0;
103135
- return Math.round(gop / framerate * 1e3);
103136
- }
103137
- return;
103138
- } catch {
103139
- return;
103140
- }
103141
- }
103142
- function resolveFps(fpsByProfile, profile) {
103143
- return fpsByProfile.get(profile)?.fps ?? ASSUMED_RECORDING_FPS;
103144
- }
103145
- //#endregion
103146
- //#region src/mappers/builders/child-switch.ts
103147
- /**
103148
- * Child-switch builder — turns a camstack accessory child device (siren,
103149
- * floodlight, spotlight, …) into a HomeKit Service ON THE PARENT
103150
- * camera Accessory.
103151
- *
103152
- * The orchestrator (`child-accessory.ts → buildChildServicesFor`)
103153
- * hands this builder the PARENT accessory + the CHILD's proxy + a
103154
- * unique subtype so each service round-trips deterministically through
103155
- * HAP serialisation. The builder:
103156
- * - Maps `switch` cap → `Service.Switch`.
103157
- * - Maps `switch` + `brightness` cap → `Service.Lightbulb` with
103158
- * Brightness characteristic (lighting devices only — see below).
103159
- * - Hydrates initial state from `getStatus({})`.
103160
- * - Wires `Characteristic.On` / `Characteristic.Brightness` through
103161
- * the typed DeviceProxy.
103162
- * - Subscribes to runtime-state slice updates so iOS Home reflects
103163
- * external state changes (e.g. another integration toggling the
103164
- * light).
103165
- *
103166
- * Service-kind selection: most cameras expose `brightness` on a siren
103167
- * because it controls the alarm VOLUME, not luminosity. Mapping a
103168
- * siren onto `Service.Lightbulb` makes it appear as a regular light in
103169
- * iOS Home (wrong icon, wrong scenes, wrong Siri intents). We therefore
103170
- * gate `Service.Lightbulb` on the child's `DeviceType` — only Light /
103171
- * Generic devices keep the Lightbulb shape; Siren, Switch, and others
103172
- * fall back to `Service.Switch`. Volume control for sirens is exposed
103173
- * through the camstack admin UI; iOS Home only needs the on/off toggle.
103174
- *
103175
- * Child devices without `switch` / `brightness` cap (status sensors,
103176
- * IR LED, etc.) return a no-op handle.
105263
+ * `undefined` when the cap is not bound, which is most non-Hikvision providers.
103177
105264
  */
103178
- async function buildChildSwitch(bctx, subtype, deviceType) {
103179
- const { ctx, accessory, proxy, numericDeviceId, displayName } = bctx;
103180
- const log = ctx.logger.withTags({ deviceId: numericDeviceId });
103181
- const hasSwitch = !!proxy.switch;
103182
- const hasBrightness = !!proxy.brightness;
103183
- if (!hasSwitch && !hasBrightness) {
103184
- log.debug("export-hap: child has no switch/brightness cap — skipping");
103185
- return { async dispose() {} };
103186
- }
103187
- const isLightingDevice = deviceType === DeviceType.Light || deviceType === DeviceType.Generic;
103188
- const useLightbulb = hasBrightness && isLightingDevice;
103189
- const service = useLightbulb ? accessory.addService(import_dist.Service.Lightbulb, displayName, subtype) : accessory.addService(import_dist.Service.Switch, displayName, subtype);
103190
- applyServiceLabel(service, displayName);
105265
+ async function readSourceGopMs(bctx, width, height) {
103191
105266
  try {
103192
- const switchStatus = await proxy.switch?.getStatus({});
103193
- if (switchStatus && typeof switchStatus.on === "boolean") service.updateCharacteristic(import_dist.Characteristic.On, switchStatus.on);
103194
- } catch (err) {
103195
- log.debug("export-hap: child switch.getStatus failed (non-fatal)", { meta: { error: errMsg$7(err) } });
103196
- }
103197
- if (useLightbulb) try {
103198
- const status = await proxy.brightness?.getStatus({});
103199
- if (status && typeof status.percentage === "number") service.updateCharacteristic(import_dist.Characteristic.Brightness, status.percentage);
103200
- } catch (err) {
103201
- log.debug("export-hap: child brightness.getStatus failed (non-fatal)", { meta: { error: errMsg$7(err) } });
103202
- }
103203
- if (hasSwitch) service.getCharacteristic(import_dist.Characteristic.On).onSet(async (value) => {
103204
- const on = value === true;
103205
- try {
103206
- await proxy.switch?.setState({ on });
103207
- } catch (err) {
103208
- log.warn("export-hap: child switch.setState failed", { meta: {
103209
- on,
103210
- error: errMsg$7(err)
103211
- } });
103212
- }
103213
- });
103214
- if (useLightbulb) service.getCharacteristic(import_dist.Characteristic.Brightness).onSet(async (value) => {
103215
- const percentage = typeof value === "number" ? value : Number(value);
103216
- if (!Number.isFinite(percentage)) return;
103217
- try {
103218
- await proxy.brightness?.setBrightness({ percentage });
103219
- } catch (err) {
103220
- log.warn("export-hap: child brightness.setBrightness failed", { meta: {
103221
- percentage,
103222
- error: errMsg$7(err)
103223
- } });
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);
103224
105279
  }
103225
- });
103226
- const unsubscribes = [];
103227
- if (proxy.state.switch) {
103228
- const unsubSwitch = proxy.state.switch.subscribe((value) => {
103229
- if (value && typeof value.on === "boolean") service.updateCharacteristic(import_dist.Characteristic.On, value.on);
103230
- });
103231
- unsubscribes.push(unsubSwitch);
103232
- }
103233
- if (useLightbulb && proxy.state.brightness) {
103234
- const unsubBrightness = proxy.state.brightness.subscribe((value) => {
103235
- if (value && typeof value.percentage === "number") service.updateCharacteristic(import_dist.Characteristic.Brightness, value.percentage);
103236
- });
103237
- unsubscribes.push(unsubBrightness);
105280
+ return;
105281
+ } catch {
105282
+ return;
103238
105283
  }
103239
- return { async dispose() {
103240
- for (const u of unsubscribes) try {
103241
- u();
103242
- } catch {}
103243
- } };
103244
105284
  }
103245
- function errMsg$7(err) {
103246
- return err instanceof Error ? err.message : String(err);
105285
+ function resolveFps(fpsByProfile, profile) {
105286
+ return fpsByProfile.get(profile)?.fps ?? ASSUMED_RECORDING_FPS;
103247
105287
  }
103248
105288
  //#endregion
103249
105289
  //#region src/mappers/child-accessory.ts
@@ -103269,7 +105309,7 @@ async function buildChildServicesFor(input) {
103269
105309
  for (const h of handles) try {
103270
105310
  await h.dispose();
103271
105311
  } catch (err) {
103272
- 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) } });
103273
105313
  }
103274
105314
  } };
103275
105315
  }
@@ -103285,7 +105325,7 @@ async function listChildren(ctx, parentNumericId) {
103285
105325
  features: Array.isArray(c.features) ? c.features.filter((f) => typeof f === "string") : []
103286
105326
  }));
103287
105327
  } catch (err) {
103288
- 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) } });
103289
105329
  return [];
103290
105330
  }
103291
105331
  }
@@ -103301,7 +105341,7 @@ function asDeviceType(raw) {
103301
105341
  for (const value of Object.values(DeviceType)) if (value === lower) return value;
103302
105342
  return DeviceType.Generic;
103303
105343
  }
103304
- function errMsg$6(err) {
105344
+ function errMsg$3(err) {
103305
105345
  return err instanceof Error ? err.message : String(err);
103306
105346
  }
103307
105347
  //#endregion
@@ -103898,1035 +105938,389 @@ function buildMultiTierServices(input) {
103898
105938
  logger.info("hap multi-tier: refused a setup-endpoints write", {
103899
105939
  tags: { deviceId },
103900
105940
  meta: { reason: input.negotiation === void 0 ? "no-negotiator" : "not-a-string" }
103901
- });
103902
- return;
103903
- }
103904
- await input.negotiation.handleSetupEndpointsWrite(value);
103905
- });
103906
- streamManagement.addCharacteristic(setupEndpoints);
103907
- const streamingControl = new import_dist.Characteristic("RTP Streaming Control", CHAR_RTP_STREAMING_CONTROL, {
103908
- format: import_dist.Formats.TLV8,
103909
- perms: [import_dist.Perms.PAIRED_READ, import_dist.Perms.PAIRED_WRITE]
103910
- });
103911
- streamingControl.onGet(() => {
103912
- note("rtp-streaming-control");
103913
- return input.negotiation?.readStreamingControl() ?? encodeRtpStreamingControlStatus(null, RtpStreamingStatus.ERROR);
103914
- });
103915
- streamingControl.onSet(async (value) => {
103916
- if (input.negotiation === void 0 || typeof value !== "string") {
103917
- logger.info("hap multi-tier: refused an rtp streaming control write", {
103918
- tags: { deviceId },
103919
- meta: { reason: input.negotiation === void 0 ? "no-negotiator" : "not-a-string" }
103920
- });
103921
- return;
103922
- }
103923
- await input.negotiation.handleStreamingControlWrite(value);
103924
- });
103925
- streamManagement.addCharacteristic(streamingControl);
103926
- const sensor = new import_dist.Characteristic("Sensor UUID", CHAR_SENSOR_UUID, {
103927
- format: import_dist.Formats.DATA,
103928
- perms: [import_dist.Perms.PAIRED_READ]
103929
- });
103930
- const sensorValue = sensorUuid.toString("base64");
103931
- sensor.onGet(() => {
103932
- note("sensor-uuid");
103933
- return sensorValue;
103934
- });
103935
- streamManagement.addCharacteristic(sensor);
103936
- return {
103937
- capabilities,
103938
- globalOperatingMode,
103939
- streamManagement
103940
- };
103941
- }
103942
- //#endregion
103943
- //#region src/multi-tier/negotiation.ts
103944
- /**
103945
- * The adapter between Apple's new characteristics and the streaming engine this
103946
- * addon already has.
103947
- *
103948
- * ## Why an adapter and not a second engine
103949
- *
103950
- * The multi-tier RTP service owns its OWN `Setup Endpoints` and `RTP Streaming
103951
- * Control`, so hap-nodejs's `CameraController` never negotiates for it — and
103952
- * the library's own handlers are all `private`, so its engine cannot be
103953
- * borrowed either. But the sockets, the SRTP contexts, the ffmpeg pipeline and
103954
- * the teardown are the same ones the classic service uses. A second copy would
103955
- * be two engines to keep in agreement, and this repo has paid for that shape
103956
- * before. So: new characteristics, one engine, through `StreamNegotiator`.
103957
- *
103958
- * ## What is different about the new flow
103959
- *
103960
- * The classic `Selected RTP Stream Configuration` carries the whole selected
103961
- * configuration in its start — codec, profile, level, resolution, bitrate.
103962
- * `RTP Streaming Control` carries a **tier identifier**, because the
103963
- * configuration was agreed when we published `Supported Video Stream Tiers`.
103964
- * So this maps an identifier back to the tier WE published, and a tier we never
103965
- * published is `No Such Stream` — improvising a geometry there would serve a
103966
- * stream the controller did not ask for and it would look like it worked.
103967
- *
103968
- * ## Every path answers
103969
- *
103970
- * A read of either characteristic always produces a valid TLV8. The Home app
103971
- * reads a camera's characteristics in one batch, and one member that answers
103972
- * nothing took the kitchen camera's whole read down and left it on "loading".
103973
- * So an engine failure, a malformed write and "nothing has happened yet" are
103974
- * all statuses, never silence.
103975
- */
103976
- /** RTP payload types. 96 is the first dynamic one (RFC 3551); 110 is what our
103977
- * own Opus offers use. Both are echoed in the tier declaration. */
103978
- var VIDEO_PAYLOAD_TYPE = 96;
103979
- var AUDIO_PAYLOAD_TYPE = 110;
103980
- /** RTCP interval in SECONDS, which is the unit the engine reads. */
103981
- var RTCP_INTERVAL_SECONDS = 5;
103982
- /** The MTU the classic path uses for an Ethernet-sized path. */
103983
- var DEFAULT_MTU = 1378;
103984
- function errorMessage(error) {
103985
- return error instanceof Error ? error.message : String(error);
103986
- }
103987
- function buildMultiTierNegotiation(input) {
103988
- const { deviceId, logger, negotiator } = input;
103989
- const log = logger.withTags({ deviceId });
103990
- let setupResponse = encodeSetupEndpointsRefusal(SetupEndpointsStatus.ERROR);
103991
- let controlResponse = encodeRtpStreamingControlStatus(null, RtpStreamingStatus.ERROR);
103992
- /** Sessions we have STARTED, so an End can tell "not started" from "unknown". */
103993
- const started = /* @__PURE__ */ new Set();
103994
- async function handleSetupEndpointsWrite(base64) {
103995
- let sessionId = null;
103996
- try {
103997
- const request = decodeSetupEndpointsRequest(base64);
103998
- sessionId = request.sessionId;
103999
- const prepareRequest = {
104000
- sessionID: request.sessionId,
104001
- sourceAddress: request.controllerAddress,
104002
- targetAddress: request.controllerAddress,
104003
- addressVersion: request.addressVersion,
104004
- video: {
104005
- port: request.videoPort,
104006
- srtpCryptoSuite: import_dist.SRTPCryptoSuites.AES_CM_128_HMAC_SHA1_80,
104007
- srtp_key: request.videoSrtp.key,
104008
- srtp_salt: request.videoSrtp.salt
104009
- },
104010
- audio: {
104011
- port: request.audioPort,
104012
- srtpCryptoSuite: import_dist.SRTPCryptoSuites.AES_CM_128_HMAC_SHA1_80,
104013
- srtp_key: request.audioSrtp.key,
104014
- srtp_salt: request.audioSrtp.salt
104015
- }
104016
- };
104017
- const prepared = await negotiator.prepare(prepareRequest);
104018
- setupResponse = encodeSetupEndpointsResponse({
104019
- sessionId: request.sessionId,
104020
- addressVersion: request.addressVersion,
104021
- accessoryAddress: prepared.localAddress,
104022
- videoPort: prepared.videoPort,
104023
- audioPort: prepared.audioPort,
104024
- videoSrtp: {
104025
- key: request.videoSrtp.key,
104026
- salt: request.videoSrtp.salt
104027
- },
104028
- audioSrtp: {
104029
- key: request.audioSrtp.key,
104030
- salt: request.audioSrtp.salt
104031
- },
104032
- videoSsrc: prepared.videoSsrc,
104033
- audioSsrc: prepared.audioSsrc
104034
- });
104035
- log.info("hap multi-tier: endpoints prepared", { meta: {
104036
- sessionId: request.sessionId,
104037
- controller: `${request.controllerAddress}:${request.videoPort}/${request.audioPort}`,
104038
- accessory: `${prepared.localAddress}:${prepared.videoPort}/${prepared.audioPort}`,
104039
- videoSsrc: prepared.videoSsrc,
104040
- audioSsrc: prepared.audioSsrc
104041
- } });
104042
- } catch (error) {
104043
- setupResponse = encodeSetupEndpointsRefusal(SetupEndpointsStatus.ERROR);
104044
- log.warn("hap multi-tier: setup endpoints refused", { meta: {
104045
- sessionId,
104046
- error: errorMessage(error)
104047
- } });
104048
- }
104049
- }
104050
- async function handleStreamingControlWrite(base64) {
104051
- let sessionId = null;
104052
- try {
104053
- const command = decodeRtpStreamingControl(base64);
104054
- sessionId = command.sessionId;
104055
- if (!negotiator.has(command.sessionId)) {
104056
- controlResponse = encodeRtpStreamingControlStatus(command.sessionId, RtpStreamingStatus.UNKNOWN_SESSION_IDENTIFIER);
104057
- log.warn("hap multi-tier: control write for a session that was never prepared", { meta: {
104058
- sessionId: command.sessionId,
104059
- command: command.command
104060
- } });
104061
- return;
104062
- }
104063
- if (command.command === RtpStreamingCommand.END) {
104064
- if (!started.has(command.sessionId)) {
104065
- controlResponse = encodeRtpStreamingControlStatus(command.sessionId, RtpStreamingStatus.NO_SUCH_STREAM);
104066
- return;
104067
- }
104068
- const stop = {
104069
- sessionID: command.sessionId,
104070
- type: import_dist.StreamRequestTypes.STOP
104071
- };
104072
- await negotiator.control(stop);
104073
- started.delete(command.sessionId);
104074
- controlResponse = encodeRtpStreamingControlStatus(command.sessionId, RtpStreamingStatus.SUCCESS);
104075
- log.info("hap multi-tier: stream ended", { meta: { sessionId: command.sessionId } });
104076
- return;
104077
- }
104078
- const tier = input.tiers.find((t) => t.identifier === command.videoTier);
104079
- if (tier === void 0) {
104080
- controlResponse = encodeRtpStreamingControlStatus(command.sessionId, RtpStreamingStatus.NO_SUCH_STREAM);
104081
- log.warn("hap multi-tier: start named a tier we never published", { meta: {
104082
- sessionId: command.sessionId,
104083
- requested: command.videoTier,
104084
- published: input.tiers.map((t) => t.identifier)
104085
- } });
104086
- return;
104087
- }
104088
- log.info("hap multi-tier: start ssrc comparison", { meta: {
104089
- sessionId: command.sessionId,
104090
- controllerVideoSsrc: command.videoSsrc ?? null,
104091
- controllerAudioSsrc: command.audioSsrc ?? null,
104092
- note: "accessory keeps the ssrcs it advertised at setup"
104093
- } });
104094
- const start = {
104095
- sessionID: command.sessionId,
104096
- type: import_dist.StreamRequestTypes.START,
104097
- video: {
104098
- codec: import_dist.VideoCodecType.H264,
104099
- profile: import_dist.H264Profile.HIGH,
104100
- level: import_dist.H264Level.LEVEL4_0,
104101
- packetizationMode: import_dist.VideoCodecPacketizationMode.NON_INTERLEAVED,
104102
- width: tier.width,
104103
- height: tier.height,
104104
- fps: tier.frameRate,
104105
- pt: VIDEO_PAYLOAD_TYPE,
104106
- ssrc: command.videoSsrc ?? 0,
104107
- max_bit_rate: tier.targetAverageBitrateKbps,
104108
- rtcp_interval: RTCP_INTERVAL_SECONDS,
104109
- mtu: DEFAULT_MTU
104110
- },
104111
- audio: {
104112
- codec: import_dist.AudioStreamingCodecType.OPUS,
104113
- channel: 1,
104114
- bit_rate: 24,
104115
- sample_rate: import_dist.AudioStreamingSamplerate.KHZ_24,
104116
- packet_time: 20,
104117
- pt: AUDIO_PAYLOAD_TYPE,
104118
- ssrc: command.audioSsrc ?? 0,
104119
- max_bit_rate: 24,
104120
- rtcp_interval: RTCP_INTERVAL_SECONDS,
104121
- comfort_pt: 13,
104122
- comfortNoiseEnabled: false
104123
- }
104124
- };
104125
- await negotiator.control(start);
104126
- started.add(command.sessionId);
104127
- controlResponse = encodeRtpStreamingControlStatus(command.sessionId, RtpStreamingStatus.SUCCESS);
104128
- log.info("hap multi-tier: stream started", { meta: {
104129
- sessionId: command.sessionId,
104130
- tier: tier.identifier,
104131
- geometry: `${tier.width}x${tier.height}@${tier.frameRate}`,
104132
- targetKbps: tier.targetAverageBitrateKbps,
104133
- codec: input.codec
104134
- } });
104135
- } catch (error) {
104136
- controlResponse = encodeRtpStreamingControlStatus(sessionId, RtpStreamingStatus.ERROR);
104137
- log.warn("hap multi-tier: control write refused", { meta: {
104138
- sessionId,
104139
- error: errorMessage(error)
104140
- } });
104141
- }
104142
- }
104143
- return {
104144
- handleSetupEndpointsWrite,
104145
- readSetupEndpoints: () => setupResponse,
104146
- handleStreamingControlWrite,
104147
- readStreamingControl: () => controlResponse
104148
- };
104149
- }
104150
- //#endregion
104151
- //#region src/mappers/camera-accessory.ts
104152
- /**
104153
- * Camera accessory orchestrator — given a camstack deviceId, builds one
104154
- * HomeKit Accessory representing the camera (or doorbell) plus a
104155
- * separate Accessory per child device (siren, floodlight, …).
104156
- *
104157
- * Flow:
104158
- * 1. Resolve a typed `DeviceProxy` via `ctx.fetchDevice(deviceId)`.
104159
- * 2. Inspect `proxy.binding.entries` (or fall back to `getDevice`
104160
- * features) to decide whether the device is a doorbell vs a
104161
- * regular camera. Doorbells get `Categories.VIDEO_DOORBELL` +
104162
- * `DoorbellController`; otherwise `Categories.IP_CAMERA` +
104163
- * `CameraController`.
104164
- * 3. Build the streaming delegate via the `camera-streams` builder.
104165
- * 4. `configureController(controller)` to attach the streaming
104166
- * services to the Accessory.
104167
- * 5. Run each feature builder gated on cap presence (motion sensor,
104168
- * intercom, PTZ, doorbell).
104169
- * 6. Enumerate children via `proxy.deviceManager.getChildren({...})`
104170
- * and build one Accessory per child (see `child-accessory.ts`).
104171
- * 7. Return a `Mapper` whose `accessories[]` lists every accessory
104172
- * this device produced so the bridge can register them all and
104173
- * remove them all on unexpose.
104174
- */
104175
- async function buildCameraAccessory(input) {
104176
- const { ctx, deviceId, displayName, options } = input;
104177
- const numericId = Number.parseInt(deviceId, 10);
104178
- if (!Number.isFinite(numericId)) throw new Error(`export-hap: cannot map device '${deviceId}' — id is not numeric`);
104179
- const proxy = await ctx.fetchDevice(numericId);
104180
- const capNames = new Set(proxy.binding?.entries.map((e) => e.capName) ?? []);
104181
- const isDoorbell = capNames.has("doorbell");
104182
- const category = isDoorbell ? import_dist.Categories.VIDEO_DOORBELL : import_dist.Categories.IP_CAMERA;
104183
- const accessory = new import_dist.Accessory(displayName, accessoryUuidFor("camera", numericId));
104184
- accessory.category = category;
104185
- await populateAccessoryInfo(accessory, proxy, displayName, "Camera");
104186
- const bctx = {
104187
- ctx,
104188
- accessory,
104189
- proxy,
104190
- numericDeviceId: numericId,
104191
- displayName,
104192
- options
104193
- };
104194
- const advertisedVideo = await probeAdvertisedVideoProfile(bctx);
104195
- const streams = buildCameraStreamingDelegate(bctx, advertisedVideo);
104196
- const handles = [];
104197
- if (capNames.has("intercom")) handles.push(await buildIntercom({
104198
- bctx,
104199
- streamingOptions: streams.streamingOptions
104200
- }));
104201
- const recordingEnabled = options.hapDeviceSettings.hksvRecording === true;
104202
- let recordingAudioActive = true;
104203
- const recording = recordingEnabled ? await buildHksvRecording({
104204
- bctx,
104205
- fpsByProfile: advertisedVideo.fpsByProfile,
104206
- isAudioActive: () => recordingAudioActive
104207
- }) : null;
104208
- const controller = new (isDoorbell ? import_dist.DoorbellController : import_dist.CameraController)({
104209
- delegate: streams.delegate,
104210
- streamingOptions: streams.streamingOptions,
104211
- cameraStreamCount: 2,
104212
- ...recording === null ? {} : { recording },
104213
- ...recording === null || !capNames.has("motion-detection") ? {} : { sensors: { motion: true } }
104214
- });
104215
- accessory.configureController(controller);
104216
- if (options.hapDeviceSettings.multiTierService !== false) {
104217
- const sensor = advertisedVideo.sensor;
104218
- const services = buildMultiTierServices({
104219
- deviceId: numericId,
104220
- logger: ctx.logger,
104221
- width: sensor.width,
104222
- height: sensor.height,
104223
- codec: sensor.codec,
104224
- audioCaptureRateHz: 16e3,
104225
- negotiation: buildMultiTierNegotiation({
104226
- deviceId: numericId,
104227
- logger: ctx.logger,
104228
- negotiator: streams.negotiator,
104229
- tiers: tiersForSensor(sensor.width, sensor.height),
104230
- codec: sensor.codec
104231
- })
104232
- });
104233
- accessory.addService(services.capabilities);
104234
- accessory.addService(services.globalOperatingMode);
104235
- accessory.addService(services.streamManagement);
104236
- ctx.logger.info("hap multi-tier: services declared on this accessory", {
104237
- tags: { deviceId: numericId },
104238
- meta: {
104239
- serviceUuid: MULTI_TIER_RTP_SERVICE_UUID,
104240
- codec: sensor.codec === TierVideoCodec.H265 ? "h265" : "h264",
104241
- sensor: `${sensor.width}x${sensor.height}`
104242
- }
104243
- });
104244
- }
104245
- if (recording !== null) {
104246
- handles.push({ dispose: () => recording.dispose() });
104247
- const audioCharacteristic = (controller.recordingManagement?.operatingModeService)?.getCharacteristic(import_dist.Characteristic.RecordingAudioActive);
104248
- if (audioCharacteristic) {
104249
- recordingAudioActive = audioCharacteristic.value !== 0 && audioCharacteristic.value !== false;
104250
- audioCharacteristic.on("change", ({ newValue }) => {
104251
- recordingAudioActive = newValue !== 0 && newValue !== false;
104252
- });
104253
- }
104254
- }
104255
- if (capNames.has("motion-detection")) handles.push(await buildMotionSensor(bctx, recording === null ? null : controller.motionService ?? null));
104256
- if (isDoorbell && controller instanceof import_dist.DoorbellController) handles.push(await buildDoorbell({
104257
- bctx,
104258
- controller
104259
- }));
104260
- if (capNames.has("ptz") || capNames.has("ptz-autotrack")) handles.push(await buildPtz(bctx));
104261
- if (capNames.has("battery")) handles.push(await buildBattery(bctx));
104262
- if (capNames.has("privacy-mask")) handles.push(await buildPrivacySwitch(bctx));
104263
- handles.push(await buildChildServicesFor({ parentCtx: bctx }));
104264
- return {
104265
- accessory,
104266
- accessories: [accessory],
104267
- async dispose() {
104268
- const log = ctx.logger.withTags({ deviceId: numericId });
104269
- for (const h of handles) try {
104270
- await h.dispose();
104271
- } catch (err) {
104272
- log.debug("export-hap: builder dispose failed (continuing)", { meta: { error: errMsg$5(err) } });
104273
- }
104274
- try {
104275
- await streams.dispose();
104276
- } catch (err) {
104277
- log.debug("export-hap: streams dispose failed", { meta: { error: errMsg$5(err) } });
104278
- }
104279
- }
104280
- };
104281
- }
104282
- function errMsg$5(err) {
104283
- return err instanceof Error ? err.message : String(err);
104284
- }
104285
- //#endregion
104286
- //#region src/mappers/builders/generic/lock.ts
104287
- /**
104288
- * `lock-control` → `Service.LockMechanism`.
104289
- *
104290
- * Not a sensor row: HomeKit models a lock as two characteristics, a CURRENT
104291
- * state the accessory owns and a TARGET state the controller writes, and the
104292
- * cap's five states do not map onto either one alone.
104293
- *
104294
- * ```
104295
- * cap state LockCurrentState LockTargetState
104296
- * locked SECURED SECURED
104297
- * unlocked UNSECURED UNSECURED
104298
- * locking UNKNOWN SECURED (in flight — do not claim SECURED)
104299
- * unlocking UNKNOWN UNSECURED
104300
- * jammed JAMMED unchanged (the controller's intent stands)
104301
- * ```
104302
- *
104303
- * Reporting SECURED while the bolt is still moving is the failure worth naming:
104304
- * iOS renders the lock as closed, the operator walks away, and the motor stalls
104305
- * behind them. `UNKNOWN` is the honest answer for an in-flight transition.
104306
- *
104307
- * `open` (the cap's third method, for locks with a latch/buzzer) has no HomeKit
104308
- * counterpart on this service and is deliberately not wired — a Switch that
104309
- * silently buzzes a door open would be a second knob nobody asked for.
104310
- */
104311
- var SUBTYPE$1 = "lock-control";
104312
- var readLock = reader(LockControlStatusSchema.pick({ state: true }), (status) => {
104313
- 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;
104314
- 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;
104315
- return [{
104316
- characteristic: import_dist.Characteristic.LockCurrentState,
104317
- value: current
104318
- }, ...target === null ? [] : [{
104319
- characteristic: import_dist.Characteristic.LockTargetState,
104320
- value: target
104321
- }]];
104322
- });
104323
- async function buildLockMechanism(bctx) {
104324
- const { ctx, accessory, proxy, numericDeviceId, displayName } = bctx;
104325
- const log = ctx.logger.withTags({ deviceId: numericDeviceId });
104326
- const label = hapServiceName([displayName], `Lock ${numericDeviceId}`);
104327
- const service = accessory.addService(import_dist.Service.LockMechanism, label, SUBTYPE$1);
104328
- applyServiceLabel(service, label);
104329
- const apply = (status, source) => {
104330
- const updates = readLock(status);
104331
- if (updates.length === 0) {
104332
- log.debug("export-hap: lock status did not match the cap schema — no update", { meta: { source } });
105941
+ });
104333
105942
  return;
104334
105943
  }
104335
- applyUpdates(service, updates);
104336
- };
104337
- try {
104338
- const status = await proxy.lockControl?.getStatus({});
104339
- if (status !== void 0 && status !== null) apply(status, "getStatus");
104340
- } catch (err) {
104341
- log.debug("export-hap: lock getStatus hydrate failed (non-fatal)", { meta: { error: errMsg$4(err) } });
104342
- }
104343
- service.getCharacteristic(import_dist.Characteristic.LockTargetState).onSet(async (value) => {
104344
- const secure = value === import_dist.Characteristic.LockTargetState.SECURED;
104345
- try {
104346
- if (secure) await proxy.lockControl?.lock({});
104347
- else await proxy.lockControl?.unlock({});
104348
- } catch (err) {
104349
- log.warn("export-hap: lock command failed", { meta: {
104350
- secure,
104351
- error: errMsg$4(err)
104352
- } });
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;
104353
105962
  }
105963
+ await input.negotiation.handleStreamingControlWrite(value);
104354
105964
  });
104355
- const unsubscribe = proxy.state.lockControl?.subscribe((value) => {
104356
- if (value === void 0 || value === null) return;
104357
- apply(value, "slice");
104358
- }) ?? null;
104359
- return { async dispose() {
104360
- try {
104361
- unsubscribe?.();
104362
- } catch {}
104363
- } };
104364
- }
104365
- function errMsg$4(err) {
104366
- return err instanceof Error ? err.message : String(err);
104367
- }
104368
- //#endregion
104369
- //#region src/mappers/builders/generic/security-state.ts
104370
- /** Is this a settled arm state worth remembering? */
104371
- function armedMemoOf(state) {
104372
- return state === "armed_home" || state === "armed_away" || state === "armed_night" || state === "armed_vacation" || state === "armed_custom_bypass" ? state : null;
104373
- }
104374
- function currentForArmed(state) {
104375
- switch (state) {
104376
- case "armed_home":
104377
- case "armed_custom_bypass": return import_dist.Characteristic.SecuritySystemCurrentState.STAY_ARM;
104378
- case "armed_night": return import_dist.Characteristic.SecuritySystemCurrentState.NIGHT_ARM;
104379
- default: return import_dist.Characteristic.SecuritySystemCurrentState.AWAY_ARM;
104380
- }
104381
- }
104382
- function targetForArmed(state) {
104383
- switch (state) {
104384
- case "armed_home":
104385
- case "armed_custom_bypass": return import_dist.Characteristic.SecuritySystemTargetState.STAY_ARM;
104386
- case "armed_night": return import_dist.Characteristic.SecuritySystemTargetState.NIGHT_ARM;
104387
- default: return import_dist.Characteristic.SecuritySystemTargetState.AWAY_ARM;
104388
- }
104389
- }
104390
- function securityStateFor(state, memo) {
104391
- const settled = armedMemoOf(state);
104392
- if (settled !== null) return {
104393
- current: currentForArmed(settled),
104394
- 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
104395
105980
  };
104396
- switch (state) {
104397
- case "disarmed": return {
104398
- current: import_dist.Characteristic.SecuritySystemCurrentState.DISARMED,
104399
- target: import_dist.Characteristic.SecuritySystemTargetState.DISARM
104400
- };
104401
- case "arming": return {
104402
- current: import_dist.Characteristic.SecuritySystemCurrentState.DISARMED,
104403
- target: memo === null ? null : targetForArmed(memo)
104404
- };
104405
- case "disarming": return {
104406
- current: memo === null ? import_dist.Characteristic.SecuritySystemCurrentState.DISARMED : currentForArmed(memo),
104407
- target: import_dist.Characteristic.SecuritySystemTargetState.DISARM
104408
- };
104409
- case "pending": return {
104410
- current: memo === null ? import_dist.Characteristic.SecuritySystemCurrentState.DISARMED : currentForArmed(memo),
104411
- target: memo === null ? null : targetForArmed(memo)
104412
- };
104413
- default: return {
104414
- current: import_dist.Characteristic.SecuritySystemCurrentState.ALARM_TRIGGERED,
104415
- target: memo === null ? null : targetForArmed(memo)
104416
- };
104417
- }
104418
- }
104419
- /**
104420
- * The arm mode behind a HomeKit target, or `null` for DISARM and for anything
104421
- * outside the four values HomeKit defines.
104422
- *
104423
- * `null` is not an error the caller can ignore: DISARM is a different COMMAND,
104424
- * and a value HomeKit never sends is a write this builder refuses to translate.
104425
- */
104426
- function alarmArmModeFor(target) {
104427
- switch (target) {
104428
- case import_dist.Characteristic.SecuritySystemTargetState.STAY_ARM: return "home";
104429
- case import_dist.Characteristic.SecuritySystemTargetState.AWAY_ARM: return "away";
104430
- case import_dist.Characteristic.SecuritySystemTargetState.NIGHT_ARM: return "night";
104431
- default: return null;
104432
- }
104433
- }
104434
- //#endregion
104435
- //#region src/mappers/builders/generic/security-target.ts
104436
- /**
104437
- * The panel modes behind each HomeKit target, PRIMARY first.
104438
- *
104439
- * The primary is the mode `alarmArmModeFor` names; the secondary is the other
104440
- * mode `security-state.ts` reports through the same HomeKit value, so the two
104441
- * modules agree in both directions by construction.
104442
- */
104443
- var MODES_BY_TARGET = new Map([
104444
- [import_dist.Characteristic.SecuritySystemTargetState.STAY_ARM, ["home", "custom_bypass"]],
104445
- [import_dist.Characteristic.SecuritySystemTargetState.AWAY_ARM, ["away", "vacation"]],
104446
- [import_dist.Characteristic.SecuritySystemTargetState.NIGHT_ARM, ["night"]]
104447
- ]);
104448
- /** Arm targets in the order HomeKit numbers them; DISARM is appended last. */
104449
- var ARM_TARGETS = [
104450
- import_dist.Characteristic.SecuritySystemTargetState.STAY_ARM,
104451
- import_dist.Characteristic.SecuritySystemTargetState.AWAY_ARM,
104452
- import_dist.Characteristic.SecuritySystemTargetState.NIGHT_ARM
104453
- ];
104454
- /**
104455
- * The `validValues` for `SecuritySystemTargetState`, or `null` to leave
104456
- * HomeKit's own four values in place.
104457
- *
104458
- * DISARM is always present: disarming is not one of the panel's arm modes and
104459
- * a panel that cannot be disarmed is not a thing this cap can express.
104460
- */
104461
- function targetValidValuesFor(availableModes) {
104462
- if (availableModes.length === 0) return null;
104463
- return [...ARM_TARGETS.filter((target) => (MODES_BY_TARGET.get(target) ?? []).some((mode) => availableModes.includes(mode))), import_dist.Characteristic.SecuritySystemTargetState.DISARM];
104464
- }
104465
- /**
104466
- * The arm mode to send for a HomeKit target, or `null` when there is none.
104467
- *
104468
- * `null` is not a shrug the caller may swallow: DISARM is a different command,
104469
- * and an arm target the panel accepts no mode for is a write this module
104470
- * refuses to translate rather than guess at.
104471
- */
104472
- function armModeForTarget(target, availableModes) {
104473
- const primary = alarmArmModeFor(target);
104474
- if (primary === null) return null;
104475
- if (availableModes.length === 0) return primary;
104476
- return (MODES_BY_TARGET.get(target) ?? [primary]).find((mode) => availableModes.includes(mode)) ?? null;
104477
105981
  }
104478
105982
  //#endregion
104479
- //#region src/mappers/builders/generic/security-system.ts
105983
+ //#region src/multi-tier/negotiation.ts
104480
105984
  /**
104481
- * `alarm-panel` → `Service.SecuritySystem`.
104482
- *
104483
- * The lock builder's shape (a current/target pair, a hydrate, an `onSet`, a
104484
- * slice subscription, a dispose) applied to the one device on the fleet where
104485
- * reporting a state that is not true has a consequence worse than a stalled
104486
- * motor. The mapping itself is NOT here: `security-state.ts` owns what the
104487
- * panel's state looks like to HomeKit and `security-target.ts` owns what
104488
- * HomeKit may ask for, both pure and both tested on their own. This file is
104489
- * the service around them.
104490
- *
104491
- * ## The memo
105985
+ * The adapter between Apple's new characteristics and the streaming engine this
105986
+ * addon already has.
104492
105987
  *
104493
- * `pending`, `disarming` and `triggered` do not carry the mode the panel was
104494
- * armed to — only the last settled state knows it. This builder is the caller
104495
- * `security-state.ts` describes: it remembers the last settled arm and forgets
104496
- * it on `disarmed`, because a memo that outlives a disarm makes the next
104497
- * `arming` claim a target nobody asked for.
105988
+ * ## Why an adapter and not a second engine
104498
105989
  *
104499
- * The memo is deliberately NOT seeded from the controller's own write. HomeKit
104500
- * already holds the target the operator just set; not writing it back is
104501
- * exactly right. The cost is a panel armed from its own keypad during an exit
104502
- * delay, where the target stays at whatever HomeKit last knew — a stale target
104503
- * for the length of the delay, against inventing an intent, and the invention
104504
- * 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`.
104505
105997
  *
104506
- * ## `requiresCode`: the arm is REFUSED, not attempted
105998
+ * ## What is different about the new flow
104507
105999
  *
104508
- * HomeKit's SecuritySystem has no PIN prompt — there is no characteristic for
104509
- * it and no way to ask the operator mid-write. So a panel that requires a code
104510
- * cannot be armed or disarmed from the Home app, and this builder says so
104511
- * twice: the write fails with `INSUFFICIENT_AUTHORIZATION` (iOS renders the
104512
- * 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.
104513
106007
  *
104514
- * Sending the command code-less and letting the panel reject it was the
104515
- * alternative. It is worse: the refusal costs a round trip, it is
104516
- * indistinguishable from a broken panel in the log, and repeated code-less
104517
- * attempts are what locks a real alarm panel out.
104518
- */
104519
- var SUBTYPE = "alarm-panel";
104520
- /** The subset of the status this builder reads. */
104521
- var PanelReadSchema = AlarmPanelStatusSchema.pick({
104522
- state: true,
104523
- availableModes: true,
104524
- requiresCode: true
104525
- });
104526
- /**
104527
- * Turn a panel status into characteristic writes, folding the memo.
106008
+ * ## Every path answers
104528
106009
  *
104529
- * A payload that does not match the cap's own schema yields NO updates and
104530
- * leaves the memo alone — it is a status we could not read, not a panel that
104531
- * changed. The caller logs the drop; a frozen characteristic is otherwise
104532
- * 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.
104533
106015
  */
104534
- function securityUpdatesFor(status, memo) {
104535
- const parsed = PanelReadSchema.safeParse(status);
104536
- if (!parsed.success) return {
104537
- updates: [],
104538
- memo,
104539
- panel: null
104540
- };
104541
- const nextMemo = parsed.data.state === "disarmed" ? null : armedMemoOf(parsed.data.state) ?? memo;
104542
- const { current, target } = securityStateFor(parsed.data.state, nextMemo);
104543
- return {
104544
- updates: [{
104545
- characteristic: import_dist.Characteristic.SecuritySystemCurrentState,
104546
- value: current
104547
- }, ...target === null ? [] : [{
104548
- characteristic: import_dist.Characteristic.SecuritySystemTargetState,
104549
- value: target
104550
- }]],
104551
- memo: nextMemo,
104552
- panel: {
104553
- availableModes: parsed.data.availableModes,
104554
- requiresCode: parsed.data.requiresCode
104555
- }
104556
- };
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);
104557
106026
  }
104558
- async function buildSecuritySystem(bctx) {
104559
- const { ctx, accessory, proxy, numericDeviceId, displayName } = bctx;
104560
- const log = ctx.logger.withTags({ deviceId: numericDeviceId });
104561
- const label = hapServiceName([displayName], `Alarm ${numericDeviceId}`);
104562
- const service = accessory.addService(import_dist.Service.SecuritySystem, label, SUBTYPE);
104563
- applyServiceLabel(service, label);
104564
- let memo = null;
104565
- let availableModes = [];
104566
- let requiresCode = false;
104567
- /** Only log the `validValues` narrowing when it actually changes. */
104568
- let lastValidValues = "";
104569
- const applyValidValues = () => {
104570
- const validValues = targetValidValuesFor(availableModes);
104571
- if (validValues === null) {
104572
- if (lastValidValues !== "unconstrained") {
104573
- lastValidValues = "unconstrained";
104574
- log.info("export-hap: alarm panel named no arm modes — leaving HomeKit’s targets unconstrained", { meta: { subtype: SUBTYPE } });
104575
- }
104576
- return;
104577
- }
104578
- const key = validValues.join(",");
104579
- if (key === lastValidValues) return;
104580
- lastValidValues = key;
104581
- service.getCharacteristic(import_dist.Characteristic.SecuritySystemTargetState).setProps({ validValues: [...validValues] });
104582
- log.info("export-hap: alarm panel target constrained to the modes it accepts", { meta: {
104583
- subtype: SUBTYPE,
104584
- availableModes: [...availableModes],
104585
- validValues
104586
- } });
104587
- };
104588
- const apply = (status, source) => {
104589
- const next = securityUpdatesFor(status, memo);
104590
- if (next.updates.length === 0) {
104591
- log.debug("export-hap: alarm panel status did not match the cap schema — no update", { meta: {
104592
- subtype: SUBTYPE,
104593
- source
104594
- } });
104595
- return;
104596
- }
104597
- memo = next.memo;
104598
- if (next.panel !== null) {
104599
- availableModes = next.panel.availableModes;
104600
- requiresCode = next.panel.requiresCode;
104601
- applyValidValues();
104602
- }
104603
- applyUpdates(service, next.updates);
104604
- };
104605
- try {
104606
- const status = await proxy.alarmPanel?.getStatus({});
104607
- if (status !== void 0 && status !== null) apply(status, "getStatus");
104608
- } catch (err) {
104609
- log.debug("export-hap: alarm panel getStatus hydrate failed (non-fatal)", { meta: { error: errMsg$3(err) } });
104610
- }
104611
- service.getCharacteristic(import_dist.Characteristic.SecuritySystemTargetState).onSet(async (value) => {
104612
- const target = typeof value === "number" ? value : Number(value);
104613
- const disarm = target === import_dist.Characteristic.SecuritySystemTargetState.DISARM;
104614
- const mode = disarm ? null : armModeForTarget(target, availableModes);
104615
- if (requiresCode) {
104616
- log.warn("export-hap: alarm panel requires a PIN — HomeKit cannot supply one, command REFUSED", { meta: {
104617
- subtype: SUBTYPE,
104618
- command: disarm ? "disarm" : "arm",
104619
- mode,
104620
- 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
104621
106081
  } });
104622
- throw new import_dist.HapStatusError(import_dist.HAPStatus.INSUFFICIENT_AUTHORIZATION);
104623
- }
104624
- if (!disarm && mode === null) {
104625
- log.warn("export-hap: alarm panel cannot honour this HomeKit arm target — REFUSED", { meta: {
104626
- subtype: SUBTYPE,
104627
- target,
104628
- 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)
104629
106087
  } });
104630
- throw new import_dist.HapStatusError(import_dist.HAPStatus.INVALID_VALUE_IN_REQUEST);
104631
106088
  }
106089
+ }
106090
+ async function handleStreamingControlWrite(base64) {
106091
+ let sessionId = null;
104632
106092
  try {
104633
- if (mode === null) await proxy.alarmPanel?.disarm({});
104634
- else await proxy.alarmPanel?.arm({ mode });
104635
- } catch (err) {
104636
- log.warn("export-hap: alarm panel command failed", { meta: {
104637
- subtype: SUBTYPE,
104638
- command: mode === null ? "disarm" : "arm",
104639
- mode,
104640
- 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"
104641
106133
  } });
104642
- throw new import_dist.HapStatusError(import_dist.HAPStatus.SERVICE_COMMUNICATION_FAILURE);
104643
- }
104644
- });
104645
- const unsubscribe = proxy.state.alarmPanel?.subscribe((value) => {
104646
- if (value === void 0 || value === null) return;
104647
- apply(value, "slice");
104648
- }) ?? null;
104649
- return { async dispose() {
104650
- try {
104651
- unsubscribe?.();
104652
- } catch {}
104653
- } };
104654
- }
104655
- function errMsg$3(err) {
104656
- return err instanceof Error ? err.message : String(err);
104657
- }
104658
- //#endregion
104659
- //#region src/mappers/builders/generic/sensor-service.ts
104660
- async function buildSensorService(input) {
104661
- const { bctx, spec, name, subtype } = input;
104662
- const { ctx, accessory, proxy, numericDeviceId } = bctx;
104663
- const log = ctx.logger.withTags({ deviceId: numericDeviceId });
104664
- const label = hapServiceName([name], spec.label);
104665
- const service = spec.addService(accessory, label, subtype);
104666
- applyServiceLabel(service, label);
104667
- const apply = (status, source) => {
104668
- const updates = spec.read(status);
104669
- if (updates.length === 0) {
104670
- log.debug("export-hap: sensor status did not match the cap schema — no update", { meta: {
104671
- subtype,
104672
- 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)
104673
106180
  } });
104674
- return;
104675
106181
  }
104676
- applyUpdates(service, updates);
104677
- };
104678
- try {
104679
- const status = await spec.getStatus(proxy);
104680
- if (status !== void 0 && status !== null) apply(status, "getStatus");
104681
- } catch (err) {
104682
- log.debug("export-hap: sensor getStatus hydrate failed (non-fatal)", { meta: {
104683
- subtype,
104684
- error: errMsg$2(err)
104685
- } });
104686
106182
  }
104687
- const unsubscribe = spec.subscribe(proxy, (value) => {
104688
- if (value === void 0 || value === null) return;
104689
- apply(value, "slice");
104690
- });
104691
- return { async dispose() {
104692
- try {
104693
- unsubscribe?.();
104694
- } catch {}
104695
- } };
104696
- }
104697
- function errMsg$2(err) {
104698
- return err instanceof Error ? err.message : String(err);
106183
+ return {
106184
+ handleSetupEndpointsWrite,
106185
+ readSetupEndpoints: () => setupResponse,
106186
+ handleStreamingControlWrite,
106187
+ readStreamingControl: () => controlResponse
106188
+ };
104699
106189
  }
104700
106190
  //#endregion
104701
- //#region src/mappers/builders/generic/cap-service-table.ts
106191
+ //#region src/mappers/camera-accessory.ts
104702
106192
  /**
104703
- * The capability→HomeKit-service TABLE for non-camera devices.
104704
- *
104705
- * This is the whole coverage decision for the generic export path, and it is
104706
- * deliberately a table rather than a `switch` on `DeviceType`. The doctrine is
104707
- * the one the Home Assistant exporter already writes down: *coverage is decided
104708
- * by a device's capabilities, not its type — restricting the picker by type is
104709
- * what confined the previous exporter to cameras.* A `DeviceType` here is only
104710
- * ever a UI filter (`HAP_EXPORTABLE_DEVICE_TYPES`) or an icon
104711
- * (`ACCESSORY_CATEGORY_BY_TYPE`); it never decides whether something exports.
104712
- *
104713
- * The shape mirrors `CAP_ENTITY_MAP` in
104714
- * `packages/addon-provider-homeassistant/src/ha-export/entity-catalog.ts` so
104715
- * the two can be unified later — one row per capability, keyed by the cap name
104716
- * exactly as it appears in `proxy.binding.entries[].capName`.
104717
- *
104718
- * ## Reading a status
104719
- *
104720
- * Every row parses the cap's OWN Zod status schema rather than duck-typing the
104721
- * payload, and it `.pick()`s only the fields it reads: a provider that omits a
104722
- * timestamp must not silence a sensor, and a provider that sends the wrong
104723
- * shape must not write a garbage characteristic. A parse that fails yields no
104724
- * updates, and the builder that owns the service logs the drop — silence reads
104725
- * as "the sensor never changed".
104726
- *
104727
- * ## Tier
104728
- *
104729
- * Tier A, plus `alarm-panel`: the caps whose HomeKit service exists today and
104730
- * needs no feature-dependent shape. `cover` still needs `cover-positionable`
104731
- * and `climate-control` the dual-setpoint split; those are a separate task,
104732
- * and adding a row here must never mean adding a service whose semantics this
104733
- * 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, …).
104734
106196
  *
104735
- * `alarm-panel` earned its row by answering the two questions that kept it
104736
- * out: a target the panel would refuse is never offered (`availableModes` →
104737
- * `validValues`, `security-target.ts`) and an arm a PIN-gated panel cannot
104738
- * 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.
104739
106214
  */
104740
- /** HomeKit's floor for `CurrentAmbientLightLevel`; 0 lux is out of range. */
104741
- var MIN_LUX = 1e-4;
104742
- var SENSOR_SPECS = {
104743
- contact: {
104744
- label: "Contact",
104745
- addService: (accessory, name, subtype) => accessory.addService(import_dist.Service.ContactSensor, name, subtype),
104746
- getStatus: (proxy) => proxy.contact?.getStatus({}),
104747
- subscribe: (proxy, onValue) => proxy.state.contact?.subscribe(onValue) ?? null,
104748
- read: reader(ContactStatusSchema.pick({ entryOpen: true }), (status) => [{
104749
- characteristic: import_dist.Characteristic.ContactSensorState,
104750
- value: status.entryOpen ? import_dist.Characteristic.ContactSensorState.CONTACT_NOT_DETECTED : import_dist.Characteristic.ContactSensorState.CONTACT_DETECTED
104751
- }])
104752
- },
104753
- motion: {
104754
- label: "Motion",
104755
- addService: (accessory, name, subtype) => accessory.addService(import_dist.Service.MotionSensor, name, subtype),
104756
- getStatus: (proxy) => proxy.motion?.getStatus({}),
104757
- subscribe: (proxy, onValue) => proxy.state.motion?.subscribe(onValue) ?? null,
104758
- read: reader(MotionStatusSchema.pick({ detected: true }), (status) => [{
104759
- characteristic: import_dist.Characteristic.MotionDetected,
104760
- value: status.detected
104761
- }])
104762
- },
104763
- presence: {
104764
- label: "Presence",
104765
- addService: (accessory, name, subtype) => accessory.addService(import_dist.Service.OccupancySensor, name, subtype),
104766
- getStatus: (proxy) => proxy.presence?.getStatus({}),
104767
- subscribe: (proxy, onValue) => proxy.state.presence?.subscribe(onValue) ?? null,
104768
- read: reader(PresenceStatusSchema.pick({ state: true }), (status) => [{
104769
- characteristic: import_dist.Characteristic.OccupancyDetected,
104770
- value: status.state === "home" ? import_dist.Characteristic.OccupancyDetected.OCCUPANCY_DETECTED : import_dist.Characteristic.OccupancyDetected.OCCUPANCY_NOT_DETECTED
104771
- }])
104772
- },
104773
- smoke: {
104774
- label: "Smoke",
104775
- addService: (accessory, name, subtype) => accessory.addService(import_dist.Service.SmokeSensor, name, subtype),
104776
- getStatus: (proxy) => proxy.smoke?.getStatus({}),
104777
- subscribe: (proxy, onValue) => proxy.state.smoke?.subscribe(onValue) ?? null,
104778
- read: reader(SmokeStatusSchema.pick({ detected: true }), (status) => [{
104779
- characteristic: import_dist.Characteristic.SmokeDetected,
104780
- value: status.detected ? import_dist.Characteristic.SmokeDetected.SMOKE_DETECTED : import_dist.Characteristic.SmokeDetected.SMOKE_NOT_DETECTED
104781
- }])
104782
- },
104783
- "carbon-monoxide": {
104784
- label: "Carbon monoxide",
104785
- addService: (accessory, name, subtype) => accessory.addService(import_dist.Service.CarbonMonoxideSensor, name, subtype),
104786
- getStatus: (proxy) => proxy.carbonMonoxide?.getStatus({}),
104787
- subscribe: (proxy, onValue) => proxy.state.carbonMonoxide?.subscribe(onValue) ?? null,
104788
- read: reader(CarbonMonoxideStatusSchema.pick({ detected: true }), (status) => [{
104789
- characteristic: import_dist.Characteristic.CarbonMonoxideDetected,
104790
- value: status.detected ? import_dist.Characteristic.CarbonMonoxideDetected.CO_LEVELS_ABNORMAL : import_dist.Characteristic.CarbonMonoxideDetected.CO_LEVELS_NORMAL
104791
- }])
104792
- },
104793
- flood: {
104794
- label: "Leak",
104795
- addService: (accessory, name, subtype) => accessory.addService(import_dist.Service.LeakSensor, name, subtype),
104796
- getStatus: (proxy) => proxy.flood?.getStatus({}),
104797
- subscribe: (proxy, onValue) => proxy.state.flood?.subscribe(onValue) ?? null,
104798
- read: reader(FloodStatusSchema.pick({ flooded: true }), (status) => [{
104799
- characteristic: import_dist.Characteristic.LeakDetected,
104800
- value: status.flooded ? import_dist.Characteristic.LeakDetected.LEAK_DETECTED : import_dist.Characteristic.LeakDetected.LEAK_NOT_DETECTED
104801
- }])
104802
- },
104803
- "temperature-sensor": {
104804
- label: "Temperature",
104805
- addService: (accessory, name, subtype) => accessory.addService(import_dist.Service.TemperatureSensor, name, subtype),
104806
- getStatus: (proxy) => proxy.temperatureSensor?.getStatus({}),
104807
- subscribe: (proxy, onValue) => proxy.state.temperatureSensor?.subscribe(onValue) ?? null,
104808
- read: reader(TemperatureSensorStatusSchema.pick({ celsius: true }), (status) => [{
104809
- characteristic: import_dist.Characteristic.CurrentTemperature,
104810
- value: status.celsius
104811
- }])
104812
- },
104813
- "humidity-sensor": {
104814
- label: "Humidity",
104815
- addService: (accessory, name, subtype) => accessory.addService(import_dist.Service.HumiditySensor, name, subtype),
104816
- getStatus: (proxy) => proxy.humiditySensor?.getStatus({}),
104817
- subscribe: (proxy, onValue) => proxy.state.humiditySensor?.subscribe(onValue) ?? null,
104818
- read: reader(HumiditySensorStatusSchema.pick({ percent: true }), (status) => [{
104819
- characteristic: import_dist.Characteristic.CurrentRelativeHumidity,
104820
- value: status.percent
104821
- }])
104822
- },
104823
- "ambient-light-sensor": {
104824
- label: "Light level",
104825
- addService: (accessory, name, subtype) => accessory.addService(import_dist.Service.LightSensor, name, subtype),
104826
- getStatus: (proxy) => proxy.ambientLightSensor?.getStatus({}),
104827
- subscribe: (proxy, onValue) => proxy.state.ambientLightSensor?.subscribe(onValue) ?? null,
104828
- read: reader(AmbientLightSensorStatusSchema.pick({ lux: true }), (status) => [{
104829
- characteristic: import_dist.Characteristic.CurrentAmbientLightLevel,
104830
- value: Math.max(MIN_LUX, status.lux)
104831
- }])
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
+ });
104832
106284
  }
104833
- };
104834
- /**
104835
- * The `battery` row reads like a sensor but writes three characteristics from
104836
- * one status, and the camera path publishes the SAME service from the same cap.
104837
- * The derivation therefore lives once, in `battery.ts`, and both callers read
104838
- * it from there.
104839
- */
104840
- var BATTERY_SPEC = {
104841
- label: "Battery",
104842
- addService: (accessory, name, subtype) => accessory.addService(import_dist.Service.Battery, name, subtype),
104843
- getStatus: (proxy) => proxy.battery?.getStatus({}),
104844
- subscribe: (proxy, onValue) => proxy.state.battery?.subscribe(onValue) ?? null,
104845
- read: batteryCharacteristicUpdates
104846
- };
104847
- 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 }));
104848
106304
  return {
104849
- caps: [capName],
104850
- label: spec.label,
104851
- build: ({ bctx, name }) => buildSensorService({
104852
- bctx,
104853
- spec,
104854
- name,
104855
- subtype: capName
104856
- })
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
+ }
104857
106320
  };
104858
106321
  }
104859
- /**
104860
- * THE table. Order matters only for one thing: the first row that matches a
104861
- * device decides the accessory's category when the device's own type does not.
104862
- */
104863
- var HAP_CAP_SERVICES = [
104864
- {
104865
- caps: ["switch", "brightness"],
104866
- label: "Power",
104867
- build: ({ bctx, name, deviceType }) => buildChildSwitch({
104868
- ...bctx,
104869
- displayName: name
104870
- }, "switch", deviceType)
104871
- },
104872
- {
104873
- caps: ["lock-control"],
104874
- label: "Lock",
104875
- build: ({ bctx, name }) => buildLockMechanism({
104876
- ...bctx,
104877
- displayName: name
104878
- })
104879
- },
104880
- {
104881
- caps: ["alarm-panel"],
104882
- label: "Alarm",
104883
- build: ({ bctx, name }) => buildSecuritySystem({
104884
- ...bctx,
104885
- displayName: name
104886
- })
104887
- },
104888
- {
104889
- caps: ["battery"],
104890
- label: BATTERY_SPEC.label,
104891
- build: ({ bctx, name }) => buildSensorService({
104892
- bctx,
104893
- spec: BATTERY_SPEC,
104894
- name,
104895
- subtype: "battery"
104896
- })
104897
- },
104898
- ...Object.entries(SENSOR_SPECS).map(([capName, spec]) => sensorRow(capName, spec))
104899
- ];
104900
- /**
104901
- * The rows a device's bound capabilities select, in table order.
104902
- *
104903
- * A row matches when ANY of its caps is bound: a lamp with `switch` but no
104904
- * `brightness` is still a Lightbulb-shaped row, and the builder degrades on its
104905
- * own.
104906
- */
104907
- function rowsForCaps(capNames) {
104908
- return HAP_CAP_SERVICES.filter((row) => row.caps.some((cap) => capNames.has(cap)));
104909
- }
104910
- /**
104911
- * The HomeKit accessory category per camstack device type — the icon iOS Home
104912
- * draws and nothing else. Absent = `OTHER`, which is a valid accessory that
104913
- * simply gets the generic tile.
104914
- *
104915
- * This is NOT a coverage decision. `HAP_EXPORTABLE_DEVICE_TYPES` (in
104916
- * `mappers/index.ts`) filters the picker; whether a device exports anything is
104917
- * decided by `rowsForCaps`.
104918
- */
104919
- var ACCESSORY_CATEGORY_BY_TYPE = {
104920
- [DeviceType.Switch]: import_dist.Categories.SWITCH,
104921
- [DeviceType.Light]: import_dist.Categories.LIGHTBULB,
104922
- [DeviceType.Siren]: import_dist.Categories.SWITCH,
104923
- [DeviceType.Lock]: import_dist.Categories.DOOR_LOCK,
104924
- [DeviceType.Sensor]: import_dist.Categories.SENSOR,
104925
- [DeviceType.Presence]: import_dist.Categories.SENSOR,
104926
- [DeviceType.AlarmPanel]: import_dist.Categories.SECURITY_SYSTEM
104927
- };
104928
- function accessoryCategoryFor(deviceType) {
104929
- return ACCESSORY_CATEGORY_BY_TYPE[deviceType] ?? import_dist.Categories.OTHER;
106322
+ function errMsg$2(err) {
106323
+ return err instanceof Error ? err.message : String(err);
104930
106324
  }
104931
106325
  //#endregion
104932
106326
  //#region src/mappers/generic-accessory.ts
@@ -105030,118 +106424,6 @@ function getMapperFactory(kind) {
105030
106424
  return factory;
105031
106425
  }
105032
106426
  //#endregion
105033
- //#region src/mappers/builders/stream-hwaccel-memo.ts
105034
- /**
105035
- * The two bounded memos HomeKit's decode path owns.
105036
- *
105037
- * ## Why they exist
105038
- *
105039
- * Everything D67 was actually about — one argv builder, one set of constants,
105040
- * one hwaccel authority — HomeKit already had. What it did NOT have were the
105041
- * two things the broker gained alongside them:
105042
- *
105043
- * 1. **A memo.** `probeDecoderHwaccel` issued a cross-process
105044
- * `decoder.getInfo` per SESSION. iOS starts sessions in bursts — one on
105045
- * record was started three times in 16 s — and every one of those paid a
105046
- * cap call on a hub whose main thread is the scarce resource.
105047
- * 2. **Failure feedback.** When HomeKit's hardware child died at init and
105048
- * `shouldRetryInSoftware` saved the session, HomeKit told nobody. The next
105049
- * session re-picked the same corpse and paid the same two-second death.
105050
- * `EgressTranscodeManager` fixed exactly this for its own children
105051
- * (26a522cd5) by reporting the dead backend into the broker's 60 s memo.
105052
- *
105053
- * ## Both ride `HwAccelCache`, deliberately
105054
- *
105055
- * `createHwAccelCache` from `@camstack/types` is the primitive the broker's own
105056
- * `egressHwAccelCache` is built from, and the discipline it encodes is the
105057
- * point: **caller-owned, never a module global** — a module global would
105058
- * outlive an addon respawn and survive an operator changing the decoder
105059
- * backend. Same TTL as the broker's, so "has hardware come back yet" cannot
105060
- * answer differently depending on which consumer asked.
105061
- *
105062
- * ## The cross-process gap, stated honestly
105063
- *
105064
- * These memos are scoped to the `export-hap` PROCESS. When HomeKit's vaapi
105065
- * child dies, the broker's next child still pays its own two-second death, and
105066
- * vice versa — because addons may never import each other and there is no
105067
- * capability for "this backend is dead on this node right now". Closing that
105068
- * would need a new cap surface, which Phase 0 explicitly does not take. What is
105069
- * closed here is HomeKit's own repetition of the cost, across cameras and
105070
- * across sessions.
105071
- */
105072
- /**
105073
- * The window both memos answer for.
105074
- *
105075
- * 60 s, the same as `stream-broker-manager`'s `egressHwAccelCache`. Long enough
105076
- * that a burst of session restarts pays one read; short enough that an operator
105077
- * who changes the decoder backend, or a host whose accelerator recovers, is
105078
- * obeyed on the next session rather than after an addon respawn.
105079
- */
105080
- var HAP_DECODE_MEMO_TTL_MS = 6e4;
105081
- /**
105082
- * Separator inside the encoded reading. A control character, because a backend
105083
- * name is `[a-z0-9]+` and the decoder's non-backend choices are `auto` /
105084
- * `none` / `''`, none of which can contain one — so the split is total.
105085
- */
105086
- var READING_SEPARATOR = "";
105087
- /**
105088
- * Marks a `null` FIELD, distinct from an EMPTY one.
105089
- *
105090
- * `probedBestHwaccel: ''` means the decoder answered and has never probed
105091
- * (=> `not-probed`); `null` means the field was absent altogether. Encoding
105092
- * both as `''` would lose a distinction `selectHwDecode` acts on.
105093
- */
105094
- var NULL_FIELD = "\0";
105095
- function encodeField(value) {
105096
- return value === null ? NULL_FIELD : value;
105097
- }
105098
- function decodeField(value) {
105099
- return value === NULL_FIELD ? null : value;
105100
- }
105101
- /**
105102
- * A reading as ONE `string | null`, which is what {@link HwAccelCache} stores.
105103
- *
105104
- * The cache's three states are exactly the three a memoised reading needs:
105105
- * `undefined` (never asked, or expired), `null` (asked, and the decoder could
105106
- * not be reached), and a value. Encoding into the one cache rather than
105107
- * splitting across two is what keeps those three from skewing — two caches
105108
- * written together can still be READ across an expiry boundary.
105109
- */
105110
- function encodeDecoderReading(reading) {
105111
- if (reading === null) return null;
105112
- return `${encodeField(reading.hwaccel)}${READING_SEPARATOR}${encodeField(reading.probedBestHwaccel)}`;
105113
- }
105114
- function decodeDecoderReading(value) {
105115
- if (value === null) return null;
105116
- const [hwaccel = NULL_FIELD, probed = NULL_FIELD] = value.split(READING_SEPARATOR);
105117
- return {
105118
- hwaccel: decodeField(hwaccel),
105119
- probedBestHwaccel: decodeField(probed)
105120
- };
105121
- }
105122
- function createDecoderReadingMemo(options) {
105123
- const cache = createHwAccelCache(options);
105124
- return {
105125
- read() {
105126
- const cached = cache.read();
105127
- return cached === void 0 ? void 0 : decodeDecoderReading(cached);
105128
- },
105129
- write(reading) {
105130
- cache.write(encodeDecoderReading(reading));
105131
- }
105132
- };
105133
- }
105134
- function createHapDecodeMemos(now) {
105135
- const options = {
105136
- ttlMs: HAP_DECODE_MEMO_TTL_MS,
105137
- ...now ? { now } : {}
105138
- };
105139
- return {
105140
- reading: createDecoderReadingMemo(options),
105141
- failedBackend: createHwAccelCache(options)
105142
- };
105143
- }
105144
- //#endregion
105145
106427
  //#region src/reconcile/sync-state.ts
105146
106428
  function syncStateFromJson(json) {
105147
106429
  const map = /* @__PURE__ */ new Map();
@@ -105327,6 +106609,16 @@ var ExportHapAddon = class extends BaseAddon {
105327
106609
  */
105328
106610
  decodeMemos = createHapDecodeMemos();
105329
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
+ /**
105330
106622
  * THE bridge. Lazily created and published the first time a non-camera
105331
106623
  * accessory needs it; never torn down while the addon runs, because an
105332
106624
  * unpublish would drop a pairing the operator already entered a code for.
@@ -105352,6 +106644,14 @@ var ExportHapAddon = class extends BaseAddon {
105352
106644
  this.lastError = errMsg(err);
105353
106645
  this.ctx.logger.error("export-hap: HAP storage init failed", { meta: { error: this.lastError } });
105354
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
+ });
105355
106655
  const validKinds = new Set(SUPPORTED_MAPPER_KINDS);
105356
106656
  const cleaned = this.config.exposed.filter((entry) => {
105357
106657
  if (validKinds.has(entry.mapperKind)) return true;
@@ -105544,6 +106844,7 @@ var ExportHapAddon = class extends BaseAddon {
105544
106844
  options: {
105545
106845
  ptzPulseMs: this.config.ptzPulseMs,
105546
106846
  decodeMemos: this.decodeMemos,
106847
+ hksvClipStore: this.hksvClipStore,
105547
106848
  hapDeviceSettings: {
105548
106849
  streamPreference: entrySettings.streamPreference ?? "auto",
105549
106850
  hksvRecording: resolveHksvRecording(entrySettings),