@camstack/addon-export-hap 1.2.11 → 1.2.12

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.
@@ -2,7 +2,7 @@ import { createRequire } from "node:module";
2
2
  import { createHash, randomBytes } from "node:crypto";
3
3
  import * as path from "node:path";
4
4
  import { spawn } from "node:child_process";
5
- import { Accessory, AudioBitrate, AudioRecordingCodecType, AudioRecordingSamplerate, AudioStreamingCodecType, AudioStreamingSamplerate, CameraController, Categories, Characteristic, DoorbellController, EventTriggerOption, H264Level, H264Profile, HAPStorage, MediaContainerType, SRTPCryptoSuites, Service, VideoCodecType, uuid } from "@homebridge/hap-nodejs";
5
+ import { Accessory, AudioBitrate, AudioStreamingCodecType, AudioStreamingSamplerate, CameraController, Categories, Characteristic, DoorbellController, H264Level, H264Profile, HAPStorage, SRTPCryptoSuites, Service, uuid } from "@homebridge/hap-nodejs";
6
6
  import * as fs from "node:fs/promises";
7
7
  import { createSocket } from "node:dgram";
8
8
  import { networkInterfaces } from "node:os";
@@ -14702,6 +14702,70 @@ var EventPruneCountsSchema = object({
14702
14702
  object: number().int(),
14703
14703
  audio: number().int()
14704
14704
  });
14705
+ /**
14706
+ * Re-embed stored tracks from their key frames.
14707
+ *
14708
+ * The reason this is an operator-callable method and not a migration script:
14709
+ * every knob that decides what a vector MEANS — encoder model, crop margin,
14710
+ * squaring — is only changeable if the existing vectors can be regenerated.
14711
+ * Mixing feature spaces in one index makes cosine scores incomparable, and the
14712
+ * symptom is a quality regression with no visible cause.
14713
+ */
14714
+ var RebuildObjectEmbeddingsInput = object({
14715
+ /** Restrict to one camera. Omit for the whole fleet. */
14716
+ deviceId: number().optional(),
14717
+ since: number().optional(),
14718
+ until: number().optional(),
14719
+ /** Stop after this many tracks; the result reports whether more remain. */
14720
+ maxTracks: number().int().positive().optional()
14721
+ });
14722
+ /**
14723
+ * Result of emptying the CLIP index.
14724
+ *
14725
+ * The clean slate before a policy change: a new crop margin or encoder model
14726
+ * leaves two feature spaces in one index whose cosine scores are not
14727
+ * comparable, so wiping and rebuilding is the only way to be sure every vector
14728
+ * means the same thing.
14729
+ */
14730
+ var WipeObjectEmbeddingsResultSchema = object({ deleted: number() });
14731
+ /**
14732
+ * Acknowledgement that a rebuild STARTED.
14733
+ *
14734
+ * The pass costs ~1s per track — 45 minutes for a 2,600-track fleet — so it
14735
+ * runs detached and this returns immediately. Waiting for it made the client
14736
+ * time out while the work carried on server-side, which is the worst of both:
14737
+ * no result and no way to know it was still going. Poll
14738
+ * `getObjectEmbeddingRebuildStatus` for progress.
14739
+ */
14740
+ var RebuildObjectEmbeddingsResultSchema = object({
14741
+ started: boolean(),
14742
+ /** True when a pass was already running; the new request is ignored. */
14743
+ alreadyRunning: boolean()
14744
+ });
14745
+ var RebuildStatusSchema = object({
14746
+ running: boolean(),
14747
+ scanned: number(),
14748
+ rebuilt: number(),
14749
+ /** Tracks whose key frame is gone — nothing to re-embed from. */
14750
+ missingKeyFrame: number(),
14751
+ /** Tracks with no usable detection box. */
14752
+ missingBbox: number(),
14753
+ /**
14754
+ * Tracks the pipeline REFUSED rather than broke on: the camera is not
14755
+ * attached, or `clip-embedding` is not enabled in its step tree. Separate
14756
+ * from `failed` because the remedy is a configuration change, not an engine
14757
+ * investigation — and because a pass over decommissioned cameras would
14758
+ * otherwise read as a total engine outage.
14759
+ */
14760
+ notRunnable: number(),
14761
+ failed: number(),
14762
+ /** Set once a pass ends: true only when EVERYTHING was covered. */
14763
+ complete: boolean().nullable(),
14764
+ startedAtMs: number().nullable(),
14765
+ finishedAtMs: number().nullable(),
14766
+ /** Present when the pass ended by throwing. */
14767
+ error: string().nullable()
14768
+ });
14705
14769
  DeviceType.Camera, method(object({ deviceId: number() }), array(TrackSchema).readonly()), method(object({
14706
14770
  deviceId: number(),
14707
14771
  trackId: string()
@@ -14795,7 +14859,13 @@ DeviceType.Camera, method(object({ deviceId: number() }), array(TrackSchema).rea
14795
14859
  }), array(MediaFileSchema).readonly()), method(object({
14796
14860
  trackId: string(),
14797
14861
  kinds: array(MediaFileKindEnum).optional()
14798
- }), array(MediaFileSchema).readonly()), method(object({ trackId: string() }), array(MediaFileInfoSchema).readonly()), method(SearchObjectEventsInput, array(ScoredObjectEventSchema).readonly()), object({
14862
+ }), array(MediaFileSchema).readonly()), method(object({ trackId: string() }), array(MediaFileInfoSchema).readonly()), method(SearchObjectEventsInput, array(ScoredObjectEventSchema).readonly()), method(object({}), WipeObjectEmbeddingsResultSchema, {
14863
+ kind: "mutation",
14864
+ auth: "admin"
14865
+ }), method(RebuildObjectEmbeddingsInput, RebuildObjectEmbeddingsResultSchema, {
14866
+ kind: "mutation",
14867
+ auth: "admin"
14868
+ }), method(object({}), RebuildStatusSchema), object({
14799
14869
  deviceId: number(),
14800
14870
  timestamp: number(),
14801
14871
  frameWidth: number(),
@@ -15704,6 +15774,17 @@ method(RunnerCameraConfigSchema, object({ success: literal(true) }), { kind: "mu
15704
15774
  }), NativeCropResultSchema.nullable()), method(object({
15705
15775
  deviceId: number(),
15706
15776
  frameHandle: FrameHandleSchema.optional(),
15777
+ /**
15778
+ * FULL FRAME (base64 JPEG). The runner derives the crop rectangle from
15779
+ * `parent.bbox` with the cluster crop convention and cuts it itself —
15780
+ * do NOT pre-crop for this field, that is what `cropJpeg` is.
15781
+ */
15782
+ frameJpeg: string().optional(),
15783
+ /**
15784
+ * PRE-CUT tile (base64 JPEG), used verbatim — NO padding is applied.
15785
+ * The fallback when the lease/session backing the frame is gone and the
15786
+ * caller already holds a crop.
15787
+ */
15707
15788
  cropJpeg: string().optional(),
15708
15789
  parent: DetailParentSchema,
15709
15790
  steps: array(string()).optional()
@@ -17029,6 +17110,24 @@ var VectorDeleteByFilterInputSchema = object({
17029
17110
  filter: VectorFilterSchema
17030
17111
  });
17031
17112
  var VectorDeleteResultSchema = object({ deleted: number() });
17113
+ var VectorGetInputSchema = object({
17114
+ index: string(),
17115
+ ids: array(string())
17116
+ });
17117
+ /**
17118
+ * Metadata for the requested ids, WITHOUT their vectors.
17119
+ *
17120
+ * The only caller is a best-of gate that compares a candidate's confidence
17121
+ * against the stored one, and shipping 512 floats back to answer "is 0.91 >
17122
+ * 0.87" would undo the point of the compact encoding. Ids with no row are
17123
+ * simply absent — a caller distinguishing "not stored" from "stored" reads the
17124
+ * length, and a null placeholder would invite a `?? 0` that treats a missing
17125
+ * row as confidence zero.
17126
+ */
17127
+ var VectorGetResultSchema = object({ items: array(object({
17128
+ id: string(),
17129
+ metadata: VectorMetadataSchema
17130
+ })) });
17032
17131
  var VectorStatsInputSchema = object({ index: string() });
17033
17132
  var VectorStatsResultSchema = object({
17034
17133
  /** Provider id, so an operator can tell brute force from an ANN index. */
@@ -17041,7 +17140,7 @@ var VectorStatsResultSchema = object({
17041
17140
  /** False when the backend ranks approximately. */
17042
17141
  exact: boolean()
17043
17142
  });
17044
- method(VectorDeclareIndexInputSchema, _void(), { kind: "mutation" }), method(VectorUpsertInputSchema, VectorUpsertResultSchema, { kind: "mutation" }), method(VectorQueryInputSchema, VectorQueryResultSchema), method(VectorDeleteInputSchema, VectorDeleteResultSchema, { kind: "mutation" }), method(VectorDeleteByFilterInputSchema, VectorDeleteResultSchema, { kind: "mutation" }), method(VectorStatsInputSchema, VectorStatsResultSchema);
17143
+ method(VectorDeclareIndexInputSchema, _void(), { kind: "mutation" }), method(VectorUpsertInputSchema, VectorUpsertResultSchema, { kind: "mutation" }), method(VectorQueryInputSchema, VectorQueryResultSchema), method(VectorGetInputSchema, VectorGetResultSchema), method(VectorDeleteInputSchema, VectorDeleteResultSchema, { kind: "mutation" }), method(VectorDeleteByFilterInputSchema, VectorDeleteResultSchema, { kind: "mutation" }), method(VectorStatsInputSchema, VectorStatsResultSchema);
17045
17144
  /**
17046
17145
  * `videoclips` — the unified, navigable-clip surface for a camera.
17047
17146
  *
@@ -25304,6 +25403,12 @@ Object.freeze({
25304
25403
  addonId: null,
25305
25404
  access: "view"
25306
25405
  },
25406
+ "pipelineAnalytics.getObjectEmbeddingRebuildStatus": {
25407
+ capName: "pipeline-analytics",
25408
+ capScope: "device",
25409
+ addonId: null,
25410
+ access: "view"
25411
+ },
25307
25412
  "pipelineAnalytics.getObjectEvents": {
25308
25413
  capName: "pipeline-analytics",
25309
25414
  capScope: "device",
@@ -25382,6 +25487,12 @@ Object.freeze({
25382
25487
  addonId: null,
25383
25488
  access: "create"
25384
25489
  },
25490
+ "pipelineAnalytics.rebuildObjectEmbeddings": {
25491
+ capName: "pipeline-analytics",
25492
+ capScope: "device",
25493
+ addonId: null,
25494
+ access: "create"
25495
+ },
25385
25496
  "pipelineAnalytics.relocateMedia": {
25386
25497
  capName: "pipeline-analytics",
25387
25498
  capScope: "device",
@@ -25400,6 +25511,12 @@ Object.freeze({
25400
25511
  addonId: null,
25401
25512
  access: "delete"
25402
25513
  },
25514
+ "pipelineAnalytics.wipeObjectEmbeddings": {
25515
+ capName: "pipeline-analytics",
25516
+ capScope: "device",
25517
+ addonId: null,
25518
+ access: "delete"
25519
+ },
25403
25520
  "pipelineExecutor.cacheFrameInPool": {
25404
25521
  capName: "pipeline-executor",
25405
25522
  capScope: "system",
@@ -27320,6 +27437,12 @@ Object.freeze({
27320
27437
  addonId: null,
27321
27438
  access: "delete"
27322
27439
  },
27440
+ "vectorStore.getByIds": {
27441
+ capName: "vector-store",
27442
+ capScope: "system",
27443
+ addonId: null,
27444
+ access: "view"
27445
+ },
27323
27446
  "vectorStore.query": {
27324
27447
  capName: "vector-store",
27325
27448
  capScope: "system",
@@ -27594,6 +27717,30 @@ TimelapseRuleInputSchema.extend({
27594
27717
  createdAt: number(),
27595
27718
  updatedAt: number()
27596
27719
  });
27720
+ object({
27721
+ /**
27722
+ * Fraction of the box's own size added on EACH side before cutting.
27723
+ *
27724
+ * CLIP is trained on natural images WITH surroundings; a pixel-tight crop
27725
+ * removes exactly the context it is strongest on (a dog cut to its outline
27726
+ * is a dark blob). The right value is an empirical question, which is why it
27727
+ * is a setting: 0 / 0.15 / 0.2 / 0.5 are the interesting points.
27728
+ */
27729
+ paddingRatio: number().min(0).max(4),
27730
+ /**
27731
+ * Square the window (in PIXELS) before cutting.
27732
+ *
27733
+ * CLIP's input is square, so a tall bbox resized straight to NxN is squashed
27734
+ * — a standing person becomes a shape the model never saw. Squaring costs
27735
+ * extra background, which is context the model wants anyway. Off by default
27736
+ * because the live path has never squared and the stored index reflects that.
27737
+ */
27738
+ square: boolean()
27739
+ });
27740
+ ({
27741
+ paddingRatio: .15,
27742
+ square: false
27743
+ }).paddingRatio;
27597
27744
  /**
27598
27745
  * Deterministic SHA-256 hash of an arbitrary serialisable value. The
27599
27746
  * canonical form sorts object keys alphabetically at every depth so two
@@ -38150,13 +38297,305 @@ var require_src = /* @__PURE__ */ __commonJSMin(((exports) => {
38150
38297
  __exportStar(require_util(), exports);
38151
38298
  }));
38152
38299
  //#endregion
38153
- //#region src/mappers/builders/camera-streams.ts
38300
+ //#region src/mappers/builders/rtcp-gate.ts
38154
38301
  var import_src = require_src();
38302
+ /** `packet@42ms` / `timeout@1000ms` — one compact log field. */
38303
+ function formatRtcpGate(resolution) {
38304
+ if (resolution === null) return "pending";
38305
+ return `${resolution.reason}@${resolution.waitedMs}ms`;
38306
+ }
38307
+ /**
38308
+ * Resolve as soon as ANY packet arrives on `socket`, or after `timeoutMs`.
38309
+ * One-shot: the listener is detached on either path.
38310
+ */
38311
+ function makeRtcpGate(socket, timeoutMs) {
38312
+ const armedAt = Date.now();
38313
+ return new Promise((resolve) => {
38314
+ const onMessage = () => {
38315
+ socket.removeListener("message", onMessage);
38316
+ clearTimeout(timer);
38317
+ resolve({
38318
+ reason: "packet",
38319
+ waitedMs: Date.now() - armedAt
38320
+ });
38321
+ };
38322
+ const timer = setTimeout(() => {
38323
+ socket.removeListener("message", onMessage);
38324
+ resolve({
38325
+ reason: "timeout",
38326
+ waitedMs: Date.now() - armedAt
38327
+ });
38328
+ }, timeoutMs);
38329
+ socket.on("message", onMessage);
38330
+ });
38331
+ }
38332
+ /**
38333
+ * The resolutions we offer, before rates are attached. Same list the delegate
38334
+ * advertised before R2 — only the frame rate changes, so a controller that had
38335
+ * already negotiated a resolution keeps finding it.
38336
+ */
38337
+ var CANDIDATE_RESOLUTIONS = [
38338
+ [1920, 1080],
38339
+ [1280, 720],
38340
+ [1024, 768],
38341
+ [640, 480],
38342
+ [640, 360],
38343
+ [480, 360],
38344
+ [480, 270],
38345
+ [320, 240],
38346
+ [320, 180]
38347
+ ];
38348
+ /**
38349
+ * Resolve the real frame rate of each profile slot.
38350
+ *
38351
+ * A slot with no measurement, no publication and no assignment simply does not
38352
+ * appear in the map — the caller then advertises {@link ASSUMED_FPS} and says
38353
+ * so. Absence is never silently rendered as a number.
38354
+ */
38355
+ function resolveProfileFps(input) {
38356
+ const publishedByProfile = publishedFpsByProfile(input.slots, input.camStreams);
38357
+ const out = /* @__PURE__ */ new Map();
38358
+ for (const choice of input.choices) {
38359
+ if (choice.target.kind !== "profile") continue;
38360
+ const profile = choice.target.profile;
38361
+ const measured = clampFps(choice.inputFps);
38362
+ if (measured !== null) {
38363
+ out.set(profile, {
38364
+ profile,
38365
+ fps: measured,
38366
+ source: "measured"
38367
+ });
38368
+ continue;
38369
+ }
38370
+ const published = clampFps(publishedByProfile.get(profile) ?? null);
38371
+ out.set(profile, published !== null ? {
38372
+ profile,
38373
+ fps: published,
38374
+ source: "published"
38375
+ } : {
38376
+ profile,
38377
+ fps: 30,
38378
+ source: "assumed"
38379
+ });
38380
+ }
38381
+ for (const [profile, fps] of publishedByProfile) {
38382
+ if (out.has(profile)) continue;
38383
+ const published = clampFps(fps);
38384
+ if (published !== null) out.set(profile, {
38385
+ profile,
38386
+ fps: published,
38387
+ source: "published"
38388
+ });
38389
+ }
38390
+ return out;
38391
+ }
38392
+ /**
38393
+ * Attach a frame rate to each candidate resolution by asking the REAL picker
38394
+ * which slot the START path would dial for it. That coupling is the point: if
38395
+ * the picker's steering changes, the advertisement changes with it instead of
38396
+ * drifting into a second, silently different opinion.
38397
+ */
38398
+ function deriveAdvertisedResolutions(input) {
38399
+ const candidates = input.candidates.length > 0 ? input.candidates : CANDIDATE_RESOLUTIONS;
38400
+ const seen = /* @__PURE__ */ new Set();
38401
+ const out = [];
38402
+ for (const [width, height] of candidates) {
38403
+ const picked = pickPreferredRtspEntry(input.entries, input.pref, input.deviceId, { targetResolution: {
38404
+ width,
38405
+ height
38406
+ } });
38407
+ const profile = picked === null ? null : toCamProfile(picked.profileId);
38408
+ const resolved = profile === null ? void 0 : input.fpsByProfile.get(profile);
38409
+ const advertised = {
38410
+ width,
38411
+ height,
38412
+ fps: resolved?.fps ?? 30,
38413
+ profile,
38414
+ source: resolved?.source ?? "assumed"
38415
+ };
38416
+ const key = `${advertised.width}x${advertised.height}@${advertised.fps}`;
38417
+ if (seen.has(key)) continue;
38418
+ seen.add(key);
38419
+ out.push(advertised);
38420
+ }
38421
+ return out;
38422
+ }
38423
+ /** Project onto the `[width, height, fps]` triples hap-nodejs expects. */
38424
+ function toHapResolutions(advertised) {
38425
+ return advertised.map((a) => [
38426
+ a.width,
38427
+ a.height,
38428
+ a.fps
38429
+ ]);
38430
+ }
38431
+ /** Compact `1280x720@10(measured)` rendering for a single log field. */
38432
+ function formatAdvertisedResolutions(advertised) {
38433
+ return advertised.map((a) => `${a.width}x${a.height}@${a.fps}/${a.profile ?? "-"}(${a.source})`);
38434
+ }
38435
+ function publishedFpsByProfile(slots, camStreams) {
38436
+ const fpsByCamStream = /* @__PURE__ */ new Map();
38437
+ for (const stream of camStreams) if (typeof stream.fps === "number") fpsByCamStream.set(stream.camStreamId, stream.fps);
38438
+ const out = /* @__PURE__ */ new Map();
38439
+ for (const slot of slots) {
38440
+ if (slot.sourceCamStreamId === null) continue;
38441
+ const fps = fpsByCamStream.get(slot.sourceCamStreamId);
38442
+ if (fps !== void 0) out.set(slot.profile, fps);
38443
+ }
38444
+ return out;
38445
+ }
38446
+ /**
38447
+ * Coerce a probe reading into an advertisable integer rate, or null when the
38448
+ * reading carries no information (absent, zero because the broker is idle,
38449
+ * negative, NaN, or below the representable floor).
38450
+ */
38451
+ function clampFps(value) {
38452
+ if (typeof value !== "number" || !Number.isFinite(value)) return null;
38453
+ const floored = Math.floor(value);
38454
+ if (floored < 1) return null;
38455
+ return Math.min(floored, 30);
38456
+ }
38457
+ var CAM_PROFILES = [
38458
+ "high",
38459
+ "mid",
38460
+ "low"
38461
+ ];
38462
+ /**
38463
+ * `PickedStream.profileId` is the brokerId suffix, which for a profile-keyed
38464
+ * entry IS the profile name. Anything else (a raw cam-stream id) is not a
38465
+ * profile and must not be coerced into one.
38466
+ */
38467
+ function toCamProfile(profileId) {
38468
+ return CAM_PROFILES.find((p) => p === profileId) ?? null;
38469
+ }
38470
+ //#endregion
38471
+ //#region src/mappers/builders/stream-telemetry.ts
38472
+ /**
38473
+ * Every branch on the streaming path that discards work, and its starting
38474
+ * count. This object is the single source of the reason set: the union below
38475
+ * is its `keyof`, so adding a silent `return` without adding a reason here is
38476
+ * a compile error rather than an invisible hole.
38477
+ */
38478
+ var ZERO_DROP_COUNTERS = {
38479
+ /** Outbound: the leg has no SRTP session (prepareStream init failed). */
38480
+ "no-srtp-session": 0,
38481
+ /** Outbound: the leg has no return-path gate — forwarding was never armed. */
38482
+ "no-gate": 0,
38483
+ /** Outbound: werift refused to encrypt the packet. */
38484
+ "srtp-encrypt-failed": 0,
38485
+ /** Outbound: the UDP send itself errored. */
38486
+ "srtp-send-failed": 0,
38487
+ /** Outbound RTCP: the leg has no SRTCP context, so no Sender Report can go out. */
38488
+ "rtcp-no-srtcp": 0,
38489
+ /** Outbound RTCP: building or encrypting the Sender Report failed. */
38490
+ "rtcp-encrypt-failed": 0,
38491
+ /** Upstream: packet shorter than an RTP header. */
38492
+ "upstream-short-packet": 0,
38493
+ /** Upstream: no inbound SRTP context (init failed at prepareStream). */
38494
+ "upstream-no-srtp": 0,
38495
+ /** Upstream: SRTP authentication/decrypt failed. */
38496
+ "upstream-decrypt-failed": 0,
38497
+ /** Upstream: decrypted bytes did not parse as RTP. */
38498
+ "upstream-parse-failed": 0,
38499
+ /** Upstream: audio arrived before `handleStreamRequest('start')` settled. */
38500
+ "upstream-before-start": 0,
38501
+ /** Upstream: payload type is not the negotiated audio PT (RTCP / keepalive). */
38502
+ "upstream-pt-mismatch": 0,
38503
+ /** Upstream: RTP carried a zero-length payload. */
38504
+ "upstream-empty-payload": 0,
38505
+ /** Upstream: no camera-side talk session, so the frame has nowhere to go. */
38506
+ "upstream-no-talk-session": 0,
38507
+ /** Upstream: the camera-side `pushTalkAudio` rejected the frame. */
38508
+ "upstream-push-failed": 0,
38509
+ /** Snapshot: the `snapshot` cap returned nothing. */
38510
+ "snapshot-unavailable": 0
38511
+ };
38512
+ function emptyDropCounters() {
38513
+ return { ...ZERO_DROP_COUNTERS };
38514
+ }
38515
+ /** Immutable increment — returns a new record, never touches the input. */
38516
+ function recordDrop(counters, reason) {
38517
+ return {
38518
+ ...counters,
38519
+ [reason]: counters[reason] + 1
38520
+ };
38521
+ }
38522
+ /** Only the reasons that actually fired, so a clean session logs `{}`. */
38523
+ function nonZeroDrops(counters) {
38524
+ const out = {};
38525
+ for (const [reason, count] of Object.entries(counters)) if (count > 0) out[reason] = count;
38526
+ return out;
38527
+ }
38528
+ /**
38529
+ * Classify a packet received on one of our advertised return ports.
38530
+ *
38531
+ * RTP and RTCP arrive on the same symmetric port here. RTCP packet types are
38532
+ * 200..207, which sit inside the 192..223 band RFC 5761 §4 reserves precisely
38533
+ * so the two can be told apart on a shared socket (an RTP packet's marker bit
38534
+ * plus payload type can never land there for any payload type we negotiate).
38535
+ * The payloads are SRTP/SRTCP-encrypted, but both keep the first two bytes in
38536
+ * the clear.
38537
+ */
38538
+ function classifyInboundPacket(packet) {
38539
+ if (packet.length < 2) return "malformed";
38540
+ if ((packet[0] >> 6 & 3) !== 2) return "malformed";
38541
+ const typeByte = packet[1];
38542
+ if (typeByte >= 192 && typeByte <= 223) return "rtcp";
38543
+ return "rtp";
38544
+ }
38545
+ /**
38546
+ * Build the meta for `export-hap: stream session summary` — the single line a
38547
+ * future session greps to answer "why did this session die".
38548
+ */
38549
+ function summariseSession(snapshot) {
38550
+ const durationMs = snapshot.startedAtMs !== null && snapshot.endedAtMs !== null ? snapshot.endedAtMs - snapshot.startedAtMs : null;
38551
+ const negotiated = snapshot.negotiated;
38552
+ const slot = snapshot.selectedSlot;
38553
+ return {
38554
+ sessionId: snapshot.sessionId,
38555
+ durationMs,
38556
+ stopRequestedByController: snapshot.stopRequestedByController,
38557
+ ffmpegExitCode: snapshot.ffmpegExit?.code ?? null,
38558
+ ffmpegExitSignal: snapshot.ffmpegExit?.signal ?? null,
38559
+ negotiatedResolution: negotiated ? `${negotiated.width}x${negotiated.height}` : null,
38560
+ negotiatedFps: negotiated?.fps ?? null,
38561
+ negotiatedMaxBitrateKbps: negotiated?.maxBitrateKbps ?? null,
38562
+ selectedProfile: slot?.profile ?? null,
38563
+ selectedBrokerId: slot?.brokerId ?? null,
38564
+ advertisedFps: slot?.advertisedFps ?? null,
38565
+ advertisedFpsSource: slot?.advertisedFpsSource ?? null,
38566
+ transcode: slot?.transcode ?? null,
38567
+ videoPacketsForwarded: snapshot.videoPacketsForwarded,
38568
+ audioPacketsForwarded: snapshot.audioPacketsForwarded,
38569
+ videoRtcpSrSent: snapshot.videoRtcpSrSent,
38570
+ audioRtcpSrSent: snapshot.audioRtcpSrSent,
38571
+ videoRtcpReceived: snapshot.videoRtcpReceived,
38572
+ audioRtcpReceived: snapshot.audioRtcpReceived,
38573
+ videoRtpReceived: snapshot.videoRtpReceived,
38574
+ audioRtpReceived: snapshot.audioRtpReceived,
38575
+ videoGate: formatRtcpGate(snapshot.videoGate),
38576
+ audioGate: formatRtcpGate(snapshot.audioGate),
38577
+ mediaStarved: snapshot.videoPacketsForwarded === 0,
38578
+ drops: nonZeroDrops(snapshot.drops)
38579
+ };
38580
+ }
38581
+ //#endregion
38582
+ //#region src/mappers/builders/camera-streams.ts
38155
38583
  var SRTP_KEY_LEN = 16;
38156
38584
  var SRTP_SALT_LEN = 14;
38585
+ /**
38586
+ * Cadence of the per-session heartbeat log.
38587
+ *
38588
+ * This timer LOGS ONLY. It never kills ffmpeg, never closes a socket and never
38589
+ * ends a session. That distinction matters: the two mainstream open-source
38590
+ * HomeKit camera stacks arm an idle watchdog that tears the stream down at
38591
+ * exactly 30 000 ms, and the ~31 s teardown here was misdiagnosed as one for a
38592
+ * long time before it was proven that this accessory has no such timer. Do not
38593
+ * make this one act.
38594
+ */
38595
+ var SESSION_HEARTBEAT_MS = 5e3;
38157
38596
  var OPUS_BITRATE_KBPS = 24;
38158
38597
  var OPUS_CHANNELS = 1;
38159
- function buildCameraStreamingDelegate(bctx) {
38598
+ function buildCameraStreamingDelegate(bctx, advertised) {
38160
38599
  const { ctx, numericDeviceId } = bctx;
38161
38600
  const log = ctx.logger.withTags({ deviceId: numericDeviceId });
38162
38601
  const sessions = /* @__PURE__ */ new Map();
@@ -38175,7 +38614,7 @@ function buildCameraStreamingDelegate(bctx) {
38175
38614
  });
38176
38615
  },
38177
38616
  handleStreamRequest(request, callback) {
38178
- handleStreamRequest(request, sessions, bctx).then(() => callback()).catch((err) => {
38617
+ handleStreamRequest(request, sessions, bctx, advertised).then(() => callback()).catch((err) => {
38179
38618
  log.warn("export-hap: handleStreamRequest failed", { meta: { error: errMsg$8(err) } });
38180
38619
  callback(err instanceof Error ? err : new Error(errMsg$8(err)));
38181
38620
  });
@@ -38196,58 +38635,7 @@ function buildCameraStreamingDelegate(bctx) {
38196
38635
  H264Level.LEVEL4_0
38197
38636
  ]
38198
38637
  },
38199
- resolutions: [
38200
- [
38201
- 1920,
38202
- 1080,
38203
- 30
38204
- ],
38205
- [
38206
- 1280,
38207
- 720,
38208
- 30
38209
- ],
38210
- [
38211
- 1024,
38212
- 768,
38213
- 30
38214
- ],
38215
- [
38216
- 640,
38217
- 480,
38218
- 30
38219
- ],
38220
- [
38221
- 640,
38222
- 360,
38223
- 30
38224
- ],
38225
- [
38226
- 480,
38227
- 360,
38228
- 30
38229
- ],
38230
- [
38231
- 480,
38232
- 270,
38233
- 30
38234
- ],
38235
- [
38236
- 320,
38237
- 240,
38238
- 30
38239
- ],
38240
- [
38241
- 320,
38242
- 240,
38243
- 15
38244
- ],
38245
- [
38246
- 320,
38247
- 180,
38248
- 30
38249
- ]
38250
- ]
38638
+ resolutions: toHapResolutions(advertised.resolutions)
38251
38639
  },
38252
38640
  audio: {
38253
38641
  codecs: [{
@@ -38262,10 +38650,16 @@ function buildCameraStreamingDelegate(bctx) {
38262
38650
  },
38263
38651
  dispose: async () => {
38264
38652
  for (const session of sessions.values()) {
38653
+ session.teardownTrigger = "accessory-dispose";
38654
+ const hadFfmpeg = session.ffmpeg !== null;
38265
38655
  killFfmpeg(session, ctx, numericDeviceId);
38656
+ stopHeartbeat(session);
38657
+ if (!hadFfmpeg) logSessionSummary(session, log, "accessory-dispose-no-ffmpeg");
38266
38658
  await closeIntercomTalkSession(session, bctx).catch(() => void 0);
38267
38659
  closeSocket(session.videoUdp);
38268
38660
  closeSocket(session.audioUdp);
38661
+ closeSocket(session.videoLoopUdp);
38662
+ closeSocket(session.audioLoopUdp);
38269
38663
  }
38270
38664
  sessions.clear();
38271
38665
  }
@@ -38275,7 +38669,10 @@ async function handleSnapshot(bctx, request) {
38275
38669
  const { proxy, ctx, numericDeviceId } = bctx;
38276
38670
  const result = await proxy.snapshot?.getSnapshot({});
38277
38671
  if (!result || typeof result.base64 !== "string") {
38278
- ctx.logger.withTags({ deviceId: numericDeviceId }).debug("export-hap: snapshot returned null");
38672
+ ctx.logger.withTags({ deviceId: numericDeviceId }).info("export-hap: snapshot dropped", { meta: {
38673
+ reason: "snapshot-unavailable",
38674
+ capBound: proxy.snapshot !== void 0
38675
+ } });
38279
38676
  throw new Error("snapshot unavailable");
38280
38677
  }
38281
38678
  return Buffer.from(result.base64, "base64");
@@ -38347,6 +38744,26 @@ async function prepareStream(request, sessions, bctx) {
38347
38744
  const videoSsrc = randomSsrc();
38348
38745
  const audioSsrc = randomSsrc();
38349
38746
  const session = {
38747
+ sessionId: request.sessionID,
38748
+ startedAtMs: null,
38749
+ endedAtMs: null,
38750
+ negotiated: null,
38751
+ selectedSlot: null,
38752
+ ffmpegExit: null,
38753
+ stopRequestedByController: false,
38754
+ teardownTrigger: null,
38755
+ drops: emptyDropCounters(),
38756
+ videoPacketsForwarded: 0,
38757
+ audioPacketsForwarded: 0,
38758
+ videoRtcpSrSent: 0,
38759
+ audioRtcpSrSent: 0,
38760
+ videoRtcpReceived: 0,
38761
+ audioRtcpReceived: 0,
38762
+ videoRtpReceived: 0,
38763
+ audioRtpReceived: 0,
38764
+ videoGate: null,
38765
+ audioGate: null,
38766
+ heartbeat: null,
38350
38767
  hapVideoPort: request.video.port,
38351
38768
  hapAddress: request.targetAddress,
38352
38769
  videoSrtpKey: request.video.srtp_key,
@@ -38391,19 +38808,33 @@ async function prepareStream(request, sessions, bctx) {
38391
38808
  audioRtcpIntervalMs: 5e3
38392
38809
  };
38393
38810
  sessions.set(request.sessionID, session);
38811
+ const tagLog = bctx.ctx.logger.withTags({ deviceId: bctx.numericDeviceId });
38394
38812
  session.videoSendGate = makeRtcpGate(videoUdp, 1e3);
38395
38813
  session.audioSendGate = makeRtcpGate(audioUdp, 1e3);
38396
- const tagLog = bctx.ctx.logger.withTags({ deviceId: bctx.numericDeviceId });
38397
- let videoPacketsForwarded = 0;
38398
- let audioPacketsForwarded = 0;
38814
+ session.videoSendGate.then((resolution) => {
38815
+ session.videoGate = resolution;
38816
+ logGateResolved(session, "video", resolution, tagLog);
38817
+ });
38818
+ session.audioSendGate.then((resolution) => {
38819
+ session.audioGate = resolution;
38820
+ logGateResolved(session, "audio", resolution, tagLog);
38821
+ });
38822
+ videoUdp.on("message", (packet) => {
38823
+ countInbound(session, "video", packet, tagLog);
38824
+ });
38825
+ audioUdp.on("message", (packet) => {
38826
+ countInbound(session, "audio", packet, tagLog);
38827
+ });
38399
38828
  videoLoopUdp.on("message", (rtpPacket) => {
38400
- videoPacketsForwarded += 1;
38401
- if (videoPacketsForwarded === 1 || videoPacketsForwarded % 500 === 0) tagLog.info("export-hap: video loopback packets forwarded", { meta: { count: videoPacketsForwarded } });
38829
+ session.videoPacketsForwarded += 1;
38830
+ if (session.videoPacketsForwarded === 1) tagLog.info("export-hap: first video packet from ffmpeg", { meta: {
38831
+ sessionId: session.sessionId,
38832
+ bytes: rtpPacket.length
38833
+ } });
38402
38834
  forwardEncryptedRtp(session, rtpPacket, "video", tagLog);
38403
38835
  });
38404
38836
  audioLoopUdp.on("message", (rtpPacket) => {
38405
- audioPacketsForwarded += 1;
38406
- if (audioPacketsForwarded === 1 || audioPacketsForwarded % 100 === 0) tagLog.info("export-hap: audio loopback packets forwarded", { meta: { count: audioPacketsForwarded } });
38837
+ session.audioPacketsForwarded += 1;
38407
38838
  forwardEncryptedRtp(session, rtpPacket, "audio", tagLog);
38408
38839
  });
38409
38840
  audioUdp.on("message", (packet, rinfo) => {
@@ -38411,6 +38842,19 @@ async function prepareStream(request, sessions, bctx) {
38411
38842
  bctx.ctx.logger.withTags({ deviceId: bctx.numericDeviceId }).debug("export-hap: incoming-audio handler error (dropped)", { meta: { error: errMsg$8(err) } });
38412
38843
  });
38413
38844
  });
38845
+ tagLog.info("export-hap: stream prepared", { meta: {
38846
+ sessionId: request.sessionID,
38847
+ controllerAddress: request.targetAddress,
38848
+ addressVersion: ipVersion,
38849
+ localIp,
38850
+ advertisedVideoPort: localVideoPort,
38851
+ advertisedAudioPort: localAudioPort,
38852
+ controllerVideoPort: request.video.port,
38853
+ controllerAudioPort: request.audio.port,
38854
+ videoSsrc,
38855
+ audioSsrc,
38856
+ upstreamAudioDecrypt: upstreamAudioSrtp !== null
38857
+ } });
38414
38858
  return {
38415
38859
  video: {
38416
38860
  port: localVideoPort,
@@ -38485,30 +38929,110 @@ function sameIpv4Subnet(a, mask, b) {
38485
38929
  async function bindLoopback(ipVersion) {
38486
38930
  return bindUdp(ipVersion, ipVersion === "ipv6" ? "::1" : "127.0.0.1");
38487
38931
  }
38932
+ /** Book a named drop. Every silent `return` on the streaming path routes here. */
38933
+ function drop(session, reason) {
38934
+ session.drops = recordDrop(session.drops, reason);
38935
+ }
38488
38936
  /**
38489
- * Build a promise that resolves as soon as ANY packet (RTP or RTCP)
38490
- * arrives on `socket`, OR `timeoutMs` elapses. The loopback reader
38491
- * awaits this before forwarding the first encrypted SRTP frame so
38492
- * iOS sees a return-path handshake before our actual stream begins.
38493
- *
38494
- * One-shot: the listener is removed on the first message OR on
38495
- * timeout — the gate then stays resolved for the rest of the session.
38937
+ * Report a gate resolution. `timeout` is the interesting one: it means we are
38938
+ * about to stream into a port the controller never touched, and it was
38939
+ * indistinguishable from a successful probe until now.
38496
38940
  */
38497
- function makeRtcpGate(socket, timeoutMs) {
38498
- return new Promise((resolve) => {
38499
- const onMessage = () => {
38500
- socket.removeListener("message", onMessage);
38501
- clearTimeout(timer);
38502
- resolve();
38503
- };
38504
- const timer = setTimeout(() => {
38505
- socket.removeListener("message", onMessage);
38506
- resolve();
38507
- }, timeoutMs);
38508
- socket.on("message", onMessage);
38941
+ function logGateResolved(session, leg, resolution, log) {
38942
+ const meta = {
38943
+ sessionId: session.sessionId,
38944
+ leg,
38945
+ reason: resolution.reason,
38946
+ waitedMs: resolution.waitedMs
38947
+ };
38948
+ if (resolution.reason === "packet") log.info("export-hap: RTCP gate opened on a REAL controller packet", { meta });
38949
+ else log.warn("export-hap: RTCP gate opened on the 1s FALLBACK — controller never probed", { meta });
38950
+ }
38951
+ /** Count and classify one packet the controller sent to a return port. */
38952
+ function countInbound(session, leg, packet, log) {
38953
+ const kind = classifyInboundPacket(packet);
38954
+ const firstRtcp = kind === "rtcp" && (leg === "video" ? session.videoRtcpReceived : session.audioRtcpReceived) === 0;
38955
+ if (leg === "video") {
38956
+ if (kind === "rtcp") session.videoRtcpReceived += 1;
38957
+ else if (kind === "rtp") session.videoRtpReceived += 1;
38958
+ } else if (kind === "rtcp") session.audioRtcpReceived += 1;
38959
+ else if (kind === "rtp") session.audioRtpReceived += 1;
38960
+ if (firstRtcp) log.info("export-hap: first inbound RTCP from controller", { meta: {
38961
+ sessionId: session.sessionId,
38962
+ leg,
38963
+ bytes: packet.length
38964
+ } });
38965
+ }
38966
+ /** Snapshot every counter into the summary meta. */
38967
+ function sessionSummaryMeta(session) {
38968
+ return summariseSession({
38969
+ sessionId: session.sessionId,
38970
+ startedAtMs: session.startedAtMs,
38971
+ endedAtMs: session.endedAtMs,
38972
+ negotiated: session.negotiated,
38973
+ selectedSlot: session.selectedSlot,
38974
+ videoPacketsForwarded: session.videoPacketsForwarded,
38975
+ audioPacketsForwarded: session.audioPacketsForwarded,
38976
+ videoRtcpSrSent: session.videoRtcpSrSent,
38977
+ audioRtcpSrSent: session.audioRtcpSrSent,
38978
+ videoRtcpReceived: session.videoRtcpReceived,
38979
+ audioRtcpReceived: session.audioRtcpReceived,
38980
+ videoRtpReceived: session.videoRtpReceived,
38981
+ audioRtpReceived: session.audioRtpReceived,
38982
+ videoGate: session.videoGate,
38983
+ audioGate: session.audioGate,
38984
+ drops: session.drops,
38985
+ ffmpegExit: session.ffmpegExit,
38986
+ stopRequestedByController: session.stopRequestedByController
38509
38987
  });
38510
38988
  }
38511
38989
  /**
38990
+ * Arm the per-session heartbeat. LOGS ONLY — see `SESSION_HEARTBEAT_MS`. It
38991
+ * exists so a session that stops producing media says so while it is still
38992
+ * alive, instead of being reconstructed after the fact from its final line.
38993
+ */
38994
+ function armHeartbeat(session, log) {
38995
+ if (session.heartbeat) clearInterval(session.heartbeat);
38996
+ let lastVideo = session.videoPacketsForwarded;
38997
+ const timer = setInterval(() => {
38998
+ const forwarded = session.videoPacketsForwarded - lastVideo;
38999
+ lastVideo = session.videoPacketsForwarded;
39000
+ log.info("export-hap: stream heartbeat", { meta: {
39001
+ ...sessionSummaryMeta(session),
39002
+ videoPacketsSinceLastBeat: forwarded,
39003
+ videoStalled: forwarded === 0
39004
+ } });
39005
+ }, SESSION_HEARTBEAT_MS);
39006
+ timer.unref();
39007
+ session.heartbeat = timer;
39008
+ }
39009
+ function stopHeartbeat(session) {
39010
+ if (!session.heartbeat) return;
39011
+ clearInterval(session.heartbeat);
39012
+ session.heartbeat = null;
39013
+ }
39014
+ /**
39015
+ * **`export-hap: stream session summary` is the line to grep** when asking why
39016
+ * a HomeKit session died. It carries the negotiated parameters, the slot we
39017
+ * dialled and the rate we had promised for it, packet counts in both
39018
+ * directions on both legs, each leg's gate outcome, ffmpeg's exit, who asked
39019
+ * for the teardown, and every named drop.
39020
+ *
39021
+ * Emitted from the ffmpeg `exit` handler — the only place the exit code is
39022
+ * known — and directly from the teardown paths when there is no ffmpeg to wait
39023
+ * for. It deliberately does NOT touch the heartbeat: iOS restarts a session
39024
+ * in place when it is not getting a picture (one on record was started three
39025
+ * times in 16 s), and stopping the heartbeat on the superseded process's exit
39026
+ * would silence the replacement.
39027
+ */
39028
+ function logSessionSummary(session, log, trigger) {
39029
+ session.endedAtMs = Date.now();
39030
+ log.info("export-hap: stream session summary", { meta: {
39031
+ ...sessionSummaryMeta(session),
39032
+ trigger
39033
+ } });
39034
+ }
39035
+ /**
38512
39036
  * Build the NTP timestamp expected in an RTCP Sender Report.
38513
39037
  *
38514
39038
  * NTP timestamps are 64-bit: upper 32 bits = seconds since 1900-01-01,
@@ -38541,7 +39065,10 @@ function ntpTime() {
38541
39065
  */
38542
39066
  function sendRtcpSr(session, kind, log) {
38543
39067
  const srtcp = kind === "video" ? session.videoOutSrtcp : session.audioOutSrtcp;
38544
- if (!srtcp) return;
39068
+ if (!srtcp) {
39069
+ drop(session, "rtcp-no-srtcp");
39070
+ return;
39071
+ }
38545
39072
  const sink = kind === "video" ? session.videoUdp : session.audioUdp;
38546
39073
  const port = kind === "video" ? session.hapVideoPort : session.hapAudioPort;
38547
39074
  try {
@@ -38556,15 +39083,31 @@ function sendRtcpSr(session, kind, log) {
38556
39083
  });
38557
39084
  const encrypted = srtcp.encrypt(sr.serialize());
38558
39085
  sink.send(encrypted, port, session.hapAddress, (err) => {
38559
- if (err) log.debug("export-hap: RTCP send error", { meta: {
38560
- kind,
38561
- error: err.message
38562
- } });
39086
+ if (err) {
39087
+ drop(session, "srtp-send-failed");
39088
+ log.warn("export-hap: RTCP send error", { meta: {
39089
+ sessionId: session.sessionId,
39090
+ kind,
39091
+ error: err.message
39092
+ } });
39093
+ }
38563
39094
  });
38564
- if (kind === "video") session.videoOutLastRtcpAt = Date.now();
38565
- else session.audioOutLastRtcpAt = Date.now();
39095
+ if (kind === "video") {
39096
+ session.videoOutLastRtcpAt = Date.now();
39097
+ session.videoRtcpSrSent += 1;
39098
+ if (session.videoRtcpSrSent === 1) log.info("export-hap: first VIDEO RTCP Sender Report sent", { meta: {
39099
+ sessionId: session.sessionId,
39100
+ rtpTimestamp: session.videoOutLastRtpTimestamp,
39101
+ intervalMs: session.videoRtcpIntervalMs
39102
+ } });
39103
+ } else {
39104
+ session.audioOutLastRtcpAt = Date.now();
39105
+ session.audioRtcpSrSent += 1;
39106
+ }
38566
39107
  } catch (err) {
38567
- log.debug("export-hap: RTCP SR build/encrypt failed", { meta: {
39108
+ drop(session, "rtcp-encrypt-failed");
39109
+ log.warn("export-hap: RTCP SR build/encrypt failed", { meta: {
39110
+ sessionId: session.sessionId,
38568
39111
  kind,
38569
39112
  error: err instanceof Error ? err.message : String(err)
38570
39113
  } });
@@ -38586,7 +39129,14 @@ function forwardEncryptedRtp(session, rtpPacket, kind, log) {
38586
39129
  const sink = kind === "video" ? session.videoUdp : session.audioUdp;
38587
39130
  const port = kind === "video" ? session.hapVideoPort : session.hapAudioPort;
38588
39131
  const gate = kind === "video" ? session.videoSendGate : session.audioSendGate;
38589
- if (!srtp || !gate) return;
39132
+ if (!srtp) {
39133
+ drop(session, "no-srtp-session");
39134
+ return;
39135
+ }
39136
+ if (!gate) {
39137
+ drop(session, "no-gate");
39138
+ return;
39139
+ }
38590
39140
  gate.then(() => {
38591
39141
  try {
38592
39142
  const parsed = import_src.RtpPacket.deSerialize(rtpPacket);
@@ -38622,32 +39172,46 @@ function forwardEncryptedRtp(session, rtpPacket, kind, log) {
38622
39172
  }
38623
39173
  const encrypted = srtp.encrypt(parsed.payload, parsed.header);
38624
39174
  sink.send(encrypted, port, session.hapAddress, (err) => {
38625
- if (err) log.debug("export-hap: SRTP send error", { meta: {
38626
- kind,
38627
- error: err.message
38628
- } });
39175
+ if (err) {
39176
+ drop(session, "srtp-send-failed");
39177
+ log.debug("export-hap: SRTP send error", { meta: {
39178
+ sessionId: session.sessionId,
39179
+ kind,
39180
+ error: err.message
39181
+ } });
39182
+ }
38629
39183
  });
38630
39184
  const now = Date.now();
38631
39185
  if (kind === "video") {
38632
39186
  if (firstVideo || now > session.videoOutLastRtcpAt + session.videoRtcpIntervalMs) sendRtcpSr(session, "video", log);
38633
39187
  } else if (firstAudio || now > session.audioOutLastRtcpAt + session.audioRtcpIntervalMs) sendRtcpSr(session, "audio", log);
38634
39188
  } catch (err) {
39189
+ drop(session, "srtp-encrypt-failed");
38635
39190
  log.debug("export-hap: SRTP encrypt failed", { meta: {
39191
+ sessionId: session.sessionId,
38636
39192
  kind,
38637
39193
  error: err instanceof Error ? err.message : String(err)
38638
39194
  } });
38639
39195
  }
38640
39196
  });
38641
39197
  }
38642
- async function handleStreamRequest(request, sessions, bctx) {
39198
+ async function handleStreamRequest(request, sessions, bctx, advertised) {
38643
39199
  const log = bctx.ctx.logger.withTags({ deviceId: bctx.numericDeviceId });
38644
39200
  const session = sessions.get(request.sessionID);
38645
39201
  if (!session) {
38646
- log.warn("export-hap: stream request for unknown session", { meta: { sessionID: request.sessionID } });
39202
+ log.warn("export-hap: stream request for unknown session", { meta: {
39203
+ sessionID: request.sessionID,
39204
+ type: request.type
39205
+ } });
38647
39206
  return;
38648
39207
  }
38649
39208
  if (request.type === "stop") {
39209
+ session.stopRequestedByController = true;
39210
+ session.teardownTrigger = "controller-stop";
39211
+ const hadFfmpeg = session.ffmpeg !== null;
38650
39212
  killFfmpeg(session, bctx.ctx, bctx.numericDeviceId);
39213
+ stopHeartbeat(session);
39214
+ if (!hadFfmpeg) logSessionSummary(session, log, "controller-stop-no-ffmpeg");
38651
39215
  await closeIntercomTalkSession(session, bctx).catch(() => void 0);
38652
39216
  closeSocket(session.videoUdp);
38653
39217
  closeSocket(session.audioUdp);
@@ -38673,6 +39237,27 @@ async function handleStreamRequest(request, sessions, bctx) {
38673
39237
  session.videoOutOctetCount = 0;
38674
39238
  session.videoOutLastRtpTimestamp = 0;
38675
39239
  session.videoOutLastRtcpAt = 0;
39240
+ session.negotiated = {
39241
+ width: request.video.width,
39242
+ height: request.video.height,
39243
+ fps: request.video.fps,
39244
+ maxBitrateKbps: request.video.max_bit_rate,
39245
+ mtu: request.video.mtu,
39246
+ videoPt: request.video.pt,
39247
+ videoSsrc: session.videoSsrc,
39248
+ videoRtcpIntervalMs: session.videoRtcpIntervalMs,
39249
+ audioPt: request.audio.pt,
39250
+ audioSsrc: session.audioOutSsrc,
39251
+ audioSampleRateKhz: request.audio.sample_rate,
39252
+ audioPacketTimeMs: packetTimeMs,
39253
+ audioRtcpIntervalMs: session.audioRtcpIntervalMs,
39254
+ audioMaxBitrateKbps: request.audio.max_bit_rate
39255
+ };
39256
+ session.startedAtMs = Date.now();
39257
+ log.info("export-hap: stream negotiated", { meta: {
39258
+ sessionId: request.sessionID,
39259
+ ...session.negotiated
39260
+ } });
38676
39261
  session.lastStartParams = {
38677
39262
  pt: request.video.pt,
38678
39263
  mtu: request.video.mtu,
@@ -38693,7 +39278,8 @@ async function handleStreamRequest(request, sessions, bctx) {
38693
39278
  sample_rate: request.audio.sample_rate,
38694
39279
  packet_time: packetTimeMs
38695
39280
  };
38696
- await startFfmpegForSession(bctx, session, request.sessionID, startParams);
39281
+ await startFfmpegForSession(bctx, session, request.sessionID, startParams, advertised);
39282
+ armHeartbeat(session, log);
38697
39283
  return;
38698
39284
  }
38699
39285
  if (request.type === "reconfigure") {
@@ -38720,23 +39306,69 @@ async function handleStreamRequest(request, sessions, bctx) {
38720
39306
  sample_rate: session.lastStartParams.audioSampleRateEnum,
38721
39307
  packet_time: session.lastStartParams.audioPacketTimeMs
38722
39308
  };
38723
- await startFfmpegForSession(bctx, session, request.sessionID, startParams);
39309
+ log.info("export-hap: stream reconfigured", { meta: {
39310
+ sessionId: request.sessionID,
39311
+ width: request.video.width,
39312
+ height: request.video.height,
39313
+ fps: request.video.fps,
39314
+ maxBitrateKbps: request.video.max_bit_rate
39315
+ } });
39316
+ await startFfmpegForSession(bctx, session, request.sessionID, startParams, advertised);
39317
+ armHeartbeat(session, log);
38724
39318
  }
38725
39319
  }
38726
- async function startFfmpegForSession(bctx, session, sessionId, video) {
39320
+ async function startFfmpegForSession(bctx, session, sessionId, video, advertised) {
38727
39321
  const { ctx, proxy, numericDeviceId, options } = bctx;
39322
+ const startLog = ctx.logger.withTags({ deviceId: numericDeviceId });
38728
39323
  const entries = await proxy.cameraStreams?.getProfileRtspEntries({}) ?? [];
38729
- if (entries.length === 0) throw new Error(`export-hap: no profile RTSP entries for device ${numericDeviceId}`);
39324
+ if (entries.length === 0) {
39325
+ startLog.warn("export-hap: stream start DROPPED — device publishes no profile RTSP entries", { meta: {
39326
+ sessionId,
39327
+ capBound: proxy.cameraStreams !== void 0
39328
+ } });
39329
+ throw new Error(`export-hap: no profile RTSP entries for device ${numericDeviceId}`);
39330
+ }
38730
39331
  const pref = options.hapDeviceSettings.streamPreference;
38731
39332
  const picked = pickPreferredRtspEntry(entries, pref, numericDeviceId, { targetResolution: {
38732
39333
  width: video.width,
38733
39334
  height: video.height
38734
39335
  } });
38735
- if (!picked) throw new Error(`export-hap: no enabled RTSP entries for device ${numericDeviceId}`);
39336
+ if (!picked) {
39337
+ startLog.warn("export-hap: stream start DROPPED — no ENABLED profile RTSP entry", { meta: {
39338
+ sessionId,
39339
+ streamPreference: pref,
39340
+ targetResolution: `${video.width}x${video.height}`,
39341
+ entries: entries.map((e) => `${e.profile}:${e.enabled ? "enabled" : "disabled"}`)
39342
+ } });
39343
+ throw new Error(`export-hap: no enabled RTSP entries for device ${numericDeviceId}`);
39344
+ }
38736
39345
  const rtspUrl = picked.url;
38737
39346
  const slot = (await proxy.cameraStreams?.getBrokerStreams({}) ?? []).find((s) => s.brokerId === picked.brokerId);
38738
39347
  const codec = (picked.codec ?? slot?.codec ?? "").toLowerCase();
38739
39348
  const needsTranscode = codec.includes("h265") || codec.includes("hevc");
39349
+ const pickedProfile = toKnownProfile(picked.profileId);
39350
+ const resolvedFps = pickedProfile === null ? void 0 : advertised.fpsByProfile.get(pickedProfile);
39351
+ const advertisedFps = resolvedFps?.fps ?? video.fps;
39352
+ const advertisedFpsSource = resolvedFps?.source ?? "assumed";
39353
+ session.selectedSlot = {
39354
+ profile: pickedProfile,
39355
+ brokerId: picked.brokerId,
39356
+ width: picked.resolution?.width ?? null,
39357
+ height: picked.resolution?.height ?? null,
39358
+ advertisedFps,
39359
+ advertisedFpsSource,
39360
+ codec: codec.length > 0 ? codec : "unknown",
39361
+ transcode: needsTranscode
39362
+ };
39363
+ if (advertisedFps !== video.fps) startLog.warn("export-hap: negotiated fps does NOT match the slot we are about to dial", { meta: {
39364
+ sessionId,
39365
+ negotiatedFps: video.fps,
39366
+ slotFps: advertisedFps,
39367
+ slotFpsSource: advertisedFpsSource,
39368
+ profile: pickedProfile,
39369
+ brokerId: picked.brokerId,
39370
+ transcode: needsTranscode
39371
+ } });
38740
39372
  const videoLoopPort = session.videoLoopUdp.address().port;
38741
39373
  const audioLoopPort = session.audioLoopUdp.address().port;
38742
39374
  const videoTarget = `rtp://127.0.0.1:${videoLoopPort}?pkt_size=${video.mtu}`;
@@ -38836,15 +39468,22 @@ async function startFfmpegForSession(bctx, session, sessionId, video) {
38836
39468
  } });
38837
39469
  });
38838
39470
  proc.once("exit", (code, signal) => {
39471
+ session.ffmpegExit = {
39472
+ code,
39473
+ signal
39474
+ };
38839
39475
  const ok = code === 0 && signal === null;
38840
39476
  const meta = {
38841
39477
  sessionId,
38842
39478
  code,
38843
- signal
39479
+ signal,
39480
+ stopRequestedByController: session.stopRequestedByController,
39481
+ videoPacketsForwarded: session.videoPacketsForwarded
38844
39482
  };
38845
- if (ok) log.info("export-hap: ffmpeg exited", { meta });
38846
- else log.warn("export-hap: ffmpeg exited abnormally", { meta });
39483
+ if (ok || session.stopRequestedByController) log.info("export-hap: ffmpeg exited", { meta });
39484
+ else log.warn("export-hap: ffmpeg exited without a controller stop", { meta });
38847
39485
  if (session.ffmpeg === proc) session.ffmpeg = null;
39486
+ logSessionSummary(session, log, session.teardownTrigger ?? "ffmpeg-exit");
38848
39487
  });
38849
39488
  proc.once("error", (err) => {
38850
39489
  log.warn("export-hap: ffmpeg spawn failed", { meta: {
@@ -38855,12 +39494,27 @@ async function startFfmpegForSession(bctx, session, sessionId, video) {
38855
39494
  log.info("export-hap: stream started", { meta: {
38856
39495
  sessionId,
38857
39496
  transcode: needsTranscode,
38858
- rtspUrl: picked.brokerId,
39497
+ brokerId: picked.brokerId,
39498
+ profile: pickedProfile,
39499
+ sourceResolution: picked.resolution === void 0 ? null : `${picked.resolution.width}x${picked.resolution.height}`,
39500
+ sourceCodec: codec.length > 0 ? codec : "unknown",
39501
+ negotiated: `${video.width}x${video.height}@${video.fps} ${video.max_bit_rate}kbps`,
39502
+ slotFps: advertisedFps,
39503
+ slotFpsSource: advertisedFpsSource,
38859
39504
  audioCodec: "opus",
38860
39505
  audioBitrateKbps: OPUS_BITRATE_KBPS
38861
39506
  } });
38862
39507
  }
38863
39508
  /**
39509
+ * `PickedStream.profileId` is the brokerId suffix; for a profile-keyed entry
39510
+ * that IS the profile name. Anything else addresses a raw cam-stream and must
39511
+ * not be coerced into a profile.
39512
+ */
39513
+ function toKnownProfile(profileId) {
39514
+ if (profileId === "high" || profileId === "mid" || profileId === "low") return profileId;
39515
+ return null;
39516
+ }
39517
+ /**
38864
39518
  * Handle one incoming SRTP audio packet from iOS Home.
38865
39519
  *
38866
39520
  * End-to-end flow:
@@ -38881,15 +39535,22 @@ async function startFfmpegForSession(bctx, session, sessionId, video) {
38881
39535
  * stays codec-agnostic.
38882
39536
  */
38883
39537
  async function handleIncomingAudioRtp(session, packet, sourceAddress, bctx) {
38884
- if (packet.length < 12) return;
39538
+ if (packet.length < 12) {
39539
+ drop(session, "upstream-short-packet");
39540
+ return;
39541
+ }
38885
39542
  const log = bctx.ctx.logger.withTags({ deviceId: bctx.numericDeviceId });
38886
39543
  session.upstreamRtpPacketsReceived += 1;
38887
- if (!session.upstreamAudioSrtp) return;
39544
+ if (!session.upstreamAudioSrtp) {
39545
+ drop(session, "upstream-no-srtp");
39546
+ return;
39547
+ }
38888
39548
  let decryptedPacket;
38889
39549
  try {
38890
39550
  decryptedPacket = session.upstreamAudioSrtp.decrypt(packet);
38891
39551
  session.upstreamRtpPacketsDecrypted += 1;
38892
39552
  } catch (err) {
39553
+ drop(session, "upstream-decrypt-failed");
38893
39554
  session.upstreamRtpDecryptFailures += 1;
38894
39555
  if (session.upstreamRtpDecryptFailures % 100 === 1) log.debug("export-hap: SRTP decrypt failed (rate-limited)", { meta: {
38895
39556
  failures: session.upstreamRtpDecryptFailures,
@@ -38906,18 +39567,28 @@ async function handleIncomingAudioRtp(session, packet, sourceAddress, bctx) {
38906
39567
  rtpTimestamp = parsedRtp.header.timestamp;
38907
39568
  rtpPayloadType = parsedRtp.header.payloadType;
38908
39569
  } catch (err) {
39570
+ drop(session, "upstream-parse-failed");
38909
39571
  log.debug("export-hap: RTP parse failed after decrypt", { meta: { error: errMsg$8(err) } });
38910
39572
  return;
38911
39573
  }
38912
39574
  const negotiatedAudioPt = session.lastStartParams?.audioPt;
38913
- if (negotiatedAudioPt === void 0) return;
38914
- if (rtpPayloadType !== negotiatedAudioPt) return;
39575
+ if (negotiatedAudioPt === void 0) {
39576
+ drop(session, "upstream-before-start");
39577
+ return;
39578
+ }
39579
+ if (rtpPayloadType !== negotiatedAudioPt) {
39580
+ drop(session, "upstream-pt-mismatch");
39581
+ return;
39582
+ }
38915
39583
  if (session.firstUpstreamRtpTimestamp === null) {
38916
39584
  session.firstUpstreamRtpTimestamp = rtpTimestamp;
38917
39585
  log.info("export-hap: first upstream RTP packet received");
38918
39586
  }
38919
39587
  const payload = decryptedPayload;
38920
- if (payload.length === 0) return;
39588
+ if (payload.length === 0) {
39589
+ drop(session, "upstream-empty-payload");
39590
+ return;
39591
+ }
38921
39592
  if (session.intercomTalkSessionId === null) {
38922
39593
  session.intercomTalkSessionId = "";
38923
39594
  const opened = await openIntercomTalkSession(bctx).catch((err) => {
@@ -38929,7 +39600,10 @@ async function handleIncomingAudioRtp(session, packet, sourceAddress, bctx) {
38929
39600
  log.info("export-hap: intercom talk session opened", { meta: { sessionId: opened.sessionId } });
38930
39601
  }
38931
39602
  }
38932
- if (!session.intercomTalkSessionId) return;
39603
+ if (!session.intercomTalkSessionId) {
39604
+ drop(session, "upstream-no-talk-session");
39605
+ return;
39606
+ }
38933
39607
  const opusSampleRateHz = (session.lastStartParams?.audioSampleRateEnum ?? 16) * 1e3;
38934
39608
  session.intercomPcmSequence += 1;
38935
39609
  session.upstreamPcmFramesDecoded += 1;
@@ -38942,6 +39616,7 @@ async function handleIncomingAudioRtp(session, packet, sourceAddress, bctx) {
38942
39616
  sequenceNumber: session.intercomPcmSequence
38943
39617
  });
38944
39618
  } catch (err) {
39619
+ drop(session, "upstream-push-failed");
38945
39620
  log.debug("export-hap: intercom.pushTalkAudio failed (will re-open on next frame)", { meta: {
38946
39621
  sequenceNumber: session.intercomPcmSequence,
38947
39622
  error: errMsg$8(err)
@@ -39025,140 +39700,6 @@ function errMsg$7(err) {
39025
39700
  return err instanceof Error ? err.message : String(err);
39026
39701
  }
39027
39702
  //#endregion
39028
- //#region src/mappers/builders/hksv.ts
39029
- /**
39030
- * HomeKit Secure Video (HKSV) — stub recording delegate.
39031
- *
39032
- * Wires the camera as HKSV-capable so iOS Home shows the "Activity"
39033
- * tab, the per-camera recording settings UI ("Stream and Allow
39034
- * Recording" / "Off"), and notifications. The delegate stub:
39035
- * - logs every protocol callback (active toggle, config change,
39036
- * stream request, close);
39037
- * - returns from `handleRecordingStreamRequest` immediately without
39038
- * yielding any fragment — iOS sees "no recording available" but
39039
- * the camera otherwise behaves like an HKSV camera in the UI.
39040
- *
39041
- * Why a stub: a real implementation needs an fMP4 segmenter that runs
39042
- * a per-camera ffmpeg with a sustained pre-buffer, key/IV-derived
39043
- * encrypter, motion-anchored fragment alignment, and storage for the
39044
- * resulting clips. That work belongs behind the `recording` cap
39045
- * (recording + playback-manifest surface) once it grows fragment export,
39046
- * so the segmenter lives in the recorder addon and HKSV becomes a thin
39047
- * wrapper that asks the cap for "fragments since T" and streams them to iOS.
39048
- *
39049
- * Until that cap lands this stub keeps HKSV characteristics visible
39050
- * (a) so the operator can flip "Allow Recording" without HomeKit
39051
- * complaining the camera lacks the service, and (b) so the
39052
- * `CameraController` advertises a `RecordingManagement` service for
39053
- * iOS-side analytics + Activity-tab UX scaffolding.
39054
- *
39055
- * Constructor returns a `{ options, delegate }` pair the caller
39056
- * passes verbatim into `new CameraController({ ..., recording })`.
39057
- */
39058
- var RECORDING_OPTIONS = {
39059
- prebufferLength: 4e3,
39060
- mediaContainerConfiguration: [{
39061
- type: MediaContainerType.FRAGMENTED_MP4,
39062
- fragmentLength: 4e3
39063
- }],
39064
- video: {
39065
- type: VideoCodecType.H264,
39066
- parameters: {
39067
- profiles: [
39068
- H264Profile.BASELINE,
39069
- H264Profile.MAIN,
39070
- H264Profile.HIGH
39071
- ],
39072
- levels: [
39073
- H264Level.LEVEL3_1,
39074
- H264Level.LEVEL3_2,
39075
- H264Level.LEVEL4_0
39076
- ]
39077
- },
39078
- resolutions: [
39079
- [
39080
- 1920,
39081
- 1080,
39082
- 30
39083
- ],
39084
- [
39085
- 1920,
39086
- 1080,
39087
- 24
39088
- ],
39089
- [
39090
- 1920,
39091
- 1080,
39092
- 15
39093
- ],
39094
- [
39095
- 1280,
39096
- 720,
39097
- 30
39098
- ],
39099
- [
39100
- 1280,
39101
- 720,
39102
- 24
39103
- ],
39104
- [
39105
- 1280,
39106
- 720,
39107
- 15
39108
- ]
39109
- ]
39110
- },
39111
- audio: { codecs: [{
39112
- type: AudioRecordingCodecType.AAC_LC,
39113
- bitrateMode: AudioBitrate.VARIABLE,
39114
- audioChannels: 1,
39115
- samplerate: [AudioRecordingSamplerate.KHZ_16, AudioRecordingSamplerate.KHZ_24]
39116
- }] },
39117
- overrideEventTriggerOptions: [EventTriggerOption.MOTION]
39118
- };
39119
- function buildHksvStub(bctx) {
39120
- const { ctx, numericDeviceId } = bctx;
39121
- const log = ctx.logger.withTags({ deviceId: numericDeviceId });
39122
- let activeStreamId = null;
39123
- return {
39124
- options: RECORDING_OPTIONS,
39125
- delegate: {
39126
- updateRecordingActive(active) {
39127
- log.info("export-hap: HKSV recording active changed", { meta: { active } });
39128
- },
39129
- updateRecordingConfiguration(configuration) {
39130
- if (!configuration) {
39131
- log.info("export-hap: HKSV recording configuration cleared");
39132
- return;
39133
- }
39134
- log.info("export-hap: HKSV recording configuration applied", { meta: {
39135
- prebufferLength: configuration.prebufferLength,
39136
- fragmentLength: configuration.mediaContainerConfiguration.fragmentLength,
39137
- videoResolution: `${configuration.videoCodec.resolution[0]}x${configuration.videoCodec.resolution[1]}@${configuration.videoCodec.resolution[2]}`,
39138
- videoBitrate: configuration.videoCodec.parameters.bitRate,
39139
- eventTriggers: configuration.eventTriggerTypes
39140
- } });
39141
- },
39142
- async *handleRecordingStreamRequest(streamId, _signal) {
39143
- activeStreamId = streamId;
39144
- log.warn("export-hap: HKSV stream requested — stub returns empty (no clip source yet)", { meta: { streamId } });
39145
- },
39146
- acknowledgeStream(streamId) {
39147
- if (activeStreamId === streamId) activeStreamId = null;
39148
- log.debug("export-hap: HKSV stream acknowledged", { meta: { streamId } });
39149
- },
39150
- closeRecordingStream(streamId, reason) {
39151
- if (activeStreamId === streamId) activeStreamId = null;
39152
- log.info("export-hap: HKSV stream closed", { meta: {
39153
- streamId,
39154
- reason: reason ?? null
39155
- } });
39156
- }
39157
- },
39158
- handle: { async dispose() {} }
39159
- };
39160
- }
39161
- //#endregion
39162
39703
  //#region src/mappers/builders/intercom.ts
39163
39704
  async function buildIntercom(input) {
39164
39705
  const { bctx, streamingOptions } = input;
@@ -39452,6 +39993,65 @@ function errMsg$4(err) {
39452
39993
  return err instanceof Error ? err.message : String(err);
39453
39994
  }
39454
39995
  //#endregion
39996
+ //#region src/mappers/builders/stream-fps-probe.ts
39997
+ async function probeAdvertisedVideoProfile(bctx) {
39998
+ const { ctx, proxy, numericDeviceId, options } = bctx;
39999
+ const log = ctx.logger.withTags({ deviceId: numericDeviceId });
40000
+ const entries = await probe(() => proxy.cameraStreams?.getProfileRtspEntries({}), "cameraStreams.getProfileRtspEntries", log);
40001
+ const slots = await probe(() => proxy.cameraStreams?.getBrokerStreams({}), "cameraStreams.getBrokerStreams", log);
40002
+ const camStreams = await probe(() => proxy.cameraStreams?.getCameraStreams({}), "cameraStreams.getCameraStreams", log);
40003
+ const choices = await probe(() => proxy.webrtcSession?.listStreams({}), "webrtcSession.listStreams", log);
40004
+ const fpsByProfile = resolveProfileFps({
40005
+ choices: choices ?? [],
40006
+ slots: slots ?? [],
40007
+ camStreams: camStreams ?? []
40008
+ });
40009
+ const resolutions = deriveAdvertisedResolutions({
40010
+ candidates: CANDIDATE_RESOLUTIONS,
40011
+ entries: entries ?? [],
40012
+ deviceId: numericDeviceId,
40013
+ pref: options.hapDeviceSettings.streamPreference,
40014
+ fpsByProfile
40015
+ });
40016
+ const assumed = resolutions.filter((r) => r.source === "assumed").length;
40017
+ log.info("export-hap: advertised video profile derived", { meta: {
40018
+ streamPreference: options.hapDeviceSettings.streamPreference,
40019
+ resolutions: formatAdvertisedResolutions(resolutions),
40020
+ profileFps: [...fpsByProfile.values()].map((f) => `${f.profile}=${f.fps}(${f.source})`),
40021
+ assumedCount: assumed,
40022
+ previouslyAdvertisedFps: 30
40023
+ } });
40024
+ if (assumed === resolutions.length) log.warn("export-hap: no measured or published frame rate for ANY profile — advertising the assumed rate", { meta: {
40025
+ entries: entries?.length ?? 0,
40026
+ choices: choices?.length ?? 0
40027
+ } });
40028
+ return {
40029
+ resolutions,
40030
+ fpsByProfile
40031
+ };
40032
+ }
40033
+ /**
40034
+ * Run one cap read. Returns null both when the cap is not bound and when the
40035
+ * call throws — and LOGS which, because "the camera has no telemetry" and "the
40036
+ * telemetry call failed" lead to different fixes.
40037
+ */
40038
+ async function probe(call, label, log) {
40039
+ try {
40040
+ const pending = call();
40041
+ if (pending === void 0) {
40042
+ log.info("export-hap: fps probe skipped — cap not bound on this device", { meta: { call: label } });
40043
+ return null;
40044
+ }
40045
+ return await pending;
40046
+ } catch (err) {
40047
+ log.warn("export-hap: fps probe failed — falling back to a lower-authority source", { meta: {
40048
+ call: label,
40049
+ error: err instanceof Error ? err.message : String(err)
40050
+ } });
40051
+ return null;
40052
+ }
40053
+ }
40054
+ //#endregion
39455
40055
  //#region src/mappers/builders/child-switch.ts
39456
40056
  /**
39457
40057
  * Child-switch builder — turns a camstack accessory child device (siren,
@@ -39678,24 +40278,18 @@ async function buildCameraAccessory(input) {
39678
40278
  displayName,
39679
40279
  options
39680
40280
  };
39681
- const streams = buildCameraStreamingDelegate(bctx);
40281
+ const streams = buildCameraStreamingDelegate(bctx, await probeAdvertisedVideoProfile(bctx));
39682
40282
  const handles = [];
39683
40283
  if (capNames.has("intercom")) handles.push(await buildIntercom({
39684
40284
  bctx,
39685
40285
  streamingOptions: streams.streamingOptions
39686
40286
  }));
39687
- const hksv = capNames.has("motion-detection") ? buildHksvStub(bctx) : null;
39688
40287
  const controller = new (isDoorbell ? DoorbellController : CameraController)({
39689
40288
  delegate: streams.delegate,
39690
40289
  streamingOptions: streams.streamingOptions,
39691
- cameraStreamCount: 2,
39692
- ...hksv ? { recording: {
39693
- options: hksv.options,
39694
- delegate: hksv.delegate
39695
- } } : {}
40290
+ cameraStreamCount: 2
39696
40291
  });
39697
40292
  accessory.configureController(controller);
39698
- if (hksv) handles.push(hksv.handle);
39699
40293
  if (capNames.has("motion-detection")) handles.push(await buildMotionSensor(bctx));
39700
40294
  if (isDoorbell && controller instanceof DoorbellController) handles.push(await buildDoorbell({
39701
40295
  bctx,