@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.
@@ -14727,6 +14727,70 @@ var EventPruneCountsSchema = object({
14727
14727
  object: number().int(),
14728
14728
  audio: number().int()
14729
14729
  });
14730
+ /**
14731
+ * Re-embed stored tracks from their key frames.
14732
+ *
14733
+ * The reason this is an operator-callable method and not a migration script:
14734
+ * every knob that decides what a vector MEANS — encoder model, crop margin,
14735
+ * squaring — is only changeable if the existing vectors can be regenerated.
14736
+ * Mixing feature spaces in one index makes cosine scores incomparable, and the
14737
+ * symptom is a quality regression with no visible cause.
14738
+ */
14739
+ var RebuildObjectEmbeddingsInput = object({
14740
+ /** Restrict to one camera. Omit for the whole fleet. */
14741
+ deviceId: number().optional(),
14742
+ since: number().optional(),
14743
+ until: number().optional(),
14744
+ /** Stop after this many tracks; the result reports whether more remain. */
14745
+ maxTracks: number().int().positive().optional()
14746
+ });
14747
+ /**
14748
+ * Result of emptying the CLIP index.
14749
+ *
14750
+ * The clean slate before a policy change: a new crop margin or encoder model
14751
+ * leaves two feature spaces in one index whose cosine scores are not
14752
+ * comparable, so wiping and rebuilding is the only way to be sure every vector
14753
+ * means the same thing.
14754
+ */
14755
+ var WipeObjectEmbeddingsResultSchema = object({ deleted: number() });
14756
+ /**
14757
+ * Acknowledgement that a rebuild STARTED.
14758
+ *
14759
+ * The pass costs ~1s per track — 45 minutes for a 2,600-track fleet — so it
14760
+ * runs detached and this returns immediately. Waiting for it made the client
14761
+ * time out while the work carried on server-side, which is the worst of both:
14762
+ * no result and no way to know it was still going. Poll
14763
+ * `getObjectEmbeddingRebuildStatus` for progress.
14764
+ */
14765
+ var RebuildObjectEmbeddingsResultSchema = object({
14766
+ started: boolean(),
14767
+ /** True when a pass was already running; the new request is ignored. */
14768
+ alreadyRunning: boolean()
14769
+ });
14770
+ var RebuildStatusSchema = object({
14771
+ running: boolean(),
14772
+ scanned: number(),
14773
+ rebuilt: number(),
14774
+ /** Tracks whose key frame is gone — nothing to re-embed from. */
14775
+ missingKeyFrame: number(),
14776
+ /** Tracks with no usable detection box. */
14777
+ missingBbox: number(),
14778
+ /**
14779
+ * Tracks the pipeline REFUSED rather than broke on: the camera is not
14780
+ * attached, or `clip-embedding` is not enabled in its step tree. Separate
14781
+ * from `failed` because the remedy is a configuration change, not an engine
14782
+ * investigation — and because a pass over decommissioned cameras would
14783
+ * otherwise read as a total engine outage.
14784
+ */
14785
+ notRunnable: number(),
14786
+ failed: number(),
14787
+ /** Set once a pass ends: true only when EVERYTHING was covered. */
14788
+ complete: boolean().nullable(),
14789
+ startedAtMs: number().nullable(),
14790
+ finishedAtMs: number().nullable(),
14791
+ /** Present when the pass ended by throwing. */
14792
+ error: string().nullable()
14793
+ });
14730
14794
  DeviceType.Camera, method(object({ deviceId: number() }), array(TrackSchema).readonly()), method(object({
14731
14795
  deviceId: number(),
14732
14796
  trackId: string()
@@ -14820,7 +14884,13 @@ DeviceType.Camera, method(object({ deviceId: number() }), array(TrackSchema).rea
14820
14884
  }), array(MediaFileSchema).readonly()), method(object({
14821
14885
  trackId: string(),
14822
14886
  kinds: array(MediaFileKindEnum).optional()
14823
- }), array(MediaFileSchema).readonly()), method(object({ trackId: string() }), array(MediaFileInfoSchema).readonly()), method(SearchObjectEventsInput, array(ScoredObjectEventSchema).readonly()), object({
14887
+ }), array(MediaFileSchema).readonly()), method(object({ trackId: string() }), array(MediaFileInfoSchema).readonly()), method(SearchObjectEventsInput, array(ScoredObjectEventSchema).readonly()), method(object({}), WipeObjectEmbeddingsResultSchema, {
14888
+ kind: "mutation",
14889
+ auth: "admin"
14890
+ }), method(RebuildObjectEmbeddingsInput, RebuildObjectEmbeddingsResultSchema, {
14891
+ kind: "mutation",
14892
+ auth: "admin"
14893
+ }), method(object({}), RebuildStatusSchema), object({
14824
14894
  deviceId: number(),
14825
14895
  timestamp: number(),
14826
14896
  frameWidth: number(),
@@ -15729,6 +15799,17 @@ method(RunnerCameraConfigSchema, object({ success: literal(true) }), { kind: "mu
15729
15799
  }), NativeCropResultSchema.nullable()), method(object({
15730
15800
  deviceId: number(),
15731
15801
  frameHandle: FrameHandleSchema.optional(),
15802
+ /**
15803
+ * FULL FRAME (base64 JPEG). The runner derives the crop rectangle from
15804
+ * `parent.bbox` with the cluster crop convention and cuts it itself —
15805
+ * do NOT pre-crop for this field, that is what `cropJpeg` is.
15806
+ */
15807
+ frameJpeg: string().optional(),
15808
+ /**
15809
+ * PRE-CUT tile (base64 JPEG), used verbatim — NO padding is applied.
15810
+ * The fallback when the lease/session backing the frame is gone and the
15811
+ * caller already holds a crop.
15812
+ */
15732
15813
  cropJpeg: string().optional(),
15733
15814
  parent: DetailParentSchema,
15734
15815
  steps: array(string()).optional()
@@ -17054,6 +17135,24 @@ var VectorDeleteByFilterInputSchema = object({
17054
17135
  filter: VectorFilterSchema
17055
17136
  });
17056
17137
  var VectorDeleteResultSchema = object({ deleted: number() });
17138
+ var VectorGetInputSchema = object({
17139
+ index: string(),
17140
+ ids: array(string())
17141
+ });
17142
+ /**
17143
+ * Metadata for the requested ids, WITHOUT their vectors.
17144
+ *
17145
+ * The only caller is a best-of gate that compares a candidate's confidence
17146
+ * against the stored one, and shipping 512 floats back to answer "is 0.91 >
17147
+ * 0.87" would undo the point of the compact encoding. Ids with no row are
17148
+ * simply absent — a caller distinguishing "not stored" from "stored" reads the
17149
+ * length, and a null placeholder would invite a `?? 0` that treats a missing
17150
+ * row as confidence zero.
17151
+ */
17152
+ var VectorGetResultSchema = object({ items: array(object({
17153
+ id: string(),
17154
+ metadata: VectorMetadataSchema
17155
+ })) });
17057
17156
  var VectorStatsInputSchema = object({ index: string() });
17058
17157
  var VectorStatsResultSchema = object({
17059
17158
  /** Provider id, so an operator can tell brute force from an ANN index. */
@@ -17066,7 +17165,7 @@ var VectorStatsResultSchema = object({
17066
17165
  /** False when the backend ranks approximately. */
17067
17166
  exact: boolean()
17068
17167
  });
17069
- 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);
17168
+ 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);
17070
17169
  /**
17071
17170
  * `videoclips` — the unified, navigable-clip surface for a camera.
17072
17171
  *
@@ -25329,6 +25428,12 @@ Object.freeze({
25329
25428
  addonId: null,
25330
25429
  access: "view"
25331
25430
  },
25431
+ "pipelineAnalytics.getObjectEmbeddingRebuildStatus": {
25432
+ capName: "pipeline-analytics",
25433
+ capScope: "device",
25434
+ addonId: null,
25435
+ access: "view"
25436
+ },
25332
25437
  "pipelineAnalytics.getObjectEvents": {
25333
25438
  capName: "pipeline-analytics",
25334
25439
  capScope: "device",
@@ -25407,6 +25512,12 @@ Object.freeze({
25407
25512
  addonId: null,
25408
25513
  access: "create"
25409
25514
  },
25515
+ "pipelineAnalytics.rebuildObjectEmbeddings": {
25516
+ capName: "pipeline-analytics",
25517
+ capScope: "device",
25518
+ addonId: null,
25519
+ access: "create"
25520
+ },
25410
25521
  "pipelineAnalytics.relocateMedia": {
25411
25522
  capName: "pipeline-analytics",
25412
25523
  capScope: "device",
@@ -25425,6 +25536,12 @@ Object.freeze({
25425
25536
  addonId: null,
25426
25537
  access: "delete"
25427
25538
  },
25539
+ "pipelineAnalytics.wipeObjectEmbeddings": {
25540
+ capName: "pipeline-analytics",
25541
+ capScope: "device",
25542
+ addonId: null,
25543
+ access: "delete"
25544
+ },
25428
25545
  "pipelineExecutor.cacheFrameInPool": {
25429
25546
  capName: "pipeline-executor",
25430
25547
  capScope: "system",
@@ -27345,6 +27462,12 @@ Object.freeze({
27345
27462
  addonId: null,
27346
27463
  access: "delete"
27347
27464
  },
27465
+ "vectorStore.getByIds": {
27466
+ capName: "vector-store",
27467
+ capScope: "system",
27468
+ addonId: null,
27469
+ access: "view"
27470
+ },
27348
27471
  "vectorStore.query": {
27349
27472
  capName: "vector-store",
27350
27473
  capScope: "system",
@@ -27619,6 +27742,30 @@ TimelapseRuleInputSchema.extend({
27619
27742
  createdAt: number(),
27620
27743
  updatedAt: number()
27621
27744
  });
27745
+ object({
27746
+ /**
27747
+ * Fraction of the box's own size added on EACH side before cutting.
27748
+ *
27749
+ * CLIP is trained on natural images WITH surroundings; a pixel-tight crop
27750
+ * removes exactly the context it is strongest on (a dog cut to its outline
27751
+ * is a dark blob). The right value is an empirical question, which is why it
27752
+ * is a setting: 0 / 0.15 / 0.2 / 0.5 are the interesting points.
27753
+ */
27754
+ paddingRatio: number().min(0).max(4),
27755
+ /**
27756
+ * Square the window (in PIXELS) before cutting.
27757
+ *
27758
+ * CLIP's input is square, so a tall bbox resized straight to NxN is squashed
27759
+ * — a standing person becomes a shape the model never saw. Squaring costs
27760
+ * extra background, which is context the model wants anyway. Off by default
27761
+ * because the live path has never squared and the stored index reflects that.
27762
+ */
27763
+ square: boolean()
27764
+ });
27765
+ ({
27766
+ paddingRatio: .15,
27767
+ square: false
27768
+ }).paddingRatio;
27622
27769
  /**
27623
27770
  * Deterministic SHA-256 hash of an arbitrary serialisable value. The
27624
27771
  * canonical form sorts object keys alphabetically at every depth so two
@@ -38175,13 +38322,305 @@ var require_src = /* @__PURE__ */ __commonJSMin(((exports) => {
38175
38322
  __exportStar(require_util(), exports);
38176
38323
  }));
38177
38324
  //#endregion
38178
- //#region src/mappers/builders/camera-streams.ts
38325
+ //#region src/mappers/builders/rtcp-gate.ts
38179
38326
  var import_src = require_src();
38327
+ /** `packet@42ms` / `timeout@1000ms` — one compact log field. */
38328
+ function formatRtcpGate(resolution) {
38329
+ if (resolution === null) return "pending";
38330
+ return `${resolution.reason}@${resolution.waitedMs}ms`;
38331
+ }
38332
+ /**
38333
+ * Resolve as soon as ANY packet arrives on `socket`, or after `timeoutMs`.
38334
+ * One-shot: the listener is detached on either path.
38335
+ */
38336
+ function makeRtcpGate(socket, timeoutMs) {
38337
+ const armedAt = Date.now();
38338
+ return new Promise((resolve) => {
38339
+ const onMessage = () => {
38340
+ socket.removeListener("message", onMessage);
38341
+ clearTimeout(timer);
38342
+ resolve({
38343
+ reason: "packet",
38344
+ waitedMs: Date.now() - armedAt
38345
+ });
38346
+ };
38347
+ const timer = setTimeout(() => {
38348
+ socket.removeListener("message", onMessage);
38349
+ resolve({
38350
+ reason: "timeout",
38351
+ waitedMs: Date.now() - armedAt
38352
+ });
38353
+ }, timeoutMs);
38354
+ socket.on("message", onMessage);
38355
+ });
38356
+ }
38357
+ /**
38358
+ * The resolutions we offer, before rates are attached. Same list the delegate
38359
+ * advertised before R2 — only the frame rate changes, so a controller that had
38360
+ * already negotiated a resolution keeps finding it.
38361
+ */
38362
+ var CANDIDATE_RESOLUTIONS = [
38363
+ [1920, 1080],
38364
+ [1280, 720],
38365
+ [1024, 768],
38366
+ [640, 480],
38367
+ [640, 360],
38368
+ [480, 360],
38369
+ [480, 270],
38370
+ [320, 240],
38371
+ [320, 180]
38372
+ ];
38373
+ /**
38374
+ * Resolve the real frame rate of each profile slot.
38375
+ *
38376
+ * A slot with no measurement, no publication and no assignment simply does not
38377
+ * appear in the map — the caller then advertises {@link ASSUMED_FPS} and says
38378
+ * so. Absence is never silently rendered as a number.
38379
+ */
38380
+ function resolveProfileFps(input) {
38381
+ const publishedByProfile = publishedFpsByProfile(input.slots, input.camStreams);
38382
+ const out = /* @__PURE__ */ new Map();
38383
+ for (const choice of input.choices) {
38384
+ if (choice.target.kind !== "profile") continue;
38385
+ const profile = choice.target.profile;
38386
+ const measured = clampFps(choice.inputFps);
38387
+ if (measured !== null) {
38388
+ out.set(profile, {
38389
+ profile,
38390
+ fps: measured,
38391
+ source: "measured"
38392
+ });
38393
+ continue;
38394
+ }
38395
+ const published = clampFps(publishedByProfile.get(profile) ?? null);
38396
+ out.set(profile, published !== null ? {
38397
+ profile,
38398
+ fps: published,
38399
+ source: "published"
38400
+ } : {
38401
+ profile,
38402
+ fps: 30,
38403
+ source: "assumed"
38404
+ });
38405
+ }
38406
+ for (const [profile, fps] of publishedByProfile) {
38407
+ if (out.has(profile)) continue;
38408
+ const published = clampFps(fps);
38409
+ if (published !== null) out.set(profile, {
38410
+ profile,
38411
+ fps: published,
38412
+ source: "published"
38413
+ });
38414
+ }
38415
+ return out;
38416
+ }
38417
+ /**
38418
+ * Attach a frame rate to each candidate resolution by asking the REAL picker
38419
+ * which slot the START path would dial for it. That coupling is the point: if
38420
+ * the picker's steering changes, the advertisement changes with it instead of
38421
+ * drifting into a second, silently different opinion.
38422
+ */
38423
+ function deriveAdvertisedResolutions(input) {
38424
+ const candidates = input.candidates.length > 0 ? input.candidates : CANDIDATE_RESOLUTIONS;
38425
+ const seen = /* @__PURE__ */ new Set();
38426
+ const out = [];
38427
+ for (const [width, height] of candidates) {
38428
+ const picked = pickPreferredRtspEntry(input.entries, input.pref, input.deviceId, { targetResolution: {
38429
+ width,
38430
+ height
38431
+ } });
38432
+ const profile = picked === null ? null : toCamProfile(picked.profileId);
38433
+ const resolved = profile === null ? void 0 : input.fpsByProfile.get(profile);
38434
+ const advertised = {
38435
+ width,
38436
+ height,
38437
+ fps: resolved?.fps ?? 30,
38438
+ profile,
38439
+ source: resolved?.source ?? "assumed"
38440
+ };
38441
+ const key = `${advertised.width}x${advertised.height}@${advertised.fps}`;
38442
+ if (seen.has(key)) continue;
38443
+ seen.add(key);
38444
+ out.push(advertised);
38445
+ }
38446
+ return out;
38447
+ }
38448
+ /** Project onto the `[width, height, fps]` triples hap-nodejs expects. */
38449
+ function toHapResolutions(advertised) {
38450
+ return advertised.map((a) => [
38451
+ a.width,
38452
+ a.height,
38453
+ a.fps
38454
+ ]);
38455
+ }
38456
+ /** Compact `1280x720@10(measured)` rendering for a single log field. */
38457
+ function formatAdvertisedResolutions(advertised) {
38458
+ return advertised.map((a) => `${a.width}x${a.height}@${a.fps}/${a.profile ?? "-"}(${a.source})`);
38459
+ }
38460
+ function publishedFpsByProfile(slots, camStreams) {
38461
+ const fpsByCamStream = /* @__PURE__ */ new Map();
38462
+ for (const stream of camStreams) if (typeof stream.fps === "number") fpsByCamStream.set(stream.camStreamId, stream.fps);
38463
+ const out = /* @__PURE__ */ new Map();
38464
+ for (const slot of slots) {
38465
+ if (slot.sourceCamStreamId === null) continue;
38466
+ const fps = fpsByCamStream.get(slot.sourceCamStreamId);
38467
+ if (fps !== void 0) out.set(slot.profile, fps);
38468
+ }
38469
+ return out;
38470
+ }
38471
+ /**
38472
+ * Coerce a probe reading into an advertisable integer rate, or null when the
38473
+ * reading carries no information (absent, zero because the broker is idle,
38474
+ * negative, NaN, or below the representable floor).
38475
+ */
38476
+ function clampFps(value) {
38477
+ if (typeof value !== "number" || !Number.isFinite(value)) return null;
38478
+ const floored = Math.floor(value);
38479
+ if (floored < 1) return null;
38480
+ return Math.min(floored, 30);
38481
+ }
38482
+ var CAM_PROFILES = [
38483
+ "high",
38484
+ "mid",
38485
+ "low"
38486
+ ];
38487
+ /**
38488
+ * `PickedStream.profileId` is the brokerId suffix, which for a profile-keyed
38489
+ * entry IS the profile name. Anything else (a raw cam-stream id) is not a
38490
+ * profile and must not be coerced into one.
38491
+ */
38492
+ function toCamProfile(profileId) {
38493
+ return CAM_PROFILES.find((p) => p === profileId) ?? null;
38494
+ }
38495
+ //#endregion
38496
+ //#region src/mappers/builders/stream-telemetry.ts
38497
+ /**
38498
+ * Every branch on the streaming path that discards work, and its starting
38499
+ * count. This object is the single source of the reason set: the union below
38500
+ * is its `keyof`, so adding a silent `return` without adding a reason here is
38501
+ * a compile error rather than an invisible hole.
38502
+ */
38503
+ var ZERO_DROP_COUNTERS = {
38504
+ /** Outbound: the leg has no SRTP session (prepareStream init failed). */
38505
+ "no-srtp-session": 0,
38506
+ /** Outbound: the leg has no return-path gate — forwarding was never armed. */
38507
+ "no-gate": 0,
38508
+ /** Outbound: werift refused to encrypt the packet. */
38509
+ "srtp-encrypt-failed": 0,
38510
+ /** Outbound: the UDP send itself errored. */
38511
+ "srtp-send-failed": 0,
38512
+ /** Outbound RTCP: the leg has no SRTCP context, so no Sender Report can go out. */
38513
+ "rtcp-no-srtcp": 0,
38514
+ /** Outbound RTCP: building or encrypting the Sender Report failed. */
38515
+ "rtcp-encrypt-failed": 0,
38516
+ /** Upstream: packet shorter than an RTP header. */
38517
+ "upstream-short-packet": 0,
38518
+ /** Upstream: no inbound SRTP context (init failed at prepareStream). */
38519
+ "upstream-no-srtp": 0,
38520
+ /** Upstream: SRTP authentication/decrypt failed. */
38521
+ "upstream-decrypt-failed": 0,
38522
+ /** Upstream: decrypted bytes did not parse as RTP. */
38523
+ "upstream-parse-failed": 0,
38524
+ /** Upstream: audio arrived before `handleStreamRequest('start')` settled. */
38525
+ "upstream-before-start": 0,
38526
+ /** Upstream: payload type is not the negotiated audio PT (RTCP / keepalive). */
38527
+ "upstream-pt-mismatch": 0,
38528
+ /** Upstream: RTP carried a zero-length payload. */
38529
+ "upstream-empty-payload": 0,
38530
+ /** Upstream: no camera-side talk session, so the frame has nowhere to go. */
38531
+ "upstream-no-talk-session": 0,
38532
+ /** Upstream: the camera-side `pushTalkAudio` rejected the frame. */
38533
+ "upstream-push-failed": 0,
38534
+ /** Snapshot: the `snapshot` cap returned nothing. */
38535
+ "snapshot-unavailable": 0
38536
+ };
38537
+ function emptyDropCounters() {
38538
+ return { ...ZERO_DROP_COUNTERS };
38539
+ }
38540
+ /** Immutable increment — returns a new record, never touches the input. */
38541
+ function recordDrop(counters, reason) {
38542
+ return {
38543
+ ...counters,
38544
+ [reason]: counters[reason] + 1
38545
+ };
38546
+ }
38547
+ /** Only the reasons that actually fired, so a clean session logs `{}`. */
38548
+ function nonZeroDrops(counters) {
38549
+ const out = {};
38550
+ for (const [reason, count] of Object.entries(counters)) if (count > 0) out[reason] = count;
38551
+ return out;
38552
+ }
38553
+ /**
38554
+ * Classify a packet received on one of our advertised return ports.
38555
+ *
38556
+ * RTP and RTCP arrive on the same symmetric port here. RTCP packet types are
38557
+ * 200..207, which sit inside the 192..223 band RFC 5761 §4 reserves precisely
38558
+ * so the two can be told apart on a shared socket (an RTP packet's marker bit
38559
+ * plus payload type can never land there for any payload type we negotiate).
38560
+ * The payloads are SRTP/SRTCP-encrypted, but both keep the first two bytes in
38561
+ * the clear.
38562
+ */
38563
+ function classifyInboundPacket(packet) {
38564
+ if (packet.length < 2) return "malformed";
38565
+ if ((packet[0] >> 6 & 3) !== 2) return "malformed";
38566
+ const typeByte = packet[1];
38567
+ if (typeByte >= 192 && typeByte <= 223) return "rtcp";
38568
+ return "rtp";
38569
+ }
38570
+ /**
38571
+ * Build the meta for `export-hap: stream session summary` — the single line a
38572
+ * future session greps to answer "why did this session die".
38573
+ */
38574
+ function summariseSession(snapshot) {
38575
+ const durationMs = snapshot.startedAtMs !== null && snapshot.endedAtMs !== null ? snapshot.endedAtMs - snapshot.startedAtMs : null;
38576
+ const negotiated = snapshot.negotiated;
38577
+ const slot = snapshot.selectedSlot;
38578
+ return {
38579
+ sessionId: snapshot.sessionId,
38580
+ durationMs,
38581
+ stopRequestedByController: snapshot.stopRequestedByController,
38582
+ ffmpegExitCode: snapshot.ffmpegExit?.code ?? null,
38583
+ ffmpegExitSignal: snapshot.ffmpegExit?.signal ?? null,
38584
+ negotiatedResolution: negotiated ? `${negotiated.width}x${negotiated.height}` : null,
38585
+ negotiatedFps: negotiated?.fps ?? null,
38586
+ negotiatedMaxBitrateKbps: negotiated?.maxBitrateKbps ?? null,
38587
+ selectedProfile: slot?.profile ?? null,
38588
+ selectedBrokerId: slot?.brokerId ?? null,
38589
+ advertisedFps: slot?.advertisedFps ?? null,
38590
+ advertisedFpsSource: slot?.advertisedFpsSource ?? null,
38591
+ transcode: slot?.transcode ?? null,
38592
+ videoPacketsForwarded: snapshot.videoPacketsForwarded,
38593
+ audioPacketsForwarded: snapshot.audioPacketsForwarded,
38594
+ videoRtcpSrSent: snapshot.videoRtcpSrSent,
38595
+ audioRtcpSrSent: snapshot.audioRtcpSrSent,
38596
+ videoRtcpReceived: snapshot.videoRtcpReceived,
38597
+ audioRtcpReceived: snapshot.audioRtcpReceived,
38598
+ videoRtpReceived: snapshot.videoRtpReceived,
38599
+ audioRtpReceived: snapshot.audioRtpReceived,
38600
+ videoGate: formatRtcpGate(snapshot.videoGate),
38601
+ audioGate: formatRtcpGate(snapshot.audioGate),
38602
+ mediaStarved: snapshot.videoPacketsForwarded === 0,
38603
+ drops: nonZeroDrops(snapshot.drops)
38604
+ };
38605
+ }
38606
+ //#endregion
38607
+ //#region src/mappers/builders/camera-streams.ts
38180
38608
  var SRTP_KEY_LEN = 16;
38181
38609
  var SRTP_SALT_LEN = 14;
38610
+ /**
38611
+ * Cadence of the per-session heartbeat log.
38612
+ *
38613
+ * This timer LOGS ONLY. It never kills ffmpeg, never closes a socket and never
38614
+ * ends a session. That distinction matters: the two mainstream open-source
38615
+ * HomeKit camera stacks arm an idle watchdog that tears the stream down at
38616
+ * exactly 30 000 ms, and the ~31 s teardown here was misdiagnosed as one for a
38617
+ * long time before it was proven that this accessory has no such timer. Do not
38618
+ * make this one act.
38619
+ */
38620
+ var SESSION_HEARTBEAT_MS = 5e3;
38182
38621
  var OPUS_BITRATE_KBPS = 24;
38183
38622
  var OPUS_CHANNELS = 1;
38184
- function buildCameraStreamingDelegate(bctx) {
38623
+ function buildCameraStreamingDelegate(bctx, advertised) {
38185
38624
  const { ctx, numericDeviceId } = bctx;
38186
38625
  const log = ctx.logger.withTags({ deviceId: numericDeviceId });
38187
38626
  const sessions = /* @__PURE__ */ new Map();
@@ -38200,7 +38639,7 @@ function buildCameraStreamingDelegate(bctx) {
38200
38639
  });
38201
38640
  },
38202
38641
  handleStreamRequest(request, callback) {
38203
- handleStreamRequest(request, sessions, bctx).then(() => callback()).catch((err) => {
38642
+ handleStreamRequest(request, sessions, bctx, advertised).then(() => callback()).catch((err) => {
38204
38643
  log.warn("export-hap: handleStreamRequest failed", { meta: { error: errMsg$8(err) } });
38205
38644
  callback(err instanceof Error ? err : new Error(errMsg$8(err)));
38206
38645
  });
@@ -38221,58 +38660,7 @@ function buildCameraStreamingDelegate(bctx) {
38221
38660
  _homebridge_hap_nodejs.H264Level.LEVEL4_0
38222
38661
  ]
38223
38662
  },
38224
- resolutions: [
38225
- [
38226
- 1920,
38227
- 1080,
38228
- 30
38229
- ],
38230
- [
38231
- 1280,
38232
- 720,
38233
- 30
38234
- ],
38235
- [
38236
- 1024,
38237
- 768,
38238
- 30
38239
- ],
38240
- [
38241
- 640,
38242
- 480,
38243
- 30
38244
- ],
38245
- [
38246
- 640,
38247
- 360,
38248
- 30
38249
- ],
38250
- [
38251
- 480,
38252
- 360,
38253
- 30
38254
- ],
38255
- [
38256
- 480,
38257
- 270,
38258
- 30
38259
- ],
38260
- [
38261
- 320,
38262
- 240,
38263
- 30
38264
- ],
38265
- [
38266
- 320,
38267
- 240,
38268
- 15
38269
- ],
38270
- [
38271
- 320,
38272
- 180,
38273
- 30
38274
- ]
38275
- ]
38663
+ resolutions: toHapResolutions(advertised.resolutions)
38276
38664
  },
38277
38665
  audio: {
38278
38666
  codecs: [{
@@ -38287,10 +38675,16 @@ function buildCameraStreamingDelegate(bctx) {
38287
38675
  },
38288
38676
  dispose: async () => {
38289
38677
  for (const session of sessions.values()) {
38678
+ session.teardownTrigger = "accessory-dispose";
38679
+ const hadFfmpeg = session.ffmpeg !== null;
38290
38680
  killFfmpeg(session, ctx, numericDeviceId);
38681
+ stopHeartbeat(session);
38682
+ if (!hadFfmpeg) logSessionSummary(session, log, "accessory-dispose-no-ffmpeg");
38291
38683
  await closeIntercomTalkSession(session, bctx).catch(() => void 0);
38292
38684
  closeSocket(session.videoUdp);
38293
38685
  closeSocket(session.audioUdp);
38686
+ closeSocket(session.videoLoopUdp);
38687
+ closeSocket(session.audioLoopUdp);
38294
38688
  }
38295
38689
  sessions.clear();
38296
38690
  }
@@ -38300,7 +38694,10 @@ async function handleSnapshot(bctx, request) {
38300
38694
  const { proxy, ctx, numericDeviceId } = bctx;
38301
38695
  const result = await proxy.snapshot?.getSnapshot({});
38302
38696
  if (!result || typeof result.base64 !== "string") {
38303
- ctx.logger.withTags({ deviceId: numericDeviceId }).debug("export-hap: snapshot returned null");
38697
+ ctx.logger.withTags({ deviceId: numericDeviceId }).info("export-hap: snapshot dropped", { meta: {
38698
+ reason: "snapshot-unavailable",
38699
+ capBound: proxy.snapshot !== void 0
38700
+ } });
38304
38701
  throw new Error("snapshot unavailable");
38305
38702
  }
38306
38703
  return Buffer.from(result.base64, "base64");
@@ -38372,6 +38769,26 @@ async function prepareStream(request, sessions, bctx) {
38372
38769
  const videoSsrc = randomSsrc();
38373
38770
  const audioSsrc = randomSsrc();
38374
38771
  const session = {
38772
+ sessionId: request.sessionID,
38773
+ startedAtMs: null,
38774
+ endedAtMs: null,
38775
+ negotiated: null,
38776
+ selectedSlot: null,
38777
+ ffmpegExit: null,
38778
+ stopRequestedByController: false,
38779
+ teardownTrigger: null,
38780
+ drops: emptyDropCounters(),
38781
+ videoPacketsForwarded: 0,
38782
+ audioPacketsForwarded: 0,
38783
+ videoRtcpSrSent: 0,
38784
+ audioRtcpSrSent: 0,
38785
+ videoRtcpReceived: 0,
38786
+ audioRtcpReceived: 0,
38787
+ videoRtpReceived: 0,
38788
+ audioRtpReceived: 0,
38789
+ videoGate: null,
38790
+ audioGate: null,
38791
+ heartbeat: null,
38375
38792
  hapVideoPort: request.video.port,
38376
38793
  hapAddress: request.targetAddress,
38377
38794
  videoSrtpKey: request.video.srtp_key,
@@ -38416,19 +38833,33 @@ async function prepareStream(request, sessions, bctx) {
38416
38833
  audioRtcpIntervalMs: 5e3
38417
38834
  };
38418
38835
  sessions.set(request.sessionID, session);
38836
+ const tagLog = bctx.ctx.logger.withTags({ deviceId: bctx.numericDeviceId });
38419
38837
  session.videoSendGate = makeRtcpGate(videoUdp, 1e3);
38420
38838
  session.audioSendGate = makeRtcpGate(audioUdp, 1e3);
38421
- const tagLog = bctx.ctx.logger.withTags({ deviceId: bctx.numericDeviceId });
38422
- let videoPacketsForwarded = 0;
38423
- let audioPacketsForwarded = 0;
38839
+ session.videoSendGate.then((resolution) => {
38840
+ session.videoGate = resolution;
38841
+ logGateResolved(session, "video", resolution, tagLog);
38842
+ });
38843
+ session.audioSendGate.then((resolution) => {
38844
+ session.audioGate = resolution;
38845
+ logGateResolved(session, "audio", resolution, tagLog);
38846
+ });
38847
+ videoUdp.on("message", (packet) => {
38848
+ countInbound(session, "video", packet, tagLog);
38849
+ });
38850
+ audioUdp.on("message", (packet) => {
38851
+ countInbound(session, "audio", packet, tagLog);
38852
+ });
38424
38853
  videoLoopUdp.on("message", (rtpPacket) => {
38425
- videoPacketsForwarded += 1;
38426
- if (videoPacketsForwarded === 1 || videoPacketsForwarded % 500 === 0) tagLog.info("export-hap: video loopback packets forwarded", { meta: { count: videoPacketsForwarded } });
38854
+ session.videoPacketsForwarded += 1;
38855
+ if (session.videoPacketsForwarded === 1) tagLog.info("export-hap: first video packet from ffmpeg", { meta: {
38856
+ sessionId: session.sessionId,
38857
+ bytes: rtpPacket.length
38858
+ } });
38427
38859
  forwardEncryptedRtp(session, rtpPacket, "video", tagLog);
38428
38860
  });
38429
38861
  audioLoopUdp.on("message", (rtpPacket) => {
38430
- audioPacketsForwarded += 1;
38431
- if (audioPacketsForwarded === 1 || audioPacketsForwarded % 100 === 0) tagLog.info("export-hap: audio loopback packets forwarded", { meta: { count: audioPacketsForwarded } });
38862
+ session.audioPacketsForwarded += 1;
38432
38863
  forwardEncryptedRtp(session, rtpPacket, "audio", tagLog);
38433
38864
  });
38434
38865
  audioUdp.on("message", (packet, rinfo) => {
@@ -38436,6 +38867,19 @@ async function prepareStream(request, sessions, bctx) {
38436
38867
  bctx.ctx.logger.withTags({ deviceId: bctx.numericDeviceId }).debug("export-hap: incoming-audio handler error (dropped)", { meta: { error: errMsg$8(err) } });
38437
38868
  });
38438
38869
  });
38870
+ tagLog.info("export-hap: stream prepared", { meta: {
38871
+ sessionId: request.sessionID,
38872
+ controllerAddress: request.targetAddress,
38873
+ addressVersion: ipVersion,
38874
+ localIp,
38875
+ advertisedVideoPort: localVideoPort,
38876
+ advertisedAudioPort: localAudioPort,
38877
+ controllerVideoPort: request.video.port,
38878
+ controllerAudioPort: request.audio.port,
38879
+ videoSsrc,
38880
+ audioSsrc,
38881
+ upstreamAudioDecrypt: upstreamAudioSrtp !== null
38882
+ } });
38439
38883
  return {
38440
38884
  video: {
38441
38885
  port: localVideoPort,
@@ -38510,30 +38954,110 @@ function sameIpv4Subnet(a, mask, b) {
38510
38954
  async function bindLoopback(ipVersion) {
38511
38955
  return bindUdp(ipVersion, ipVersion === "ipv6" ? "::1" : "127.0.0.1");
38512
38956
  }
38957
+ /** Book a named drop. Every silent `return` on the streaming path routes here. */
38958
+ function drop(session, reason) {
38959
+ session.drops = recordDrop(session.drops, reason);
38960
+ }
38513
38961
  /**
38514
- * Build a promise that resolves as soon as ANY packet (RTP or RTCP)
38515
- * arrives on `socket`, OR `timeoutMs` elapses. The loopback reader
38516
- * awaits this before forwarding the first encrypted SRTP frame so
38517
- * iOS sees a return-path handshake before our actual stream begins.
38518
- *
38519
- * One-shot: the listener is removed on the first message OR on
38520
- * timeout — the gate then stays resolved for the rest of the session.
38962
+ * Report a gate resolution. `timeout` is the interesting one: it means we are
38963
+ * about to stream into a port the controller never touched, and it was
38964
+ * indistinguishable from a successful probe until now.
38521
38965
  */
38522
- function makeRtcpGate(socket, timeoutMs) {
38523
- return new Promise((resolve) => {
38524
- const onMessage = () => {
38525
- socket.removeListener("message", onMessage);
38526
- clearTimeout(timer);
38527
- resolve();
38528
- };
38529
- const timer = setTimeout(() => {
38530
- socket.removeListener("message", onMessage);
38531
- resolve();
38532
- }, timeoutMs);
38533
- socket.on("message", onMessage);
38966
+ function logGateResolved(session, leg, resolution, log) {
38967
+ const meta = {
38968
+ sessionId: session.sessionId,
38969
+ leg,
38970
+ reason: resolution.reason,
38971
+ waitedMs: resolution.waitedMs
38972
+ };
38973
+ if (resolution.reason === "packet") log.info("export-hap: RTCP gate opened on a REAL controller packet", { meta });
38974
+ else log.warn("export-hap: RTCP gate opened on the 1s FALLBACK — controller never probed", { meta });
38975
+ }
38976
+ /** Count and classify one packet the controller sent to a return port. */
38977
+ function countInbound(session, leg, packet, log) {
38978
+ const kind = classifyInboundPacket(packet);
38979
+ const firstRtcp = kind === "rtcp" && (leg === "video" ? session.videoRtcpReceived : session.audioRtcpReceived) === 0;
38980
+ if (leg === "video") {
38981
+ if (kind === "rtcp") session.videoRtcpReceived += 1;
38982
+ else if (kind === "rtp") session.videoRtpReceived += 1;
38983
+ } else if (kind === "rtcp") session.audioRtcpReceived += 1;
38984
+ else if (kind === "rtp") session.audioRtpReceived += 1;
38985
+ if (firstRtcp) log.info("export-hap: first inbound RTCP from controller", { meta: {
38986
+ sessionId: session.sessionId,
38987
+ leg,
38988
+ bytes: packet.length
38989
+ } });
38990
+ }
38991
+ /** Snapshot every counter into the summary meta. */
38992
+ function sessionSummaryMeta(session) {
38993
+ return summariseSession({
38994
+ sessionId: session.sessionId,
38995
+ startedAtMs: session.startedAtMs,
38996
+ endedAtMs: session.endedAtMs,
38997
+ negotiated: session.negotiated,
38998
+ selectedSlot: session.selectedSlot,
38999
+ videoPacketsForwarded: session.videoPacketsForwarded,
39000
+ audioPacketsForwarded: session.audioPacketsForwarded,
39001
+ videoRtcpSrSent: session.videoRtcpSrSent,
39002
+ audioRtcpSrSent: session.audioRtcpSrSent,
39003
+ videoRtcpReceived: session.videoRtcpReceived,
39004
+ audioRtcpReceived: session.audioRtcpReceived,
39005
+ videoRtpReceived: session.videoRtpReceived,
39006
+ audioRtpReceived: session.audioRtpReceived,
39007
+ videoGate: session.videoGate,
39008
+ audioGate: session.audioGate,
39009
+ drops: session.drops,
39010
+ ffmpegExit: session.ffmpegExit,
39011
+ stopRequestedByController: session.stopRequestedByController
38534
39012
  });
38535
39013
  }
38536
39014
  /**
39015
+ * Arm the per-session heartbeat. LOGS ONLY — see `SESSION_HEARTBEAT_MS`. It
39016
+ * exists so a session that stops producing media says so while it is still
39017
+ * alive, instead of being reconstructed after the fact from its final line.
39018
+ */
39019
+ function armHeartbeat(session, log) {
39020
+ if (session.heartbeat) clearInterval(session.heartbeat);
39021
+ let lastVideo = session.videoPacketsForwarded;
39022
+ const timer = setInterval(() => {
39023
+ const forwarded = session.videoPacketsForwarded - lastVideo;
39024
+ lastVideo = session.videoPacketsForwarded;
39025
+ log.info("export-hap: stream heartbeat", { meta: {
39026
+ ...sessionSummaryMeta(session),
39027
+ videoPacketsSinceLastBeat: forwarded,
39028
+ videoStalled: forwarded === 0
39029
+ } });
39030
+ }, SESSION_HEARTBEAT_MS);
39031
+ timer.unref();
39032
+ session.heartbeat = timer;
39033
+ }
39034
+ function stopHeartbeat(session) {
39035
+ if (!session.heartbeat) return;
39036
+ clearInterval(session.heartbeat);
39037
+ session.heartbeat = null;
39038
+ }
39039
+ /**
39040
+ * **`export-hap: stream session summary` is the line to grep** when asking why
39041
+ * a HomeKit session died. It carries the negotiated parameters, the slot we
39042
+ * dialled and the rate we had promised for it, packet counts in both
39043
+ * directions on both legs, each leg's gate outcome, ffmpeg's exit, who asked
39044
+ * for the teardown, and every named drop.
39045
+ *
39046
+ * Emitted from the ffmpeg `exit` handler — the only place the exit code is
39047
+ * known — and directly from the teardown paths when there is no ffmpeg to wait
39048
+ * for. It deliberately does NOT touch the heartbeat: iOS restarts a session
39049
+ * in place when it is not getting a picture (one on record was started three
39050
+ * times in 16 s), and stopping the heartbeat on the superseded process's exit
39051
+ * would silence the replacement.
39052
+ */
39053
+ function logSessionSummary(session, log, trigger) {
39054
+ session.endedAtMs = Date.now();
39055
+ log.info("export-hap: stream session summary", { meta: {
39056
+ ...sessionSummaryMeta(session),
39057
+ trigger
39058
+ } });
39059
+ }
39060
+ /**
38537
39061
  * Build the NTP timestamp expected in an RTCP Sender Report.
38538
39062
  *
38539
39063
  * NTP timestamps are 64-bit: upper 32 bits = seconds since 1900-01-01,
@@ -38566,7 +39090,10 @@ function ntpTime() {
38566
39090
  */
38567
39091
  function sendRtcpSr(session, kind, log) {
38568
39092
  const srtcp = kind === "video" ? session.videoOutSrtcp : session.audioOutSrtcp;
38569
- if (!srtcp) return;
39093
+ if (!srtcp) {
39094
+ drop(session, "rtcp-no-srtcp");
39095
+ return;
39096
+ }
38570
39097
  const sink = kind === "video" ? session.videoUdp : session.audioUdp;
38571
39098
  const port = kind === "video" ? session.hapVideoPort : session.hapAudioPort;
38572
39099
  try {
@@ -38581,15 +39108,31 @@ function sendRtcpSr(session, kind, log) {
38581
39108
  });
38582
39109
  const encrypted = srtcp.encrypt(sr.serialize());
38583
39110
  sink.send(encrypted, port, session.hapAddress, (err) => {
38584
- if (err) log.debug("export-hap: RTCP send error", { meta: {
38585
- kind,
38586
- error: err.message
38587
- } });
39111
+ if (err) {
39112
+ drop(session, "srtp-send-failed");
39113
+ log.warn("export-hap: RTCP send error", { meta: {
39114
+ sessionId: session.sessionId,
39115
+ kind,
39116
+ error: err.message
39117
+ } });
39118
+ }
38588
39119
  });
38589
- if (kind === "video") session.videoOutLastRtcpAt = Date.now();
38590
- else session.audioOutLastRtcpAt = Date.now();
39120
+ if (kind === "video") {
39121
+ session.videoOutLastRtcpAt = Date.now();
39122
+ session.videoRtcpSrSent += 1;
39123
+ if (session.videoRtcpSrSent === 1) log.info("export-hap: first VIDEO RTCP Sender Report sent", { meta: {
39124
+ sessionId: session.sessionId,
39125
+ rtpTimestamp: session.videoOutLastRtpTimestamp,
39126
+ intervalMs: session.videoRtcpIntervalMs
39127
+ } });
39128
+ } else {
39129
+ session.audioOutLastRtcpAt = Date.now();
39130
+ session.audioRtcpSrSent += 1;
39131
+ }
38591
39132
  } catch (err) {
38592
- log.debug("export-hap: RTCP SR build/encrypt failed", { meta: {
39133
+ drop(session, "rtcp-encrypt-failed");
39134
+ log.warn("export-hap: RTCP SR build/encrypt failed", { meta: {
39135
+ sessionId: session.sessionId,
38593
39136
  kind,
38594
39137
  error: err instanceof Error ? err.message : String(err)
38595
39138
  } });
@@ -38611,7 +39154,14 @@ function forwardEncryptedRtp(session, rtpPacket, kind, log) {
38611
39154
  const sink = kind === "video" ? session.videoUdp : session.audioUdp;
38612
39155
  const port = kind === "video" ? session.hapVideoPort : session.hapAudioPort;
38613
39156
  const gate = kind === "video" ? session.videoSendGate : session.audioSendGate;
38614
- if (!srtp || !gate) return;
39157
+ if (!srtp) {
39158
+ drop(session, "no-srtp-session");
39159
+ return;
39160
+ }
39161
+ if (!gate) {
39162
+ drop(session, "no-gate");
39163
+ return;
39164
+ }
38615
39165
  gate.then(() => {
38616
39166
  try {
38617
39167
  const parsed = import_src.RtpPacket.deSerialize(rtpPacket);
@@ -38647,32 +39197,46 @@ function forwardEncryptedRtp(session, rtpPacket, kind, log) {
38647
39197
  }
38648
39198
  const encrypted = srtp.encrypt(parsed.payload, parsed.header);
38649
39199
  sink.send(encrypted, port, session.hapAddress, (err) => {
38650
- if (err) log.debug("export-hap: SRTP send error", { meta: {
38651
- kind,
38652
- error: err.message
38653
- } });
39200
+ if (err) {
39201
+ drop(session, "srtp-send-failed");
39202
+ log.debug("export-hap: SRTP send error", { meta: {
39203
+ sessionId: session.sessionId,
39204
+ kind,
39205
+ error: err.message
39206
+ } });
39207
+ }
38654
39208
  });
38655
39209
  const now = Date.now();
38656
39210
  if (kind === "video") {
38657
39211
  if (firstVideo || now > session.videoOutLastRtcpAt + session.videoRtcpIntervalMs) sendRtcpSr(session, "video", log);
38658
39212
  } else if (firstAudio || now > session.audioOutLastRtcpAt + session.audioRtcpIntervalMs) sendRtcpSr(session, "audio", log);
38659
39213
  } catch (err) {
39214
+ drop(session, "srtp-encrypt-failed");
38660
39215
  log.debug("export-hap: SRTP encrypt failed", { meta: {
39216
+ sessionId: session.sessionId,
38661
39217
  kind,
38662
39218
  error: err instanceof Error ? err.message : String(err)
38663
39219
  } });
38664
39220
  }
38665
39221
  });
38666
39222
  }
38667
- async function handleStreamRequest(request, sessions, bctx) {
39223
+ async function handleStreamRequest(request, sessions, bctx, advertised) {
38668
39224
  const log = bctx.ctx.logger.withTags({ deviceId: bctx.numericDeviceId });
38669
39225
  const session = sessions.get(request.sessionID);
38670
39226
  if (!session) {
38671
- log.warn("export-hap: stream request for unknown session", { meta: { sessionID: request.sessionID } });
39227
+ log.warn("export-hap: stream request for unknown session", { meta: {
39228
+ sessionID: request.sessionID,
39229
+ type: request.type
39230
+ } });
38672
39231
  return;
38673
39232
  }
38674
39233
  if (request.type === "stop") {
39234
+ session.stopRequestedByController = true;
39235
+ session.teardownTrigger = "controller-stop";
39236
+ const hadFfmpeg = session.ffmpeg !== null;
38675
39237
  killFfmpeg(session, bctx.ctx, bctx.numericDeviceId);
39238
+ stopHeartbeat(session);
39239
+ if (!hadFfmpeg) logSessionSummary(session, log, "controller-stop-no-ffmpeg");
38676
39240
  await closeIntercomTalkSession(session, bctx).catch(() => void 0);
38677
39241
  closeSocket(session.videoUdp);
38678
39242
  closeSocket(session.audioUdp);
@@ -38698,6 +39262,27 @@ async function handleStreamRequest(request, sessions, bctx) {
38698
39262
  session.videoOutOctetCount = 0;
38699
39263
  session.videoOutLastRtpTimestamp = 0;
38700
39264
  session.videoOutLastRtcpAt = 0;
39265
+ session.negotiated = {
39266
+ width: request.video.width,
39267
+ height: request.video.height,
39268
+ fps: request.video.fps,
39269
+ maxBitrateKbps: request.video.max_bit_rate,
39270
+ mtu: request.video.mtu,
39271
+ videoPt: request.video.pt,
39272
+ videoSsrc: session.videoSsrc,
39273
+ videoRtcpIntervalMs: session.videoRtcpIntervalMs,
39274
+ audioPt: request.audio.pt,
39275
+ audioSsrc: session.audioOutSsrc,
39276
+ audioSampleRateKhz: request.audio.sample_rate,
39277
+ audioPacketTimeMs: packetTimeMs,
39278
+ audioRtcpIntervalMs: session.audioRtcpIntervalMs,
39279
+ audioMaxBitrateKbps: request.audio.max_bit_rate
39280
+ };
39281
+ session.startedAtMs = Date.now();
39282
+ log.info("export-hap: stream negotiated", { meta: {
39283
+ sessionId: request.sessionID,
39284
+ ...session.negotiated
39285
+ } });
38701
39286
  session.lastStartParams = {
38702
39287
  pt: request.video.pt,
38703
39288
  mtu: request.video.mtu,
@@ -38718,7 +39303,8 @@ async function handleStreamRequest(request, sessions, bctx) {
38718
39303
  sample_rate: request.audio.sample_rate,
38719
39304
  packet_time: packetTimeMs
38720
39305
  };
38721
- await startFfmpegForSession(bctx, session, request.sessionID, startParams);
39306
+ await startFfmpegForSession(bctx, session, request.sessionID, startParams, advertised);
39307
+ armHeartbeat(session, log);
38722
39308
  return;
38723
39309
  }
38724
39310
  if (request.type === "reconfigure") {
@@ -38745,23 +39331,69 @@ async function handleStreamRequest(request, sessions, bctx) {
38745
39331
  sample_rate: session.lastStartParams.audioSampleRateEnum,
38746
39332
  packet_time: session.lastStartParams.audioPacketTimeMs
38747
39333
  };
38748
- await startFfmpegForSession(bctx, session, request.sessionID, startParams);
39334
+ log.info("export-hap: stream reconfigured", { meta: {
39335
+ sessionId: request.sessionID,
39336
+ width: request.video.width,
39337
+ height: request.video.height,
39338
+ fps: request.video.fps,
39339
+ maxBitrateKbps: request.video.max_bit_rate
39340
+ } });
39341
+ await startFfmpegForSession(bctx, session, request.sessionID, startParams, advertised);
39342
+ armHeartbeat(session, log);
38749
39343
  }
38750
39344
  }
38751
- async function startFfmpegForSession(bctx, session, sessionId, video) {
39345
+ async function startFfmpegForSession(bctx, session, sessionId, video, advertised) {
38752
39346
  const { ctx, proxy, numericDeviceId, options } = bctx;
39347
+ const startLog = ctx.logger.withTags({ deviceId: numericDeviceId });
38753
39348
  const entries = await proxy.cameraStreams?.getProfileRtspEntries({}) ?? [];
38754
- if (entries.length === 0) throw new Error(`export-hap: no profile RTSP entries for device ${numericDeviceId}`);
39349
+ if (entries.length === 0) {
39350
+ startLog.warn("export-hap: stream start DROPPED — device publishes no profile RTSP entries", { meta: {
39351
+ sessionId,
39352
+ capBound: proxy.cameraStreams !== void 0
39353
+ } });
39354
+ throw new Error(`export-hap: no profile RTSP entries for device ${numericDeviceId}`);
39355
+ }
38755
39356
  const pref = options.hapDeviceSettings.streamPreference;
38756
39357
  const picked = pickPreferredRtspEntry(entries, pref, numericDeviceId, { targetResolution: {
38757
39358
  width: video.width,
38758
39359
  height: video.height
38759
39360
  } });
38760
- if (!picked) throw new Error(`export-hap: no enabled RTSP entries for device ${numericDeviceId}`);
39361
+ if (!picked) {
39362
+ startLog.warn("export-hap: stream start DROPPED — no ENABLED profile RTSP entry", { meta: {
39363
+ sessionId,
39364
+ streamPreference: pref,
39365
+ targetResolution: `${video.width}x${video.height}`,
39366
+ entries: entries.map((e) => `${e.profile}:${e.enabled ? "enabled" : "disabled"}`)
39367
+ } });
39368
+ throw new Error(`export-hap: no enabled RTSP entries for device ${numericDeviceId}`);
39369
+ }
38761
39370
  const rtspUrl = picked.url;
38762
39371
  const slot = (await proxy.cameraStreams?.getBrokerStreams({}) ?? []).find((s) => s.brokerId === picked.brokerId);
38763
39372
  const codec = (picked.codec ?? slot?.codec ?? "").toLowerCase();
38764
39373
  const needsTranscode = codec.includes("h265") || codec.includes("hevc");
39374
+ const pickedProfile = toKnownProfile(picked.profileId);
39375
+ const resolvedFps = pickedProfile === null ? void 0 : advertised.fpsByProfile.get(pickedProfile);
39376
+ const advertisedFps = resolvedFps?.fps ?? video.fps;
39377
+ const advertisedFpsSource = resolvedFps?.source ?? "assumed";
39378
+ session.selectedSlot = {
39379
+ profile: pickedProfile,
39380
+ brokerId: picked.brokerId,
39381
+ width: picked.resolution?.width ?? null,
39382
+ height: picked.resolution?.height ?? null,
39383
+ advertisedFps,
39384
+ advertisedFpsSource,
39385
+ codec: codec.length > 0 ? codec : "unknown",
39386
+ transcode: needsTranscode
39387
+ };
39388
+ if (advertisedFps !== video.fps) startLog.warn("export-hap: negotiated fps does NOT match the slot we are about to dial", { meta: {
39389
+ sessionId,
39390
+ negotiatedFps: video.fps,
39391
+ slotFps: advertisedFps,
39392
+ slotFpsSource: advertisedFpsSource,
39393
+ profile: pickedProfile,
39394
+ brokerId: picked.brokerId,
39395
+ transcode: needsTranscode
39396
+ } });
38765
39397
  const videoLoopPort = session.videoLoopUdp.address().port;
38766
39398
  const audioLoopPort = session.audioLoopUdp.address().port;
38767
39399
  const videoTarget = `rtp://127.0.0.1:${videoLoopPort}?pkt_size=${video.mtu}`;
@@ -38861,15 +39493,22 @@ async function startFfmpegForSession(bctx, session, sessionId, video) {
38861
39493
  } });
38862
39494
  });
38863
39495
  proc.once("exit", (code, signal) => {
39496
+ session.ffmpegExit = {
39497
+ code,
39498
+ signal
39499
+ };
38864
39500
  const ok = code === 0 && signal === null;
38865
39501
  const meta = {
38866
39502
  sessionId,
38867
39503
  code,
38868
- signal
39504
+ signal,
39505
+ stopRequestedByController: session.stopRequestedByController,
39506
+ videoPacketsForwarded: session.videoPacketsForwarded
38869
39507
  };
38870
- if (ok) log.info("export-hap: ffmpeg exited", { meta });
38871
- else log.warn("export-hap: ffmpeg exited abnormally", { meta });
39508
+ if (ok || session.stopRequestedByController) log.info("export-hap: ffmpeg exited", { meta });
39509
+ else log.warn("export-hap: ffmpeg exited without a controller stop", { meta });
38872
39510
  if (session.ffmpeg === proc) session.ffmpeg = null;
39511
+ logSessionSummary(session, log, session.teardownTrigger ?? "ffmpeg-exit");
38873
39512
  });
38874
39513
  proc.once("error", (err) => {
38875
39514
  log.warn("export-hap: ffmpeg spawn failed", { meta: {
@@ -38880,12 +39519,27 @@ async function startFfmpegForSession(bctx, session, sessionId, video) {
38880
39519
  log.info("export-hap: stream started", { meta: {
38881
39520
  sessionId,
38882
39521
  transcode: needsTranscode,
38883
- rtspUrl: picked.brokerId,
39522
+ brokerId: picked.brokerId,
39523
+ profile: pickedProfile,
39524
+ sourceResolution: picked.resolution === void 0 ? null : `${picked.resolution.width}x${picked.resolution.height}`,
39525
+ sourceCodec: codec.length > 0 ? codec : "unknown",
39526
+ negotiated: `${video.width}x${video.height}@${video.fps} ${video.max_bit_rate}kbps`,
39527
+ slotFps: advertisedFps,
39528
+ slotFpsSource: advertisedFpsSource,
38884
39529
  audioCodec: "opus",
38885
39530
  audioBitrateKbps: OPUS_BITRATE_KBPS
38886
39531
  } });
38887
39532
  }
38888
39533
  /**
39534
+ * `PickedStream.profileId` is the brokerId suffix; for a profile-keyed entry
39535
+ * that IS the profile name. Anything else addresses a raw cam-stream and must
39536
+ * not be coerced into a profile.
39537
+ */
39538
+ function toKnownProfile(profileId) {
39539
+ if (profileId === "high" || profileId === "mid" || profileId === "low") return profileId;
39540
+ return null;
39541
+ }
39542
+ /**
38889
39543
  * Handle one incoming SRTP audio packet from iOS Home.
38890
39544
  *
38891
39545
  * End-to-end flow:
@@ -38906,15 +39560,22 @@ async function startFfmpegForSession(bctx, session, sessionId, video) {
38906
39560
  * stays codec-agnostic.
38907
39561
  */
38908
39562
  async function handleIncomingAudioRtp(session, packet, sourceAddress, bctx) {
38909
- if (packet.length < 12) return;
39563
+ if (packet.length < 12) {
39564
+ drop(session, "upstream-short-packet");
39565
+ return;
39566
+ }
38910
39567
  const log = bctx.ctx.logger.withTags({ deviceId: bctx.numericDeviceId });
38911
39568
  session.upstreamRtpPacketsReceived += 1;
38912
- if (!session.upstreamAudioSrtp) return;
39569
+ if (!session.upstreamAudioSrtp) {
39570
+ drop(session, "upstream-no-srtp");
39571
+ return;
39572
+ }
38913
39573
  let decryptedPacket;
38914
39574
  try {
38915
39575
  decryptedPacket = session.upstreamAudioSrtp.decrypt(packet);
38916
39576
  session.upstreamRtpPacketsDecrypted += 1;
38917
39577
  } catch (err) {
39578
+ drop(session, "upstream-decrypt-failed");
38918
39579
  session.upstreamRtpDecryptFailures += 1;
38919
39580
  if (session.upstreamRtpDecryptFailures % 100 === 1) log.debug("export-hap: SRTP decrypt failed (rate-limited)", { meta: {
38920
39581
  failures: session.upstreamRtpDecryptFailures,
@@ -38931,18 +39592,28 @@ async function handleIncomingAudioRtp(session, packet, sourceAddress, bctx) {
38931
39592
  rtpTimestamp = parsedRtp.header.timestamp;
38932
39593
  rtpPayloadType = parsedRtp.header.payloadType;
38933
39594
  } catch (err) {
39595
+ drop(session, "upstream-parse-failed");
38934
39596
  log.debug("export-hap: RTP parse failed after decrypt", { meta: { error: errMsg$8(err) } });
38935
39597
  return;
38936
39598
  }
38937
39599
  const negotiatedAudioPt = session.lastStartParams?.audioPt;
38938
- if (negotiatedAudioPt === void 0) return;
38939
- if (rtpPayloadType !== negotiatedAudioPt) return;
39600
+ if (negotiatedAudioPt === void 0) {
39601
+ drop(session, "upstream-before-start");
39602
+ return;
39603
+ }
39604
+ if (rtpPayloadType !== negotiatedAudioPt) {
39605
+ drop(session, "upstream-pt-mismatch");
39606
+ return;
39607
+ }
38940
39608
  if (session.firstUpstreamRtpTimestamp === null) {
38941
39609
  session.firstUpstreamRtpTimestamp = rtpTimestamp;
38942
39610
  log.info("export-hap: first upstream RTP packet received");
38943
39611
  }
38944
39612
  const payload = decryptedPayload;
38945
- if (payload.length === 0) return;
39613
+ if (payload.length === 0) {
39614
+ drop(session, "upstream-empty-payload");
39615
+ return;
39616
+ }
38946
39617
  if (session.intercomTalkSessionId === null) {
38947
39618
  session.intercomTalkSessionId = "";
38948
39619
  const opened = await openIntercomTalkSession(bctx).catch((err) => {
@@ -38954,7 +39625,10 @@ async function handleIncomingAudioRtp(session, packet, sourceAddress, bctx) {
38954
39625
  log.info("export-hap: intercom talk session opened", { meta: { sessionId: opened.sessionId } });
38955
39626
  }
38956
39627
  }
38957
- if (!session.intercomTalkSessionId) return;
39628
+ if (!session.intercomTalkSessionId) {
39629
+ drop(session, "upstream-no-talk-session");
39630
+ return;
39631
+ }
38958
39632
  const opusSampleRateHz = (session.lastStartParams?.audioSampleRateEnum ?? 16) * 1e3;
38959
39633
  session.intercomPcmSequence += 1;
38960
39634
  session.upstreamPcmFramesDecoded += 1;
@@ -38967,6 +39641,7 @@ async function handleIncomingAudioRtp(session, packet, sourceAddress, bctx) {
38967
39641
  sequenceNumber: session.intercomPcmSequence
38968
39642
  });
38969
39643
  } catch (err) {
39644
+ drop(session, "upstream-push-failed");
38970
39645
  log.debug("export-hap: intercom.pushTalkAudio failed (will re-open on next frame)", { meta: {
38971
39646
  sequenceNumber: session.intercomPcmSequence,
38972
39647
  error: errMsg$8(err)
@@ -39050,140 +39725,6 @@ function errMsg$7(err) {
39050
39725
  return err instanceof Error ? err.message : String(err);
39051
39726
  }
39052
39727
  //#endregion
39053
- //#region src/mappers/builders/hksv.ts
39054
- /**
39055
- * HomeKit Secure Video (HKSV) — stub recording delegate.
39056
- *
39057
- * Wires the camera as HKSV-capable so iOS Home shows the "Activity"
39058
- * tab, the per-camera recording settings UI ("Stream and Allow
39059
- * Recording" / "Off"), and notifications. The delegate stub:
39060
- * - logs every protocol callback (active toggle, config change,
39061
- * stream request, close);
39062
- * - returns from `handleRecordingStreamRequest` immediately without
39063
- * yielding any fragment — iOS sees "no recording available" but
39064
- * the camera otherwise behaves like an HKSV camera in the UI.
39065
- *
39066
- * Why a stub: a real implementation needs an fMP4 segmenter that runs
39067
- * a per-camera ffmpeg with a sustained pre-buffer, key/IV-derived
39068
- * encrypter, motion-anchored fragment alignment, and storage for the
39069
- * resulting clips. That work belongs behind the `recording` cap
39070
- * (recording + playback-manifest surface) once it grows fragment export,
39071
- * so the segmenter lives in the recorder addon and HKSV becomes a thin
39072
- * wrapper that asks the cap for "fragments since T" and streams them to iOS.
39073
- *
39074
- * Until that cap lands this stub keeps HKSV characteristics visible
39075
- * (a) so the operator can flip "Allow Recording" without HomeKit
39076
- * complaining the camera lacks the service, and (b) so the
39077
- * `CameraController` advertises a `RecordingManagement` service for
39078
- * iOS-side analytics + Activity-tab UX scaffolding.
39079
- *
39080
- * Constructor returns a `{ options, delegate }` pair the caller
39081
- * passes verbatim into `new CameraController({ ..., recording })`.
39082
- */
39083
- var RECORDING_OPTIONS = {
39084
- prebufferLength: 4e3,
39085
- mediaContainerConfiguration: [{
39086
- type: _homebridge_hap_nodejs.MediaContainerType.FRAGMENTED_MP4,
39087
- fragmentLength: 4e3
39088
- }],
39089
- video: {
39090
- type: _homebridge_hap_nodejs.VideoCodecType.H264,
39091
- parameters: {
39092
- profiles: [
39093
- _homebridge_hap_nodejs.H264Profile.BASELINE,
39094
- _homebridge_hap_nodejs.H264Profile.MAIN,
39095
- _homebridge_hap_nodejs.H264Profile.HIGH
39096
- ],
39097
- levels: [
39098
- _homebridge_hap_nodejs.H264Level.LEVEL3_1,
39099
- _homebridge_hap_nodejs.H264Level.LEVEL3_2,
39100
- _homebridge_hap_nodejs.H264Level.LEVEL4_0
39101
- ]
39102
- },
39103
- resolutions: [
39104
- [
39105
- 1920,
39106
- 1080,
39107
- 30
39108
- ],
39109
- [
39110
- 1920,
39111
- 1080,
39112
- 24
39113
- ],
39114
- [
39115
- 1920,
39116
- 1080,
39117
- 15
39118
- ],
39119
- [
39120
- 1280,
39121
- 720,
39122
- 30
39123
- ],
39124
- [
39125
- 1280,
39126
- 720,
39127
- 24
39128
- ],
39129
- [
39130
- 1280,
39131
- 720,
39132
- 15
39133
- ]
39134
- ]
39135
- },
39136
- audio: { codecs: [{
39137
- type: _homebridge_hap_nodejs.AudioRecordingCodecType.AAC_LC,
39138
- bitrateMode: _homebridge_hap_nodejs.AudioBitrate.VARIABLE,
39139
- audioChannels: 1,
39140
- samplerate: [_homebridge_hap_nodejs.AudioRecordingSamplerate.KHZ_16, _homebridge_hap_nodejs.AudioRecordingSamplerate.KHZ_24]
39141
- }] },
39142
- overrideEventTriggerOptions: [_homebridge_hap_nodejs.EventTriggerOption.MOTION]
39143
- };
39144
- function buildHksvStub(bctx) {
39145
- const { ctx, numericDeviceId } = bctx;
39146
- const log = ctx.logger.withTags({ deviceId: numericDeviceId });
39147
- let activeStreamId = null;
39148
- return {
39149
- options: RECORDING_OPTIONS,
39150
- delegate: {
39151
- updateRecordingActive(active) {
39152
- log.info("export-hap: HKSV recording active changed", { meta: { active } });
39153
- },
39154
- updateRecordingConfiguration(configuration) {
39155
- if (!configuration) {
39156
- log.info("export-hap: HKSV recording configuration cleared");
39157
- return;
39158
- }
39159
- log.info("export-hap: HKSV recording configuration applied", { meta: {
39160
- prebufferLength: configuration.prebufferLength,
39161
- fragmentLength: configuration.mediaContainerConfiguration.fragmentLength,
39162
- videoResolution: `${configuration.videoCodec.resolution[0]}x${configuration.videoCodec.resolution[1]}@${configuration.videoCodec.resolution[2]}`,
39163
- videoBitrate: configuration.videoCodec.parameters.bitRate,
39164
- eventTriggers: configuration.eventTriggerTypes
39165
- } });
39166
- },
39167
- async *handleRecordingStreamRequest(streamId, _signal) {
39168
- activeStreamId = streamId;
39169
- log.warn("export-hap: HKSV stream requested — stub returns empty (no clip source yet)", { meta: { streamId } });
39170
- },
39171
- acknowledgeStream(streamId) {
39172
- if (activeStreamId === streamId) activeStreamId = null;
39173
- log.debug("export-hap: HKSV stream acknowledged", { meta: { streamId } });
39174
- },
39175
- closeRecordingStream(streamId, reason) {
39176
- if (activeStreamId === streamId) activeStreamId = null;
39177
- log.info("export-hap: HKSV stream closed", { meta: {
39178
- streamId,
39179
- reason: reason ?? null
39180
- } });
39181
- }
39182
- },
39183
- handle: { async dispose() {} }
39184
- };
39185
- }
39186
- //#endregion
39187
39728
  //#region src/mappers/builders/intercom.ts
39188
39729
  async function buildIntercom(input) {
39189
39730
  const { bctx, streamingOptions } = input;
@@ -39477,6 +40018,65 @@ function errMsg$4(err) {
39477
40018
  return err instanceof Error ? err.message : String(err);
39478
40019
  }
39479
40020
  //#endregion
40021
+ //#region src/mappers/builders/stream-fps-probe.ts
40022
+ async function probeAdvertisedVideoProfile(bctx) {
40023
+ const { ctx, proxy, numericDeviceId, options } = bctx;
40024
+ const log = ctx.logger.withTags({ deviceId: numericDeviceId });
40025
+ const entries = await probe(() => proxy.cameraStreams?.getProfileRtspEntries({}), "cameraStreams.getProfileRtspEntries", log);
40026
+ const slots = await probe(() => proxy.cameraStreams?.getBrokerStreams({}), "cameraStreams.getBrokerStreams", log);
40027
+ const camStreams = await probe(() => proxy.cameraStreams?.getCameraStreams({}), "cameraStreams.getCameraStreams", log);
40028
+ const choices = await probe(() => proxy.webrtcSession?.listStreams({}), "webrtcSession.listStreams", log);
40029
+ const fpsByProfile = resolveProfileFps({
40030
+ choices: choices ?? [],
40031
+ slots: slots ?? [],
40032
+ camStreams: camStreams ?? []
40033
+ });
40034
+ const resolutions = deriveAdvertisedResolutions({
40035
+ candidates: CANDIDATE_RESOLUTIONS,
40036
+ entries: entries ?? [],
40037
+ deviceId: numericDeviceId,
40038
+ pref: options.hapDeviceSettings.streamPreference,
40039
+ fpsByProfile
40040
+ });
40041
+ const assumed = resolutions.filter((r) => r.source === "assumed").length;
40042
+ log.info("export-hap: advertised video profile derived", { meta: {
40043
+ streamPreference: options.hapDeviceSettings.streamPreference,
40044
+ resolutions: formatAdvertisedResolutions(resolutions),
40045
+ profileFps: [...fpsByProfile.values()].map((f) => `${f.profile}=${f.fps}(${f.source})`),
40046
+ assumedCount: assumed,
40047
+ previouslyAdvertisedFps: 30
40048
+ } });
40049
+ if (assumed === resolutions.length) log.warn("export-hap: no measured or published frame rate for ANY profile — advertising the assumed rate", { meta: {
40050
+ entries: entries?.length ?? 0,
40051
+ choices: choices?.length ?? 0
40052
+ } });
40053
+ return {
40054
+ resolutions,
40055
+ fpsByProfile
40056
+ };
40057
+ }
40058
+ /**
40059
+ * Run one cap read. Returns null both when the cap is not bound and when the
40060
+ * call throws — and LOGS which, because "the camera has no telemetry" and "the
40061
+ * telemetry call failed" lead to different fixes.
40062
+ */
40063
+ async function probe(call, label, log) {
40064
+ try {
40065
+ const pending = call();
40066
+ if (pending === void 0) {
40067
+ log.info("export-hap: fps probe skipped — cap not bound on this device", { meta: { call: label } });
40068
+ return null;
40069
+ }
40070
+ return await pending;
40071
+ } catch (err) {
40072
+ log.warn("export-hap: fps probe failed — falling back to a lower-authority source", { meta: {
40073
+ call: label,
40074
+ error: err instanceof Error ? err.message : String(err)
40075
+ } });
40076
+ return null;
40077
+ }
40078
+ }
40079
+ //#endregion
39480
40080
  //#region src/mappers/builders/child-switch.ts
39481
40081
  /**
39482
40082
  * Child-switch builder — turns a camstack accessory child device (siren,
@@ -39703,24 +40303,18 @@ async function buildCameraAccessory(input) {
39703
40303
  displayName,
39704
40304
  options
39705
40305
  };
39706
- const streams = buildCameraStreamingDelegate(bctx);
40306
+ const streams = buildCameraStreamingDelegate(bctx, await probeAdvertisedVideoProfile(bctx));
39707
40307
  const handles = [];
39708
40308
  if (capNames.has("intercom")) handles.push(await buildIntercom({
39709
40309
  bctx,
39710
40310
  streamingOptions: streams.streamingOptions
39711
40311
  }));
39712
- const hksv = capNames.has("motion-detection") ? buildHksvStub(bctx) : null;
39713
40312
  const controller = new (isDoorbell ? _homebridge_hap_nodejs.DoorbellController : _homebridge_hap_nodejs.CameraController)({
39714
40313
  delegate: streams.delegate,
39715
40314
  streamingOptions: streams.streamingOptions,
39716
- cameraStreamCount: 2,
39717
- ...hksv ? { recording: {
39718
- options: hksv.options,
39719
- delegate: hksv.delegate
39720
- } } : {}
40315
+ cameraStreamCount: 2
39721
40316
  });
39722
40317
  accessory.configureController(controller);
39723
- if (hksv) handles.push(hksv.handle);
39724
40318
  if (capNames.has("motion-detection")) handles.push(await buildMotionSensor(bctx));
39725
40319
  if (isDoorbell && controller instanceof _homebridge_hap_nodejs.DoorbellController) handles.push(await buildDoorbell({
39726
40320
  bctx,