@camstack/addon-export-hap 1.2.11 → 1.2.13

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";
@@ -774,7 +774,7 @@ var propertyKeyTypes = /* @__PURE__*/ new Set([
774
774
  "number",
775
775
  "symbol"
776
776
  ]);
777
- function escapeRegex$1(str) {
777
+ function escapeRegex(str) {
778
778
  return str.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
779
779
  }
780
780
  function clone(inst, def, params) {
@@ -1504,7 +1504,7 @@ var $ZodCheckUpperCase = /*@__PURE__*/ $constructor("$ZodCheckUpperCase", (inst,
1504
1504
  });
1505
1505
  var $ZodCheckIncludes = /*@__PURE__*/ $constructor("$ZodCheckIncludes", (inst, def) => {
1506
1506
  $ZodCheck.init(inst, def);
1507
- const escapedRegex = escapeRegex$1(def.includes);
1507
+ const escapedRegex = escapeRegex(def.includes);
1508
1508
  const pattern = new RegExp(typeof def.position === "number" ? `^.{${def.position}}${escapedRegex}` : escapedRegex);
1509
1509
  def.pattern = pattern;
1510
1510
  inst._zod.onattach.push((inst) => {
@@ -1527,7 +1527,7 @@ var $ZodCheckIncludes = /*@__PURE__*/ $constructor("$ZodCheckIncludes", (inst, d
1527
1527
  });
1528
1528
  var $ZodCheckStartsWith = /*@__PURE__*/ $constructor("$ZodCheckStartsWith", (inst, def) => {
1529
1529
  $ZodCheck.init(inst, def);
1530
- const pattern = new RegExp(`^${escapeRegex$1(def.prefix)}.*`);
1530
+ const pattern = new RegExp(`^${escapeRegex(def.prefix)}.*`);
1531
1531
  def.pattern ?? (def.pattern = pattern);
1532
1532
  inst._zod.onattach.push((inst) => {
1533
1533
  const bag = inst._zod.bag;
@@ -1549,7 +1549,7 @@ var $ZodCheckStartsWith = /*@__PURE__*/ $constructor("$ZodCheckStartsWith", (ins
1549
1549
  });
1550
1550
  var $ZodCheckEndsWith = /*@__PURE__*/ $constructor("$ZodCheckEndsWith", (inst, def) => {
1551
1551
  $ZodCheck.init(inst, def);
1552
- const pattern = new RegExp(`.*${escapeRegex$1(def.suffix)}$`);
1552
+ const pattern = new RegExp(`.*${escapeRegex(def.suffix)}$`);
1553
1553
  def.pattern ?? (def.pattern = pattern);
1554
1554
  inst._zod.onattach.push((inst) => {
1555
1555
  const bag = inst._zod.bag;
@@ -2785,7 +2785,7 @@ var $ZodEnum = /*@__PURE__*/ $constructor("$ZodEnum", (inst, def) => {
2785
2785
  const values = getEnumValues(def.entries);
2786
2786
  const valuesSet = new Set(values);
2787
2787
  inst._zod.values = valuesSet;
2788
- inst._zod.pattern = new RegExp(`^(${values.filter((k) => propertyKeyTypes.has(typeof k)).map((o) => typeof o === "string" ? escapeRegex$1(o) : o.toString()).join("|")})$`);
2788
+ inst._zod.pattern = new RegExp(`^(${values.filter((k) => propertyKeyTypes.has(typeof k)).map((o) => typeof o === "string" ? escapeRegex(o) : o.toString()).join("|")})$`);
2789
2789
  inst._zod.parse = (payload, _ctx) => {
2790
2790
  const input = payload.value;
2791
2791
  if (valuesSet.has(input)) return payload;
@@ -2803,7 +2803,7 @@ var $ZodLiteral = /*@__PURE__*/ $constructor("$ZodLiteral", (inst, def) => {
2803
2803
  if (def.values.length === 0) throw new Error("Cannot create literal schema with no valid values");
2804
2804
  const values = new Set(def.values);
2805
2805
  inst._zod.values = values;
2806
- inst._zod.pattern = new RegExp(`^(${def.values.map((o) => typeof o === "string" ? escapeRegex$1(o) : o ? escapeRegex$1(o.toString()) : String(o)).join("|")})$`);
2806
+ inst._zod.pattern = new RegExp(`^(${def.values.map((o) => typeof o === "string" ? escapeRegex(o) : o ? escapeRegex(o.toString()) : String(o)).join("|")})$`);
2807
2807
  inst._zod.parse = (payload, _ctx) => {
2808
2808
  const input = payload.value;
2809
2809
  if (values.has(input)) return payload;
@@ -6496,6 +6496,36 @@ var ProfileRtspEntrySchema = object({
6496
6496
  resolution: CamStreamResolutionSchema.optional()
6497
6497
  });
6498
6498
  /**
6499
+ * Per-call node pinning for `ctx.api` capability calls.
6500
+ *
6501
+ * A capability call normally resolves to its DEFAULT provider — a `singleton`
6502
+ * cap resolves to the hub, a device-scoped cap to the device's owning node. To
6503
+ * query a SPECIFIC node's provider instead (e.g. a remote agent's own
6504
+ * in-process `platform-probe` hardware, which the hub cannot probe), pin the
6505
+ * call to that node.
6506
+ *
6507
+ * The nodeId rides OUT-OF-BAND in the tRPC call context (NOT in the validated
6508
+ * method args), so capability method signatures stay `nodeId`-free — node
6509
+ * targeting is a property of the CALL, not of the method. The transport lifts
6510
+ * it from `op.context` onto the `CapCallInput.nodeId` field (`ipcParentLink`),
6511
+ * and the hub parent's `onUnownedCall` passes it to the `CapRouteResolver`,
6512
+ * which classifies a pinned agent node as `agent-child-forward`
6513
+ * (`$agent-cap-fwd.forward` → the agent's in-process provider).
6514
+ *
6515
+ * Usage at a call site:
6516
+ *
6517
+ * await api.platformProbe.getCapabilities.query(undefined, nodePin(nodeId))
6518
+ */
6519
+ /** tRPC `op.context` key carrying a per-call node pin. */
6520
+ var CAP_NODE_PIN_CONTEXT_KEY = "__camstackNodePin";
6521
+ /**
6522
+ * Build the tRPC request options that pin a single capability call to `nodeId`.
6523
+ * Pass as the second argument to `.query(input, …)` / `.mutate(input, …)`.
6524
+ */
6525
+ function nodePin(nodeId) {
6526
+ return { context: { [CAP_NODE_PIN_CONTEXT_KEY]: nodeId } };
6527
+ }
6528
+ /**
6499
6529
  * Output schema shared by the contribution + live methods.
6500
6530
  *
6501
6531
  * Mirrors the `ConfigUISchemaWithValues` shape (sections[] + optional
@@ -6943,6 +6973,39 @@ method(object({ deviceId: number() }), array(StreamSourceEntrySchema)), method(o
6943
6973
  action: string().min(1),
6944
6974
  input: unknown()
6945
6975
  }), unknown(), { kind: "mutation" }), method(object({ deviceId: number() }), _void(), { kind: "mutation" }), method(object({ deviceId: number() }), unknown().nullable()), method(object({ deviceId: number() }), RawStateResultSchema.nullable(), { auth: "protected" });
6976
+ //#endregion
6977
+ //#region ../types/dist/canonical-hash-7nfBbEqR.mjs
6978
+ /**
6979
+ * Deterministic SHA-256 hash of an arbitrary serialisable value. The
6980
+ * canonical form sorts object keys alphabetically at every depth so two
6981
+ * structurally-equal inputs with different key insertion orders produce
6982
+ * the same hash. Returns a 64-char lowercase hex digest.
6983
+ *
6984
+ * Used by export adapters (Alexa, HAP) to short-circuit re-discovery /
6985
+ * accessory-rebuild work when the upstream shape is byte-identical to
6986
+ * the last applied state — preventing user-visible "re-discovery"
6987
+ * notifications on every addon-runner respawn. Each respawn re-fires
6988
+ * `DeviceBindingsChanged` for every cap registration, which without
6989
+ * this guard would propagate redundant pushes.
6990
+ *
6991
+ * Note: this is a SYMPTOMATIC fix layered on top of the binding-change
6992
+ * subscription. The proper fix is a single "device ready" lifecycle
6993
+ * barrier so exports react only when the full cap set has landed —
6994
+ * tracked separately for post-HA-integration work.
6995
+ */
6996
+ function canonicalHash(value) {
6997
+ const canonical = JSON.stringify(value, replaceWithSortedKeys);
6998
+ return createHash("sha256").update(canonical ?? "").digest("hex");
6999
+ }
7000
+ function replaceWithSortedKeys(_key, value) {
7001
+ if (value && typeof value === "object" && !Array.isArray(value)) {
7002
+ const obj = value;
7003
+ const out = {};
7004
+ for (const k of Object.keys(obj).toSorted()) out[k] = obj[k];
7005
+ return out;
7006
+ }
7007
+ return value;
7008
+ }
6946
7009
  var EncodeProfileSchema = object({
6947
7010
  video: object({
6948
7011
  codec: _enum([
@@ -6955,6 +7018,14 @@ var EncodeProfileSchema = object({
6955
7018
  "main",
6956
7019
  "high"
6957
7020
  ]).optional(),
7021
+ /**
7022
+ * `-level`, e.g. `'3.1'`. A consumer that ADVERTISES a level in its SDP
7023
+ * (`profile-level-id=42e01f` is Baseline 3.1) must constrain the encoder to
7024
+ * it, or it ships a stream that does not match its own advertisement — the
7025
+ * defect class that kept HomeKit black for a year and that Alexa carried
7026
+ * silently. Optional because a browser negotiates the level itself.
7027
+ */
7028
+ level: string().optional(),
6958
7029
  width: number().int().positive().optional(),
6959
7030
  height: number().int().positive().optional(),
6960
7031
  fps: number().positive().optional(),
@@ -7002,6 +7073,29 @@ var EncodeProfileSchema = object({
7002
7073
  outputArgs: array(string()).optional()
7003
7074
  });
7004
7075
  /**
7076
+ * The shape every live egress starts from: H.264 Baseline 3.1 at 720p25.
7077
+ * Baseline because it is the one profile every consumer in this repo decodes
7078
+ * (Echo, iOS, an old browser); 3.1 because that is what the SDPs advertise.
7079
+ */
7080
+ var BASE_LIVE_EGRESS_PROFILE = {
7081
+ video: {
7082
+ codec: "h264",
7083
+ profile: "baseline",
7084
+ level: "3.1",
7085
+ width: 1280,
7086
+ height: 720,
7087
+ fps: 25,
7088
+ bitrateKbps: 2500,
7089
+ gopFrames: 25,
7090
+ bf: 0,
7091
+ preset: "veryfast",
7092
+ tune: "zerolatency"
7093
+ },
7094
+ audio: "passthrough"
7095
+ };
7096
+ ({ ...BASE_LIVE_EGRESS_PROFILE }), { ...BASE_LIVE_EGRESS_PROFILE.video };
7097
+ ({ ...BASE_LIVE_EGRESS_PROFILE });
7098
+ /**
7005
7099
  * Deep wiring healthcheck — snapshot of active reachability probes across
7006
7100
  * every declared capability + widget of every installed plugin, on every
7007
7101
  * node. Produced by the backend `WiringHealthService` and surfaced via
@@ -7051,6 +7145,105 @@ object({
7051
7145
  })
7052
7146
  });
7053
7147
  /**
7148
+ * Per-camera FUNCTION SWITCHES — the one coherent on/off surface over the
7149
+ * pipeline functions an operator thinks in terms of.
7150
+ *
7151
+ * ## This file adds no state
7152
+ *
7153
+ * Every switch here is a VIEW onto an authority that already existed
7154
+ * ([D61](../../../../docs/decisions/adr-0062.md)). The whole point of the
7155
+ * group is that there is exactly one place each function is turned off, and
7156
+ * the group routes to it:
7157
+ *
7158
+ * | Switch | Authority | Proven "off stops the work" gate |
7159
+ * | --- | --- | --- |
7160
+ * | `stream-broker` | `deviceManager.setDisabled` | `StreamBrokerManager.reconcileAllCatalogs` releases the brokers; `ensureBroker` refuses re-creation |
7161
+ * | `object-detection` | `deviceManager.setWrapperActive('detection-pipeline')` | `PipelineSettingsStore.resolvePipelineForDevice` returns `{ steps: [], audio: null }` |
7162
+ * | `audio-analysis` | `deviceManager.setWrapperActive('audio-analysis')` | `AudioSubscriptionController.subscribeAudioStream` returns `null` before opening the stream |
7163
+ * | `recording` | `recording.setDeviceConfig` → `RecordingConfig.enabled` | `band-decision.shouldRecord` returns false; the controller detaches the device |
7164
+ * | `notifications` | `notificationRules.setDeviceMuted` | `NotificationCenter.evaluateAndEnqueue` returns before any rule is evaluated |
7165
+ *
7166
+ * The wrapper-binding pair is not a new idea: `legacy-migrations.ts` already
7167
+ * migrated the legacy `audioEnabled` / `pipelineEnabled` /
7168
+ * `motionDetectionEnabled` booleans ONTO `setWrapperActive`. The group is the
7169
+ * surface that decision never got.
7170
+ *
7171
+ * ## Two rules that are load-bearing
7172
+ *
7173
+ * - **Recording's switch is `enabled`, never the bands.** `bands` is the only
7174
+ * authored intent and `mode` is derived from it (`deriveRecordingMode`).
7175
+ * Expressing "off" by clearing bands destroys the operator's schedule and
7176
+ * turning the camera back on would then silently record nothing.
7177
+ * - **A switch that is off must be reported as off**, not merely produce
7178
+ * nothing. {@link CameraSwitch.enabled} is what a status surface renders as
7179
+ * "disabled by an operator" instead of "broken" — see
7180
+ * `CameraStatus.switchedOff`.
7181
+ */
7182
+ /**
7183
+ * The five functions the operator named (2026-08-05). Deliberately NOT one id
7184
+ * per pipeline step: face recognition and plate/LPR are per-step toggles on
7185
+ * `pipelineOrchestrator.setCameraStepToggle` and belong in the pipeline
7186
+ * editor, not in a five-button safety group.
7187
+ */
7188
+ var CameraSwitchIdSchema = _enum([
7189
+ "stream-broker",
7190
+ "object-detection",
7191
+ "audio-analysis",
7192
+ "recording",
7193
+ "notifications"
7194
+ ]);
7195
+ /**
7196
+ * WHERE the switch's state actually lives. A discriminated union rather than a
7197
+ * string so both the writer (the orchestrator's `setCameraSwitch`) and any
7198
+ * reader can exhaustively narrow — and so "the group added a parallel map" is
7199
+ * a compile error rather than a review comment.
7200
+ */
7201
+ var CameraSwitchAuthoritySchema = discriminatedUnion("kind", [
7202
+ object({ kind: literal("device-disabled") }),
7203
+ object({
7204
+ kind: literal("wrapper-binding"),
7205
+ capName: string()
7206
+ }),
7207
+ object({ kind: literal("recording-config") }),
7208
+ object({ kind: literal("notification-mute") })
7209
+ ]);
7210
+ /**
7211
+ * Why a switch is not offered for this camera. Rendered instead of the
7212
+ * control, never as a dead control — an absent function and a broken one must
7213
+ * not look the same.
7214
+ */
7215
+ var CameraSwitchUnavailableReasonSchema = _enum(["no-provider", "source-unreachable"]);
7216
+ /**
7217
+ * One switch, resolved for one camera.
7218
+ *
7219
+ * `label` and `costWhenOff` travel ON THE WIRE rather than being looked up
7220
+ * client-side: the viewer is a separate repository that does not import
7221
+ * `@camstack/types`, and a cost line duplicated in two clients is a cost line
7222
+ * that will disagree with itself. Five rows per camera is nothing.
7223
+ */
7224
+ var CameraSwitchSchema = object({
7225
+ id: CameraSwitchIdSchema,
7226
+ label: string(),
7227
+ /**
7228
+ * What the operator LOSES while this is off, in one sentence. Required, not
7229
+ * optional: a switch that cannot say what it costs should not ship.
7230
+ */
7231
+ costWhenOff: string(),
7232
+ /** False = do not render a control. `unavailableReason` says why. */
7233
+ available: boolean(),
7234
+ unavailableReason: CameraSwitchUnavailableReasonSchema.optional(),
7235
+ /** Current state. Meaningless when `available` is false — read it as `true`. */
7236
+ enabled: boolean(),
7237
+ authority: CameraSwitchAuthoritySchema
7238
+ });
7239
+ /** The whole group for one camera. */
7240
+ var CameraSwitchGroupSchema = object({
7241
+ deviceId: number().int(),
7242
+ switches: array(CameraSwitchSchema).readonly(),
7243
+ /** Unix ms when the group was composed server-side. */
7244
+ fetchedAt: number()
7245
+ });
7246
+ /**
7054
7247
  * Ops-log — the durable, append-only operations audit shared by the
7055
7248
  * recordings and events management surfaces.
7056
7249
  *
@@ -7069,14 +7262,16 @@ var OpsLogOpSchema = _enum([
7069
7262
  "manual-delete",
7070
7263
  "rescan",
7071
7264
  "retention-run",
7072
- "relocate"
7265
+ "relocate",
7266
+ "orphan-audit"
7073
7267
  ]);
7074
7268
  /** Why the operation ran. */
7075
7269
  var OpsLogReasonSchema = _enum([
7076
7270
  "retention",
7077
7271
  "quota",
7078
7272
  "manual",
7079
- "operator"
7273
+ "operator",
7274
+ "maintenance"
7080
7275
  ]);
7081
7276
  /** One audit row, shared verbatim by both domains. */
7082
7277
  var OpsLogEntrySchema = object({
@@ -8991,6 +9186,100 @@ var RtpSourceSchema = object({
8991
9186
  encoder: string(),
8992
9187
  pipelineKey: string()
8993
9188
  });
9189
+ /**
9190
+ * The encode request — **structured and serialisable, with NO raw-flag escape
9191
+ * hatch.** This is deliberate and it is the one lesson taken from
9192
+ * `getStreamWithCodec`: that method's `outputArgs: string[]` is simultaneously
9193
+ * its extensibility mechanism AND part of `pipelineKeyFor`'s sharing key, so
9194
+ * adding a flag silently forks the shared child, and two consumers that mean
9195
+ * the same thing but spell it differently never share. Here every knob is a
9196
+ * NAMED field: a new requirement becomes a schema field (and a codegen run),
9197
+ * never an opaque array.
9198
+ *
9199
+ * `inputArgs` / `outputArgs` are omitted from the profile for the same reason.
9200
+ * The operator-facing derived-stream transform editor still has them — that is
9201
+ * a different surface (`publishCameraStream({ kind: 'derived' })`) with a
9202
+ * different purpose (reshaping a badly-behaved SOURCE), and it is unchanged.
9203
+ */
9204
+ var EgressEncodeSchema = EncodeProfileSchema.omit({
9205
+ inputArgs: true,
9206
+ outputArgs: true
9207
+ });
9208
+ /**
9209
+ * How the encoder is bounded. `'tight'` is a one-second VBV window for a
9210
+ * consumer whose budget is enforced per second (HomeKit); `'relaxed'` is two
9211
+ * seconds, letting a keyframe spike borrow from the next second (a browser,
9212
+ * an Echo). Named rather than numeric so the INTENT survives.
9213
+ */
9214
+ var EgressRateControlSchema = _enum(["tight", "relaxed"]);
9215
+ var EgressTranscodeRequestSchema = object({
9216
+ deviceId: number().int().nonnegative(),
9217
+ /** Which published stream to read. */
9218
+ source: discriminatedUnion("kind", [object({
9219
+ kind: literal("profile"),
9220
+ profile: CamProfileSchema
9221
+ }), object({
9222
+ kind: literal("cam-stream"),
9223
+ camStreamId: string().min(1)
9224
+ })]),
9225
+ encode: EgressEncodeSchema,
9226
+ rateControl: EgressRateControlSchema.optional(),
9227
+ /**
9228
+ * `-bsf:v`. A consumer that negotiates its OWN SDP (HomeKit) cannot carry
9229
+ * out-of-band extradata and needs `dump_extra` on both the copy and encode
9230
+ * branches. Enumerated, not free text.
9231
+ */
9232
+ bitstreamFilter: _enum([
9233
+ "dump_extra",
9234
+ "h264_mp4toannexb",
9235
+ "hevc_mp4toannexb"
9236
+ ]).optional(),
9237
+ pixelFormat: _enum(["yuv420p", "nv12"]).optional(),
9238
+ /**
9239
+ * Operator/consumer override for decode hardware. ABSENT is the normal case
9240
+ * and the one that matters: the broker then resolves the backend from the
9241
+ * DECODER ADDON's per-node `probedBestHwaccel` (see
9242
+ * `@camstack/types` `ffmpeg/hwaccel.ts`), which is the ranking known to work
9243
+ * on this hardware — never the raw kernel resolver's qsv-first order.
9244
+ */
9245
+ decodeHwAccel: _enum([
9246
+ "auto",
9247
+ "none",
9248
+ "videotoolbox",
9249
+ "vaapi",
9250
+ "qsv",
9251
+ "cuda"
9252
+ ]).optional(),
9253
+ /**
9254
+ * Host to embed in the returned restream `url`. The broker mints hub-local
9255
+ * `127.0.0.1` URLs; a consumer on another node passes a cluster-resolvable
9256
+ * host (`NodeTopologyService.reachableHostByNode`) so the returned URL is
9257
+ * dialable from there. Same contract as `getStreamWithCodec.hostname` —
9258
+ * `substituteRtspHost` rewrites only the dial address, never the restreamer.
9259
+ */
9260
+ hostname: string().optional(),
9261
+ /** Attribution for the broker panel. Never part of the sharing key. */
9262
+ tag: string().optional()
9263
+ });
9264
+ var EgressTranscodeSchema = object({
9265
+ /** Dial-able RTSP url (host-substituted when `hostname` was supplied). */
9266
+ url: string(),
9267
+ /** Release handle. Refcounted — the child dies when the last holder releases. */
9268
+ pipelineKey: string(),
9269
+ videoCodec: _enum(["H264", "H265"]),
9270
+ resolution: object({
9271
+ width: number().int().positive(),
9272
+ height: number().int().positive()
9273
+ }),
9274
+ transcoded: boolean(),
9275
+ encoder: string(),
9276
+ /**
9277
+ * The decode backend the child ACTUALLY ran with — `null` for software.
9278
+ * Returned rather than assumed: a consumer that asked for hardware and got
9279
+ * software needs to be able to see that without reading the broker's logs.
9280
+ */
9281
+ decodeHwAccel: string().nullable()
9282
+ });
8994
9283
  method(object({
8995
9284
  deviceId: number().int().nonnegative(),
8996
9285
  camStreamId: string().min(1),
@@ -9100,6 +9389,15 @@ method(object({
9100
9389
  }), {
9101
9390
  kind: "mutation",
9102
9391
  auth: "admin"
9392
+ }), method(EgressTranscodeRequestSchema, EgressTranscodeSchema, {
9393
+ kind: "mutation",
9394
+ auth: "admin"
9395
+ }), method(object({ pipelineKey: string() }), object({
9396
+ released: boolean(),
9397
+ refcount: number().int().nonnegative()
9398
+ }), {
9399
+ kind: "mutation",
9400
+ auth: "admin"
9103
9401
  }), method(SubscribeAudioChunksInputSchema, SubscribeAudioChunksResultSchema, { kind: "mutation" }), method(object({
9104
9402
  subscriptionId: string(),
9105
9403
  maxCount: number().int().positive().default(8)
@@ -9554,6 +9852,62 @@ method(_void(), EngineInfoSchema), method(object({
9554
9852
  indexes: array(CollectionIndexSchema).readonly().optional()
9555
9853
  }), _void(), { kind: "mutation" });
9556
9854
  /**
9855
+ * Stable UI option list for the `hwaccel` setting. Decoder addons
9856
+ * reuse this for `globalSettingsSchema()` so the dropdown is
9857
+ * identical everywhere. Order: auto → off → common backends by
9858
+ * platform affinity (macOS, NVIDIA, Intel/AMD, Windows, Linux).
9859
+ */
9860
+ var HWACCEL_OPTIONS = [
9861
+ {
9862
+ value: "auto",
9863
+ label: "Auto (defer to probed best)"
9864
+ },
9865
+ {
9866
+ value: "none",
9867
+ label: "Off (software)"
9868
+ },
9869
+ {
9870
+ value: "videotoolbox",
9871
+ label: "VideoToolbox (macOS)"
9872
+ },
9873
+ {
9874
+ value: "cuda",
9875
+ label: "CUDA (NVIDIA)"
9876
+ },
9877
+ {
9878
+ value: "nvdec",
9879
+ label: "NVDEC (NVIDIA legacy)"
9880
+ },
9881
+ {
9882
+ value: "vaapi",
9883
+ label: "VAAPI (Linux Intel/AMD)"
9884
+ },
9885
+ {
9886
+ value: "qsv",
9887
+ label: "QuickSync (Intel)"
9888
+ },
9889
+ {
9890
+ value: "d3d11va",
9891
+ label: "D3D11VA (Windows)"
9892
+ },
9893
+ {
9894
+ value: "dxva2",
9895
+ label: "DXVA2 (Windows legacy)"
9896
+ },
9897
+ {
9898
+ value: "amf",
9899
+ label: "AMF (AMD)"
9900
+ },
9901
+ {
9902
+ value: "vdpau",
9903
+ label: "VDPAU (Linux NVIDIA legacy)"
9904
+ },
9905
+ {
9906
+ value: "drm",
9907
+ label: "DRM (Linux generic)"
9908
+ }
9909
+ ];
9910
+ /**
9557
9911
  * shm ring usage stats for a `frameSink: 'shm'` decoder session —
9558
9912
  * exposed via `decoder.getShmStats` so downstream consumers can
9559
9913
  * observe ring pressure (slot count, byte budget, hit/miss ratio).
@@ -12903,7 +13257,7 @@ var DETECTION_SUB_COLORS = {
12903
13257
  zebra: "#404040",
12904
13258
  giraffe: "#d4a373"
12905
13259
  };
12906
- function titleCase(id) {
13260
+ function titleCase$1(id) {
12907
13261
  return id.split(/[-_ ]/).filter((p) => p.length > 0).map((p) => p.charAt(0).toUpperCase() + p.slice(1)).join(" ");
12908
13262
  }
12909
13263
  var entries = /* @__PURE__ */ new Map();
@@ -12942,7 +13296,7 @@ macro("control", "control", TAXONOMY_COLORS.control, "control", "Control");
12942
13296
  for (const [cocoClass, macroClass] of Object.entries(COCO_TO_MACRO.mapping)) {
12943
13297
  if (macroClass !== "vehicle" && macroClass !== "animal") continue;
12944
13298
  if (entries.has(cocoClass)) continue;
12945
- sub(cocoClass, macroClass, "detection", DETECTION_SUB_COLORS[cocoClass] ?? TAXONOMY_COLORS.genericDetection, cocoClass, titleCase(cocoClass));
13299
+ sub(cocoClass, macroClass, "detection", DETECTION_SUB_COLORS[cocoClass] ?? TAXONOMY_COLORS.genericDetection, cocoClass, titleCase$1(cocoClass));
12946
13300
  }
12947
13301
  sub("package-delivered", "package", "package", TAXONOMY_COLORS.package, "package", "Package delivered");
12948
13302
  sub("package-picked-up", "package", "package", TAXONOMY_COLORS.package, "package", "Package picked up");
@@ -13451,12 +13805,13 @@ var NcConditionsSchema = object({
13451
13805
  * source; otherwise the subject's source must equal it. Legacy records
13452
13806
  * with no stamped source are treated as `pipeline`. The union spans both
13453
13807
  * record kinds — object events carry `pipeline` | `onboard`, synthetic
13454
- * tracks carry `sensor`.
13808
+ * tracks carry `sensor` (a linked device) or `audio` (a D62 audio marker).
13455
13809
  */
13456
13810
  source: _enum([
13457
13811
  "pipeline",
13458
13812
  "onboard",
13459
13813
  "sensor",
13814
+ "audio",
13460
13815
  "any"
13461
13816
  ]).optional(),
13462
13817
  /**
@@ -14032,6 +14387,12 @@ method(object({}), object({ rules: array(NcRuleSchema) }), { auth: "admin" }), m
14032
14387
  }), object({ success: literal(true) }), {
14033
14388
  kind: "mutation",
14034
14389
  auth: "admin"
14390
+ }), method(object({}), object({ mutedDeviceIds: array(number().int()).readonly() }), { auth: "admin" }), method(object({
14391
+ deviceId: number().int(),
14392
+ muted: boolean()
14393
+ }), object({ success: literal(true) }), {
14394
+ kind: "mutation",
14395
+ auth: "admin"
14035
14396
  }), method(object({
14036
14397
  rule: NcRuleInputSchema,
14037
14398
  lookbackMinutes: number().int().min(1).max(1440).default(60)
@@ -14370,12 +14731,60 @@ var TrackAudioLabelSchema = object({
14370
14731
  });
14371
14732
  /**
14372
14733
  * How a track was produced. `pipeline` (default / absent) = the spatial
14373
- * detection+tracking pipeline. `sensor` = a SYNTHETIC track projected from a
14374
- * linked sensor/control state change (no positions; carries a snapshot). The
14375
- * spatial subsystems (tracker association, occupancy count, re-id/embedding,
14376
- * resurrection) MUST skip `sensor` tracks they have no bbox trajectory.
14734
+ * detection+tracking pipeline. Every OTHER value is a SYNTHETIC projection
14735
+ * no positions, a single snapshot, and no bbox trajectory at all:
14736
+ *
14737
+ * - `sensor` — a linked sensor/control device state change.
14738
+ * - `audio` — an audio event on the camera itself that was anomalous for
14739
+ * THAT camera, loud, and heard while nothing visual was happening (D62).
14740
+ *
14741
+ * The spatial subsystems (tracker association, occupancy count, re-id /
14742
+ * embedding, resurrection) MUST skip every synthetic source. Test for that
14743
+ * with `isSpatialTrack`, which allow-lists `pipeline` — a `!== 'sensor'`
14744
+ * check silently readmits every source added after it was written.
14745
+ */
14746
+ var TrackSourceSchema = _enum([
14747
+ "pipeline",
14748
+ "sensor",
14749
+ "audio"
14750
+ ]);
14751
+ /**
14752
+ * Per-track OPERATOR flags — set by hand from the admin UI or the viewer, never
14753
+ * by the pipeline. Spread into `TrackSchema` and `KeyEventSchema` from one place
14754
+ * so the two surfaces cannot drift.
14755
+ *
14756
+ * **Absent ≠ false.** A track that has never been touched omits the field; an
14757
+ * explicitly un-flagged track carries `false`. Legacy rows written before the
14758
+ * columns existed read as absent, and a consumer that needs a boolean should say
14759
+ * `flag === true`, not `flag !== false`.
14760
+ *
14761
+ * What the flags DO is deliberately UNDEFINED at the time of writing: they are
14762
+ * operator curation, and the behaviour they drive will be specified separately.
14763
+ * In particular a `markForTrain` track is NOT pinned against retention — see
14764
+ * `docs/decisions/adr-0059.md` for why that is a store-level change, not a flag.
14765
+ */
14766
+ var TrackFlagFields = {
14767
+ /** Operator marked this track as training material. */
14768
+ markForTrain: boolean().optional(),
14769
+ /** Operator marked this track for diagnostic attention. */
14770
+ debug: boolean().optional()
14771
+ };
14772
+ /**
14773
+ * The write half: a PARTIAL patch. An omitted key is left untouched, so setting
14774
+ * one flag can never clear the other — the toggles are independent and are
14775
+ * driven from three surfaces that do not know about each other.
14776
+ */
14777
+ var TrackFlagsPatchSchema = object(TrackFlagFields);
14778
+ /**
14779
+ * The resolved flag state after a write. Both fields are REQUIRED here (absent
14780
+ * collapses to `false`) so a caller can drive a toggle's checked state off the
14781
+ * mutation result without a re-fetch.
14377
14782
  */
14378
- var TrackSourceSchema = _enum(["pipeline", "sensor"]);
14783
+ var TrackFlagsSchema = object({
14784
+ trackId: string(),
14785
+ markForTrain: boolean(),
14786
+ debug: boolean()
14787
+ });
14379
14788
  var TrackSchema = object({
14380
14789
  trackId: string(),
14381
14790
  deviceId: number(),
@@ -14418,7 +14827,8 @@ var TrackSchema = object({
14418
14827
  /** Normalized 0..1 trajectory envelope (see {@link TrackEnvelopeSchema}).
14419
14828
  * Populated from the persisted envelope columns on historical reads;
14420
14829
  * absent on legacy rows, dims-less tracks and active (in-RAM) tracks. */
14421
- envelope: TrackEnvelopeSchema.optional()
14830
+ envelope: TrackEnvelopeSchema.optional(),
14831
+ ...TrackFlagFields
14422
14832
  });
14423
14833
  var BaseEventFields = {
14424
14834
  id: string(),
@@ -14631,7 +15041,8 @@ var KeyEventSchema = object({
14631
15041
  /** Highest-confidence ObjectEvent id for the track (empty when none). */
14632
15042
  bestEventId: string(),
14633
15043
  /** Track lifetime in ms (lastSeen - firstSeen). */
14634
- windowMs: number().optional()
15044
+ windowMs: number().optional(),
15045
+ ...TrackFlagFields
14635
15046
  });
14636
15047
  object({
14637
15048
  trackId: string(),
@@ -14702,6 +15113,104 @@ var EventPruneCountsSchema = object({
14702
15113
  object: number().int(),
14703
15114
  audio: number().int()
14704
15115
  });
15116
+ /**
15117
+ * Re-embed stored tracks from their key frames.
15118
+ *
15119
+ * The reason this is an operator-callable method and not a migration script:
15120
+ * every knob that decides what a vector MEANS — encoder model, crop margin,
15121
+ * squaring — is only changeable if the existing vectors can be regenerated.
15122
+ * Mixing feature spaces in one index makes cosine scores incomparable, and the
15123
+ * symptom is a quality regression with no visible cause.
15124
+ */
15125
+ var RebuildObjectEmbeddingsInput = object({
15126
+ /** Restrict to one camera. Omit for the whole fleet. */
15127
+ deviceId: number().optional(),
15128
+ since: number().optional(),
15129
+ until: number().optional(),
15130
+ /** Stop after this many tracks; the result reports whether more remain. */
15131
+ maxTracks: number().int().positive().optional(),
15132
+ /**
15133
+ * Run every embedding on THIS node instead of round-robining the fleet.
15134
+ *
15135
+ * Named `executeOnNodeId` and not `nodeId` on purpose: an inline `nodeId`
15136
+ * field in cap args is read by `parent-unowned-call.ts` as a ROUTING PIN, so
15137
+ * calling it that would pin the rebuild REQUEST itself to that node — the
15138
+ * rebuild orchestration lives on the hub, and only the per-track step runs
15139
+ * remotely. This field is data; the per-track pin is applied inside.
15140
+ *
15141
+ * Absent ⇒ round-robin over every online node whose runner can serve the
15142
+ * pinned model.
15143
+ */
15144
+ executeOnNodeId: string().optional(),
15145
+ /**
15146
+ * Milliseconds to wait between tracks; omit for the built-in default, `0` to
15147
+ * run flat out.
15148
+ *
15149
+ * A rebuild is bulk maintenance on hub-main's single thread. Measured
15150
+ * 2026-08-06, an unpaced pass held that thread busy 82.2 s out of 120 and
15151
+ * pushed `nodes.topology` from 0.25 s to 26 s for 43 minutes. The value in
15152
+ * force is logged at start and finish so a deliberately slow pass reads
15153
+ * differently from a stalled one.
15154
+ */
15155
+ pacingMs: number().int().nonnegative().optional()
15156
+ });
15157
+ /**
15158
+ * Result of emptying the CLIP index.
15159
+ *
15160
+ * The clean slate before a policy change: a new crop margin or encoder model
15161
+ * leaves two feature spaces in one index whose cosine scores are not
15162
+ * comparable, so wiping and rebuilding is the only way to be sure every vector
15163
+ * means the same thing.
15164
+ */
15165
+ var WipeObjectEmbeddingsResultSchema = object({ deleted: number() });
15166
+ /**
15167
+ * Acknowledgement that a rebuild STARTED.
15168
+ *
15169
+ * The pass costs ~1s per track — 45 minutes for a 2,600-track fleet — so it
15170
+ * runs detached and this returns immediately. Waiting for it made the client
15171
+ * time out while the work carried on server-side, which is the worst of both:
15172
+ * no result and no way to know it was still going. Poll
15173
+ * `getObjectEmbeddingRebuildStatus` for progress.
15174
+ */
15175
+ var RebuildObjectEmbeddingsResultSchema = object({
15176
+ started: boolean(),
15177
+ /** True when a pass was already running; the new request is ignored. */
15178
+ alreadyRunning: boolean()
15179
+ });
15180
+ var RebuildStatusSchema = object({
15181
+ running: boolean(),
15182
+ scanned: number(),
15183
+ rebuilt: number(),
15184
+ /** Tracks whose key frame is gone — nothing to re-embed from. */
15185
+ missingKeyFrame: number(),
15186
+ /** Tracks with no usable detection box. */
15187
+ missingBbox: number(),
15188
+ /**
15189
+ * Tracks an executing node REFUSED rather than broke on — an unreadable key
15190
+ * frame, a step that threw. Separate from `failed` because the remedy is
15191
+ * different, and because a whole camera silently contributing zero vectors
15192
+ * is the shape of failure a rebuild must never hide.
15193
+ */
15194
+ notRunnable: number(),
15195
+ /**
15196
+ * The pass stopped because NO node could serve the pinned model.
15197
+ *
15198
+ * Distinct from `notRunnable` on purpose: that one says "this track was
15199
+ * refused", this one says "the cluster cannot do this work at all" — every
15200
+ * candidate node either lacks the `clip-embedding` step, lacks a build of the
15201
+ * pinned model for its engine format, or dropped out. The remedy is a model /
15202
+ * engine change, not a per-camera one. Non-zero here always comes with
15203
+ * `complete: false`.
15204
+ */
15205
+ noCapableNode: number(),
15206
+ failed: number(),
15207
+ /** Set once a pass ends: true only when EVERYTHING was covered. */
15208
+ complete: boolean().nullable(),
15209
+ startedAtMs: number().nullable(),
15210
+ finishedAtMs: number().nullable(),
15211
+ /** Present when the pass ended by throwing. */
15212
+ error: string().nullable()
15213
+ });
14705
15214
  DeviceType.Camera, method(object({ deviceId: number() }), array(TrackSchema).readonly()), method(object({
14706
15215
  deviceId: number(),
14707
15216
  trackId: string()
@@ -14765,7 +15274,12 @@ DeviceType.Camera, method(object({ deviceId: number() }), array(TrackSchema).rea
14765
15274
  }), {
14766
15275
  kind: "mutation",
14767
15276
  auth: "admin"
14768
- }), method(object({}), EventStoreFootprintSchema, {
15277
+ }), method(object({
15278
+ /** Log/audit scope only — the trackId is globally unique on its own. */
15279
+ deviceId: number(),
15280
+ trackId: string(),
15281
+ flags: TrackFlagsPatchSchema
15282
+ }), TrackFlagsSchema, { kind: "mutation" }), method(object({}), EventStoreFootprintSchema, {
14769
15283
  kind: "query",
14770
15284
  auth: "admin"
14771
15285
  }), method(object({
@@ -14795,7 +15309,13 @@ DeviceType.Camera, method(object({ deviceId: number() }), array(TrackSchema).rea
14795
15309
  }), array(MediaFileSchema).readonly()), method(object({
14796
15310
  trackId: string(),
14797
15311
  kinds: array(MediaFileKindEnum).optional()
14798
- }), array(MediaFileSchema).readonly()), method(object({ trackId: string() }), array(MediaFileInfoSchema).readonly()), method(SearchObjectEventsInput, array(ScoredObjectEventSchema).readonly()), object({
15312
+ }), array(MediaFileSchema).readonly()), method(object({ trackId: string() }), array(MediaFileInfoSchema).readonly()), method(SearchObjectEventsInput, array(ScoredObjectEventSchema).readonly()), method(object({}), WipeObjectEmbeddingsResultSchema, {
15313
+ kind: "mutation",
15314
+ auth: "admin"
15315
+ }), method(RebuildObjectEmbeddingsInput, RebuildObjectEmbeddingsResultSchema, {
15316
+ kind: "mutation",
15317
+ auth: "admin"
15318
+ }), method(object({}), RebuildStatusSchema), object({
14799
15319
  deviceId: number(),
14800
15320
  timestamp: number(),
14801
15321
  frameWidth: number(),
@@ -15369,6 +15889,53 @@ var DetailResultSchema = object({
15369
15889
  nativeFaceShortSidePx: number().optional()
15370
15890
  });
15371
15891
  /**
15892
+ * Why an executing node REFUSED a stateless step run (`runStatelessStep`).
15893
+ *
15894
+ * A refusal is a first-class answer, not an error, because the caller's next
15895
+ * move depends on WHICH one it is — and because "the pass produced nothing"
15896
+ * must never be reachable without a named, counted cause. The two tiers:
15897
+ *
15898
+ * - **node-level** (`unknown-step`, `model-not-servable`) — this node can
15899
+ * never serve this (step, model) pair. The caller drops it from its rotation
15900
+ * and retries the same work elsewhere; nothing about the work changes.
15901
+ * - **work-level** (`unreadable-frame`, `execution-failed`) — this node is
15902
+ * fine, this one request is not. Retrying it on another node would only
15903
+ * spread the same failure.
15904
+ */
15905
+ var StatelessStepRefusalSchema = _enum([
15906
+ "unknown-step",
15907
+ "model-not-servable",
15908
+ "unreadable-frame",
15909
+ "execution-failed"
15910
+ ]);
15911
+ /**
15912
+ * Answer to `runStatelessStep` — a discriminated union rather than a nullable
15913
+ * result, because `null` is exactly what made the camera-bound detail path
15914
+ * unable to tell "refused" from "never asked".
15915
+ */
15916
+ var RunStatelessStepResultSchema = discriminatedUnion("kind", [object({
15917
+ kind: literal("ran"),
15918
+ /** The node that actually executed it — the pin, echoed back for the log. */
15919
+ nodeId: string(),
15920
+ /**
15921
+ * The model the step ran with.
15922
+ *
15923
+ * The node verified this exact id has a build for the format it dispatched
15924
+ * on BEFORE running, so the executor's format resolution returns it
15925
+ * unchanged. A caller that pinned a model must compare this field and
15926
+ * treat a mismatch as a refusal — the whole point of the pin is that a
15927
+ * pass writes one feature space.
15928
+ */
15929
+ modelId: string(),
15930
+ details: array(DetailResultSchema)
15931
+ }), object({
15932
+ kind: literal("refused"),
15933
+ nodeId: string(),
15934
+ reason: StatelessStepRefusalSchema,
15935
+ /** Human-readable specifics — the format tried, the formats shipped, etc. */
15936
+ detail: string()
15937
+ })]);
15938
+ /**
15372
15939
  * Per-camera tunable ranges + defaults. Single source of truth used
15373
15940
  * by both the Zod data schema (validation + default fallback) and
15374
15941
  * the device settings UI (slider min/max/step). Touch one place and
@@ -15704,10 +16271,46 @@ method(RunnerCameraConfigSchema, object({ success: literal(true) }), { kind: "mu
15704
16271
  }), NativeCropResultSchema.nullable()), method(object({
15705
16272
  deviceId: number(),
15706
16273
  frameHandle: FrameHandleSchema.optional(),
16274
+ /**
16275
+ * FULL FRAME (base64 JPEG). The runner derives the crop rectangle from
16276
+ * `parent.bbox` with the cluster crop convention and cuts it itself —
16277
+ * do NOT pre-crop for this field, that is what `cropJpeg` is.
16278
+ */
16279
+ frameJpeg: string().optional(),
16280
+ /**
16281
+ * PRE-CUT tile (base64 JPEG), used verbatim — NO padding is applied.
16282
+ * The fallback when the lease/session backing the frame is gone and the
16283
+ * caller already holds a crop.
16284
+ */
15707
16285
  cropJpeg: string().optional(),
15708
16286
  parent: DetailParentSchema,
15709
16287
  steps: array(string()).optional()
15710
- }), object({ details: array(DetailResultSchema) }).nullable(), { kind: "mutation" });
16288
+ }), object({ details: array(DetailResultSchema) }).nullable(), { kind: "mutation" }), method(object({
16289
+ /** Catalog step id, e.g. `clip-embedding`. */
16290
+ stepId: string(),
16291
+ /**
16292
+ * REQUIRED model pin. The node runs this exact model or refuses with
16293
+ * `model-not-servable` — it never substitutes a format default, because
16294
+ * a fleet pass that round-robins across nodes would then fill one index
16295
+ * from several encoders.
16296
+ */
16297
+ modelId: string(),
16298
+ /** FULL FRAME, base64 JPEG. The runner cuts — do NOT pre-crop. */
16299
+ frameJpeg: string(),
16300
+ /**
16301
+ * The subject box, NORMALISED [0,1] against `frameJpeg`. Normalised on
16302
+ * purpose: the caller stores boxes against a downscaled analysis frame
16303
+ * while the stored key frame is native-resolution, and the only side
16304
+ * that reliably knows the image's pixel dimensions is the side that
16305
+ * decodes it. Denormalising here removes a second reader of the
16306
+ * dimensions and the class of mismatch that comes with it.
16307
+ */
16308
+ bbox: NativeCropBboxSchema,
16309
+ /** Parent class of the subject (`person`, `vehicle`, …) — carried into the result. */
16310
+ className: string(),
16311
+ /** Camera the pixels came from. Diagnostics + log tags ONLY — never routing. */
16312
+ sourceDeviceId: number()
16313
+ }), RunStatelessStepResultSchema, { kind: "mutation" });
15711
16314
  var CameraPipelineConfigSchema = object({
15712
16315
  engine: PipelineEngineChoiceSchema.optional(),
15713
16316
  steps: array(PipelineStepInputSchema).readonly(),
@@ -16005,6 +16608,20 @@ var CameraStatusSchema = object({
16005
16608
  detection: CameraDetectionStatusSchema.nullable(),
16006
16609
  audio: CameraAudioStatusSchema.nullable(),
16007
16610
  recording: CameraRecordingStatusSchema.nullable(),
16611
+ /**
16612
+ * Per-camera function switches an OPERATOR has turned off
16613
+ * ([D61](../../../../docs/decisions/adr-0067.md)).
16614
+ *
16615
+ * This is the difference between DISABLED and BROKEN. A camera whose
16616
+ * `detection` block reports zero fps and whose `switchedOff` contains
16617
+ * `'object-detection'` was switched off by a person; the same camera with an
16618
+ * empty list is failing. Every status surface must render the two
16619
+ * differently — a quiet camera that looks identical to a dead one is the
16620
+ * silence-reads-as-never-happened trap this repo keeps paying for.
16621
+ *
16622
+ * Empty when nothing is off. Never contains a switch no provider offers.
16623
+ */
16624
+ switchedOff: array(CameraSwitchIdSchema).readonly(),
16008
16625
  /** Unix timestamp (ms) when this snapshot was composed server-side. */
16009
16626
  fetchedAt: number()
16010
16627
  });
@@ -16173,7 +16790,14 @@ method(object({
16173
16790
  }), method(object({
16174
16791
  deviceId: number(),
16175
16792
  agentNodeId: string().optional()
16176
- }), CameraPipelineConfigSchema), method(object({ deviceId: number() }), CameraStatusSchema), method(object({ deviceIds: array(number()).optional() }), array(CameraStatusSchema).readonly()), method(_void(), array(PipelineTemplateSchema).readonly()), method(object({
16793
+ }), CameraPipelineConfigSchema), method(object({ deviceId: number() }), CameraSwitchGroupSchema), method(object({
16794
+ deviceId: number(),
16795
+ switchId: CameraSwitchIdSchema,
16796
+ enabled: boolean()
16797
+ }), CameraSwitchGroupSchema, {
16798
+ kind: "mutation",
16799
+ auth: "admin"
16800
+ }), method(object({ deviceId: number() }), CameraStatusSchema), method(object({ deviceIds: array(number()).optional() }), array(CameraStatusSchema).readonly()), method(_void(), array(PipelineTemplateSchema).readonly()), method(object({
16177
16801
  name: string(),
16178
16802
  description: string().optional(),
16179
16803
  config: CameraPipelineConfigSchema
@@ -17029,6 +17653,24 @@ var VectorDeleteByFilterInputSchema = object({
17029
17653
  filter: VectorFilterSchema
17030
17654
  });
17031
17655
  var VectorDeleteResultSchema = object({ deleted: number() });
17656
+ var VectorGetInputSchema = object({
17657
+ index: string(),
17658
+ ids: array(string())
17659
+ });
17660
+ /**
17661
+ * Metadata for the requested ids, WITHOUT their vectors.
17662
+ *
17663
+ * The only caller is a best-of gate that compares a candidate's confidence
17664
+ * against the stored one, and shipping 512 floats back to answer "is 0.91 >
17665
+ * 0.87" would undo the point of the compact encoding. Ids with no row are
17666
+ * simply absent — a caller distinguishing "not stored" from "stored" reads the
17667
+ * length, and a null placeholder would invite a `?? 0` that treats a missing
17668
+ * row as confidence zero.
17669
+ */
17670
+ var VectorGetResultSchema = object({ items: array(object({
17671
+ id: string(),
17672
+ metadata: VectorMetadataSchema
17673
+ })) });
17032
17674
  var VectorStatsInputSchema = object({ index: string() });
17033
17675
  var VectorStatsResultSchema = object({
17034
17676
  /** Provider id, so an operator can tell brute force from an ANN index. */
@@ -17041,7 +17683,7 @@ var VectorStatsResultSchema = object({
17041
17683
  /** False when the backend ranks approximately. */
17042
17684
  exact: boolean()
17043
17685
  });
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);
17686
+ 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
17687
  /**
17046
17688
  * `videoclips` — the unified, navigable-clip surface for a camera.
17047
17689
  *
@@ -25112,6 +25754,12 @@ Object.freeze({
25112
25754
  addonId: null,
25113
25755
  access: "view"
25114
25756
  },
25757
+ "notificationRules.listDeviceMutes": {
25758
+ capName: "notification-rules",
25759
+ capScope: "system",
25760
+ addonId: null,
25761
+ access: "view"
25762
+ },
25115
25763
  "notificationRules.listRules": {
25116
25764
  capName: "notification-rules",
25117
25765
  capScope: "system",
@@ -25130,6 +25778,12 @@ Object.freeze({
25130
25778
  addonId: null,
25131
25779
  access: "create"
25132
25780
  },
25781
+ "notificationRules.setDeviceMuted": {
25782
+ capName: "notification-rules",
25783
+ capScope: "system",
25784
+ addonId: null,
25785
+ access: "create"
25786
+ },
25133
25787
  "notificationRules.setRuleEnabled": {
25134
25788
  capName: "notification-rules",
25135
25789
  capScope: "system",
@@ -25304,6 +25958,12 @@ Object.freeze({
25304
25958
  addonId: null,
25305
25959
  access: "view"
25306
25960
  },
25961
+ "pipelineAnalytics.getObjectEmbeddingRebuildStatus": {
25962
+ capName: "pipeline-analytics",
25963
+ capScope: "device",
25964
+ addonId: null,
25965
+ access: "view"
25966
+ },
25307
25967
  "pipelineAnalytics.getObjectEvents": {
25308
25968
  capName: "pipeline-analytics",
25309
25969
  capScope: "device",
@@ -25382,6 +26042,12 @@ Object.freeze({
25382
26042
  addonId: null,
25383
26043
  access: "create"
25384
26044
  },
26045
+ "pipelineAnalytics.rebuildObjectEmbeddings": {
26046
+ capName: "pipeline-analytics",
26047
+ capScope: "device",
26048
+ addonId: null,
26049
+ access: "create"
26050
+ },
25385
26051
  "pipelineAnalytics.relocateMedia": {
25386
26052
  capName: "pipeline-analytics",
25387
26053
  capScope: "device",
@@ -25394,12 +26060,24 @@ Object.freeze({
25394
26060
  addonId: null,
25395
26061
  access: "view"
25396
26062
  },
26063
+ "pipelineAnalytics.setTrackFlags": {
26064
+ capName: "pipeline-analytics",
26065
+ capScope: "device",
26066
+ addonId: null,
26067
+ access: "create"
26068
+ },
25397
26069
  "pipelineAnalytics.wipeAllAnalytics": {
25398
26070
  capName: "pipeline-analytics",
25399
26071
  capScope: "device",
25400
26072
  addonId: null,
25401
26073
  access: "delete"
25402
26074
  },
26075
+ "pipelineAnalytics.wipeObjectEmbeddings": {
26076
+ capName: "pipeline-analytics",
26077
+ capScope: "device",
26078
+ addonId: null,
26079
+ access: "delete"
26080
+ },
25403
26081
  "pipelineExecutor.cacheFrameInPool": {
25404
26082
  capName: "pipeline-executor",
25405
26083
  capScope: "system",
@@ -25694,6 +26372,12 @@ Object.freeze({
25694
26372
  addonId: null,
25695
26373
  access: "view"
25696
26374
  },
26375
+ "pipelineOrchestrator.getCameraSwitches": {
26376
+ capName: "pipeline-orchestrator",
26377
+ capScope: "system",
26378
+ addonId: null,
26379
+ access: "view"
26380
+ },
25697
26381
  "pipelineOrchestrator.getCapabilityBindings": {
25698
26382
  capName: "pipeline-orchestrator",
25699
26383
  capScope: "system",
@@ -25826,6 +26510,12 @@ Object.freeze({
25826
26510
  addonId: null,
25827
26511
  access: "create"
25828
26512
  },
26513
+ "pipelineOrchestrator.setCameraSwitch": {
26514
+ capName: "pipeline-orchestrator",
26515
+ capScope: "system",
26516
+ addonId: null,
26517
+ access: "create"
26518
+ },
25829
26519
  "pipelineOrchestrator.setCapabilityBinding": {
25830
26520
  capName: "pipeline-orchestrator",
25831
26521
  capScope: "system",
@@ -25916,6 +26606,12 @@ Object.freeze({
25916
26606
  addonId: null,
25917
26607
  access: "create"
25918
26608
  },
26609
+ "pipelineRunner.runStatelessStep": {
26610
+ capName: "pipeline-runner",
26611
+ capScope: "system",
26612
+ addonId: null,
26613
+ access: "create"
26614
+ },
25919
26615
  "plateGallery.assignPlate": {
25920
26616
  capName: "plate-gallery",
25921
26617
  capScope: "system",
@@ -26732,6 +27428,12 @@ Object.freeze({
26732
27428
  addonId: null,
26733
27429
  access: "create"
26734
27430
  },
27431
+ "streamBroker.acquireEgressTranscode": {
27432
+ capName: "stream-broker",
27433
+ capScope: "system",
27434
+ addonId: null,
27435
+ access: "create"
27436
+ },
26735
27437
  "streamBroker.assignProfile": {
26736
27438
  capName: "stream-broker",
26737
27439
  capScope: "system",
@@ -26840,6 +27542,12 @@ Object.freeze({
26840
27542
  addonId: null,
26841
27543
  access: "create"
26842
27544
  },
27545
+ "streamBroker.releaseEgressTranscode": {
27546
+ capName: "stream-broker",
27547
+ capScope: "system",
27548
+ addonId: null,
27549
+ access: "create"
27550
+ },
26843
27551
  "streamBroker.releaseStreamWithCodec": {
26844
27552
  capName: "stream-broker",
26845
27553
  capScope: "system",
@@ -27320,6 +28028,12 @@ Object.freeze({
27320
28028
  addonId: null,
27321
28029
  access: "delete"
27322
28030
  },
28031
+ "vectorStore.getByIds": {
28032
+ capName: "vector-store",
28033
+ capScope: "system",
28034
+ addonId: null,
28035
+ access: "view"
28036
+ },
27323
28037
  "vectorStore.query": {
27324
28038
  capName: "vector-store",
27325
28039
  capScope: "system",
@@ -27594,37 +28308,30 @@ TimelapseRuleInputSchema.extend({
27594
28308
  createdAt: number(),
27595
28309
  updatedAt: number()
27596
28310
  });
27597
- /**
27598
- * Deterministic SHA-256 hash of an arbitrary serialisable value. The
27599
- * canonical form sorts object keys alphabetically at every depth so two
27600
- * structurally-equal inputs with different key insertion orders produce
27601
- * the same hash. Returns a 64-char lowercase hex digest.
27602
- *
27603
- * Used by export adapters (Alexa, HAP) to short-circuit re-discovery /
27604
- * accessory-rebuild work when the upstream shape is byte-identical to
27605
- * the last applied state — preventing user-visible "re-discovery"
27606
- * notifications on every addon-runner respawn. Each respawn re-fires
27607
- * `DeviceBindingsChanged` for every cap registration, which without
27608
- * this guard would propagate redundant pushes.
27609
- *
27610
- * Note: this is a SYMPTOMATIC fix layered on top of the binding-change
27611
- * subscription. The proper fix is a single "device ready" lifecycle
27612
- * barrier so exports react only when the full cap set has landed
27613
- * tracked separately for post-HA-integration work.
27614
- */
27615
- function canonicalHash(value) {
27616
- const canonical = JSON.stringify(value, replaceWithSortedKeys);
27617
- return createHash("sha256").update(canonical ?? "").digest("hex");
27618
- }
27619
- function replaceWithSortedKeys(_key, value) {
27620
- if (value && typeof value === "object" && !Array.isArray(value)) {
27621
- const obj = value;
27622
- const out = {};
27623
- for (const k of Object.keys(obj).toSorted()) out[k] = obj[k];
27624
- return out;
27625
- }
27626
- return value;
27627
- }
28311
+ object({
28312
+ /**
28313
+ * Fraction of the box's own size added on EACH side before cutting.
28314
+ *
28315
+ * CLIP is trained on natural images WITH surroundings; a pixel-tight crop
28316
+ * removes exactly the context it is strongest on (a dog cut to its outline
28317
+ * is a dark blob). The right value is an empirical question, which is why it
28318
+ * is a setting: 0 / 0.15 / 0.2 / 0.5 are the interesting points.
28319
+ */
28320
+ paddingRatio: number().min(0).max(4),
28321
+ /**
28322
+ * Square the window (in PIXELS) before cutting.
28323
+ *
28324
+ * CLIP's input is square, so a tall bbox resized straight to NxN is squashed
28325
+ * a standing person becomes a shape the model never saw. Squaring costs
28326
+ * extra background, which is context the model wants anyway. Off by default
28327
+ * because the live path has never squared and the stored index reflects that.
28328
+ */
28329
+ square: boolean()
28330
+ });
28331
+ ({
28332
+ paddingRatio: .15,
28333
+ square: false
28334
+ }).paddingRatio;
27628
28335
  /**
27629
28336
  * Compute the stable 64-char lowercase-hex fingerprint of a device's
27630
28337
  * export-relevant shape. Two structurally-equal shapes (any feature order,
@@ -27796,6 +28503,110 @@ function firstExposedAccessorySetupUri(exposed, logger) {
27796
28503
  }
27797
28504
  }
27798
28505
  }
28506
+ /**
28507
+ * hap-nodejs' `checkName` regex, verbatim.
28508
+ *
28509
+ * Duplicated rather than imported because it is `@private Private API` in that
28510
+ * package and not exported. Duplicating a private regex is a liability, so the
28511
+ * guard against drift is behavioural, not textual: `service-naming.spec.ts`
28512
+ * builds real services and asserts hap-nodejs emits ZERO characteristic
28513
+ * warnings — if this expression ever diverges from theirs, that test fails.
28514
+ */
28515
+ var HAP_NAME_PATTERN = /^[\p{L}\p{N}][\p{L}\p{N}\p{Zs}’'&!._:;()/,-]*[\p{L}\p{N}]$/u;
28516
+ /** Characters HAP tolerates INSIDE a name. Anything else becomes a space. */
28517
+ var HAP_NAME_INNER = /[^\p{L}\p{N}\p{Zs}’'&!._:;()/,-]/gu;
28518
+ /** Would hap-nodejs accept this as a `Name` characteristic value? */
28519
+ function isHapServiceName(value) {
28520
+ return HAP_NAME_PATTERN.test(value);
28521
+ }
28522
+ /**
28523
+ * Build one HAP-valid service name from device-derived parts.
28524
+ *
28525
+ * Empty and absent parts are dropped rather than joined, so a missing role
28526
+ * never produces a double space. `fallback` is used ONLY when nothing
28527
+ * device-derived survives sanitisation — it is the last resort, not the
28528
+ * default, because a name that says nothing about the device is the defect
28529
+ * this module exists to end.
28530
+ */
28531
+ function hapServiceName(parts, fallback) {
28532
+ const trimmed = trimToHapName(parts.map((part) => typeof part === "string" ? part : "").map((part) => part.replace(HAP_NAME_INNER, " ")).join(" ").replace(/\s+/gu, " ").trim());
28533
+ if (trimmed !== null) return trimmed;
28534
+ return trimToHapName(fallback.replace(HAP_NAME_INNER, " ").replace(/\s+/gu, " ").trim()) ?? "";
28535
+ }
28536
+ /**
28537
+ * The privacy-mask switch.
28538
+ *
28539
+ * The camera's own name carries the meaning; "Privacy" only says which of the
28540
+ * camera's switches this is. It used to be the WHOLE name, which is why the
28541
+ * operator saw a switch that named neither its camera nor, with two cameras
28542
+ * exposed, which camera it belonged to.
28543
+ */
28544
+ function privacyServiceName(deviceName) {
28545
+ return hapServiceName([deviceName, PRIVACY_SUFFIX], PRIVACY_SUFFIX);
28546
+ }
28547
+ /**
28548
+ * Deliberately not localised, and deliberately not a translation table.
28549
+ *
28550
+ * The device half of the name is the operator's own text and arrives in the
28551
+ * operator's language. This half names a camstack capability (`privacy-mask`)
28552
+ * and there is no locale in an addon's context to resolve it against; the word
28553
+ * is also identical in the operator's language. A translation layer for one
28554
+ * word would be the kind of leftover that reads as verification.
28555
+ */
28556
+ var PRIVACY_SUFFIX = "Privacy";
28557
+ /**
28558
+ * An accessory child (siren, floodlight, spotlight) rendered as a service on
28559
+ * the parent camera.
28560
+ *
28561
+ * The child's OWN stored name wins. It is the string the operator typed, in
28562
+ * the operator's language, and the previous rule threw it away: `role` was
28563
+ * consulted first and title-cased, so every siren on the fleet published as
28564
+ * the English word "Siren" no matter what the operator had called it.
28565
+ *
28566
+ * The parent name is prefixed only when the child's name does not already
28567
+ * carry it — providers name children both ways ("Sirena" and "Videocamera
28568
+ * cucina Sirena") and the result must be one form, not two.
28569
+ */
28570
+ function childServiceName(parentName, child) {
28571
+ const own = child.name.trim();
28572
+ if (own.length > 0) return mentions(own, parentName) ? hapServiceName([own], parentName) : hapServiceName([parentName, own], own);
28573
+ return hapServiceName([parentName, typeof child.role === "string" ? titleCase(child.role) : ""], parentName);
28574
+ }
28575
+ /**
28576
+ * A PTZ action switch.
28577
+ *
28578
+ * The action labels themselves stay in `ptz-labels.ts` — they name a HomeKit
28579
+ * control, not a device — but the service is still qualified by the camera, so
28580
+ * a home with two PTZ cameras does not publish two switches called "Preset
28581
+ * ingresso".
28582
+ */
28583
+ function ptzServiceName(deviceName, actionLabel) {
28584
+ return hapServiceName([deviceName, actionLabel], actionLabel);
28585
+ }
28586
+ /** Does `name` already contain `parentName` as a whole word? */
28587
+ function mentions(name, parentName) {
28588
+ const needle = parentName.trim().toLowerCase();
28589
+ if (needle.length === 0) return true;
28590
+ return name.toLowerCase().includes(needle);
28591
+ }
28592
+ /**
28593
+ * Truncate to the HAP ceiling and shave any leading/trailing character the
28594
+ * pattern forbids. Returns `null` when nothing usable is left — the caller
28595
+ * decides what to do with that, because "fall back" and "drop the part" are
28596
+ * different answers.
28597
+ */
28598
+ function trimToHapName(value) {
28599
+ let out = value.length > 64 ? value.slice(0, 64) : value;
28600
+ while (out.length > 0 && !isAlphanumeric(out[out.length - 1])) out = out.slice(0, -1);
28601
+ while (out.length > 0 && !isAlphanumeric(out[0])) out = out.slice(1);
28602
+ return isHapServiceName(out) ? out : null;
28603
+ }
28604
+ function isAlphanumeric(ch) {
28605
+ return ch !== void 0 && /[\p{L}\p{N}]/u.test(ch);
28606
+ }
28607
+ function titleCase(raw) {
28608
+ return raw.split(/[-_\s]+/u).filter((part) => part.length > 0).map((part) => part.charAt(0).toUpperCase() + part.slice(1)).join(" ");
28609
+ }
27799
28610
  //#endregion
27800
28611
  //#region src/mappers/builders/battery.ts
27801
28612
  /**
@@ -27820,7 +28631,7 @@ var LOW_BATTERY_THRESHOLD_PCT = 20;
27820
28631
  async function buildBattery(bctx) {
27821
28632
  const { ctx, accessory, proxy, numericDeviceId, displayName } = bctx;
27822
28633
  const log = ctx.logger.withTags({ deviceId: numericDeviceId });
27823
- const service = accessory.addService(Service.Battery, displayName);
28634
+ const service = accessory.addService(Service.Battery, hapServiceName([displayName], `Camera ${numericDeviceId}`));
27824
28635
  try {
27825
28636
  const status = await proxy.battery?.getStatus({});
27826
28637
  if (status) applyToService(service, status);
@@ -38150,13 +38961,1035 @@ var require_src = /* @__PURE__ */ __commonJSMin(((exports) => {
38150
38961
  __exportStar(require_util(), exports);
38151
38962
  }));
38152
38963
  //#endregion
38153
- //#region src/mappers/builders/camera-streams.ts
38964
+ //#region src/mappers/builders/rtcp-gate.ts
38154
38965
  var import_src = require_src();
38966
+ /** `packet@42ms` / `timeout@1000ms` — one compact log field. */
38967
+ function formatRtcpGate(resolution) {
38968
+ if (resolution === null) return "pending";
38969
+ return `${resolution.reason}@${resolution.waitedMs}ms`;
38970
+ }
38971
+ /**
38972
+ * Resolve as soon as ANY packet arrives on `socket`, or after `timeoutMs`.
38973
+ * One-shot: the listener is detached on either path.
38974
+ */
38975
+ function makeRtcpGate(socket, timeoutMs) {
38976
+ const armedAt = Date.now();
38977
+ return new Promise((resolve) => {
38978
+ const onMessage = () => {
38979
+ socket.removeListener("message", onMessage);
38980
+ clearTimeout(timer);
38981
+ resolve({
38982
+ reason: "packet",
38983
+ waitedMs: Date.now() - armedAt
38984
+ });
38985
+ };
38986
+ const timer = setTimeout(() => {
38987
+ socket.removeListener("message", onMessage);
38988
+ resolve({
38989
+ reason: "timeout",
38990
+ waitedMs: Date.now() - armedAt
38991
+ });
38992
+ }, timeoutMs);
38993
+ socket.on("message", onMessage);
38994
+ });
38995
+ }
38996
+ /** V/P/RC byte + PT byte + 16-bit length. */
38997
+ var RTCP_HEADER_BYTES = 4;
38998
+ /** SSRC of the packet sender, first field of both SR and RR bodies. */
38999
+ var RTCP_SENDER_SSRC_BYTES = 4;
39000
+ /** NTP + RTP timestamp, packet count, octet count — present only on an SR. */
39001
+ var RTCP_SENDER_INFO_BYTES = 20;
39002
+ /** One report block: SSRC_n, loss, highest seq, jitter, LSR, DLSR. */
39003
+ var RTCP_REPORT_BLOCK_BYTES = 24;
39004
+ /** `fraction lost` is an 8-bit fixed-point fraction of 256. */
39005
+ var FRACTION_LOST_DENOMINATOR = 256;
39006
+ /** `delay since last SR` counts 1/65536 of a second. */
39007
+ var DLSR_UNITS_PER_SECOND = 65536;
39008
+ /** `cumulative number of packets lost` is a SIGNED 24-bit field. */
39009
+ var SIGNED_24_SIGN_BIT = 8388608;
39010
+ var SIGNED_24_MODULUS = 16777216;
39011
+ function readSigned24(packet, at) {
39012
+ const raw = packet[at] << 16 | packet[at + 1] << 8 | packet[at + 2];
39013
+ return raw >= SIGNED_24_SIGN_BIT ? raw - SIGNED_24_MODULUS : raw;
39014
+ }
39015
+ function readReportBlock(packet, at) {
39016
+ const fractionLostRaw = packet[at + 4];
39017
+ const dlsr = packet.readUInt32BE(at + 20);
39018
+ return {
39019
+ aboutSsrc: packet.readUInt32BE(at),
39020
+ fractionLostRaw,
39021
+ fractionLostPct: Math.round(fractionLostRaw / FRACTION_LOST_DENOMINATOR * 1e3) / 10,
39022
+ cumulativePacketsLost: readSigned24(packet, at + 5),
39023
+ extendedHighestSequence: packet.readUInt32BE(at + 8),
39024
+ jitter: packet.readUInt32BE(at + 12),
39025
+ lastSrTimestamp: packet.readUInt32BE(at + 16),
39026
+ delaySinceLastSrMs: Math.round(dlsr / DLSR_UNITS_PER_SECOND * 1e3)
39027
+ };
39028
+ }
39029
+ /**
39030
+ * Walk a (possibly compound) RTCP datagram and extract every report block.
39031
+ *
39032
+ * An RR is routinely bundled behind an SDES, and a controller with nothing to
39033
+ * report yet sends an RR with a reception-report count of zero. Both are
39034
+ * normal; neither is a failure. What IS a failure is a packet whose declared
39035
+ * length runs past the buffer or whose report count exceeds its own body —
39036
+ * those get named so a decrypt fault cannot masquerade as silence.
39037
+ */
39038
+ function parseCompoundRtcp(packet) {
39039
+ if (packet.length === 0) return {
39040
+ ok: false,
39041
+ failure: "empty",
39042
+ atOffset: 0
39043
+ };
39044
+ const packetTypes = [];
39045
+ const reports = [];
39046
+ let offset = 0;
39047
+ while (offset < packet.length) {
39048
+ if (packet.length - offset < RTCP_HEADER_BYTES) return {
39049
+ ok: false,
39050
+ failure: "short-header",
39051
+ atOffset: offset
39052
+ };
39053
+ const firstByte = packet[offset];
39054
+ if ((firstByte >> 6 & 3) !== 2) return {
39055
+ ok: false,
39056
+ failure: "bad-version",
39057
+ atOffset: offset
39058
+ };
39059
+ const reportCount = firstByte & 31;
39060
+ const packetType = packet[offset + 1];
39061
+ const totalBytes = (packet.readUInt16BE(offset + 2) + 1) * 4;
39062
+ if (offset + totalBytes > packet.length) return {
39063
+ ok: false,
39064
+ failure: "length-overrun",
39065
+ atOffset: offset
39066
+ };
39067
+ packetTypes.push(packetType);
39068
+ if (packetType === 201 || packetType === 200) {
39069
+ const senderSsrcAt = offset + RTCP_HEADER_BYTES;
39070
+ const blocksAt = senderSsrcAt + RTCP_SENDER_SSRC_BYTES + (packetType === 200 ? RTCP_SENDER_INFO_BYTES : 0);
39071
+ if (blocksAt + reportCount * RTCP_REPORT_BLOCK_BYTES > offset + totalBytes) return {
39072
+ ok: false,
39073
+ failure: "truncated-body",
39074
+ atOffset: offset
39075
+ };
39076
+ const blocks = [];
39077
+ for (let index = 0; index < reportCount; index += 1) blocks.push(readReportBlock(packet, blocksAt + index * RTCP_REPORT_BLOCK_BYTES));
39078
+ reports.push({
39079
+ packetType,
39080
+ reporterSsrc: packet.readUInt32BE(senderSsrcAt),
39081
+ blocks
39082
+ });
39083
+ }
39084
+ offset += totalBytes;
39085
+ }
39086
+ return {
39087
+ ok: true,
39088
+ packetTypes,
39089
+ reports
39090
+ };
39091
+ }
39092
+ function emptyReceiverReportTally() {
39093
+ return {
39094
+ reportsParsed: 0,
39095
+ blocksParsed: 0,
39096
+ unreadable: 0,
39097
+ lastFractionLostPct: null,
39098
+ worstFractionLostPct: null,
39099
+ lastCumulativePacketsLost: null,
39100
+ maxCumulativePacketsLost: null,
39101
+ lastJitter: null,
39102
+ maxJitter: null,
39103
+ lastExtendedHighestSequence: null
39104
+ };
39105
+ }
39106
+ function maxOrValue(previous, next) {
39107
+ return previous === null ? next : Math.max(previous, next);
39108
+ }
39109
+ /** Fold one report block into the tally. Returns a new tally. */
39110
+ function applyReceiverReportBlock(tally, block) {
39111
+ return {
39112
+ ...tally,
39113
+ blocksParsed: tally.blocksParsed + 1,
39114
+ lastFractionLostPct: block.fractionLostPct,
39115
+ worstFractionLostPct: maxOrValue(tally.worstFractionLostPct, block.fractionLostPct),
39116
+ lastCumulativePacketsLost: block.cumulativePacketsLost,
39117
+ maxCumulativePacketsLost: maxOrValue(tally.maxCumulativePacketsLost, block.cumulativePacketsLost),
39118
+ lastJitter: block.jitter,
39119
+ maxJitter: maxOrValue(tally.maxJitter, block.jitter),
39120
+ lastExtendedHighestSequence: block.extendedHighestSequence
39121
+ };
39122
+ }
39123
+ /** Book an RTCP datagram we could not read. Counted, never discarded silently. */
39124
+ function recordUnreadableRtcp(tally) {
39125
+ return {
39126
+ ...tally,
39127
+ unreadable: tally.unreadable + 1
39128
+ };
39129
+ }
39130
+ /**
39131
+ * Parse one decrypted RTCP datagram and fold it into a leg's tally.
39132
+ *
39133
+ * This is the seam the delegate calls: it owns SRTCP decryption and logging,
39134
+ * this owns everything that can be asserted from bytes alone. A malformed
39135
+ * packet returns a failure and an incremented `unreadable` — it never throws,
39136
+ * because this runs on a UDP `message` handler where a throw would take the
39137
+ * session with it.
39138
+ */
39139
+ function ingestDecryptedRtcp(plaintext, tally) {
39140
+ const parsed = parseCompoundRtcp(plaintext);
39141
+ if (!parsed.ok) return {
39142
+ tally: recordUnreadableRtcp(tally),
39143
+ reports: [],
39144
+ failure: parsed.failure
39145
+ };
39146
+ let next = tally;
39147
+ for (const report of parsed.reports) {
39148
+ if (report.blocks.length === 0) continue;
39149
+ next = {
39150
+ ...next,
39151
+ reportsParsed: next.reportsParsed + 1
39152
+ };
39153
+ for (const block of report.blocks) next = applyReceiverReportBlock(next, block);
39154
+ }
39155
+ return {
39156
+ tally: next,
39157
+ reports: parsed.reports,
39158
+ failure: null
39159
+ };
39160
+ }
39161
+ //#endregion
39162
+ //#region src/mappers/builders/stream-bitrate.ts
39163
+ /**
39164
+ * Fraction of the negotiated ceiling we actually aim the encoder at.
39165
+ *
39166
+ * `max_bit_rate` is what the controller budgeted for the stream; what crosses
39167
+ * the wire is the encoded payload PLUS its packetisation. At `mtu = 1378` each
39168
+ * packet carries a 12-byte RTP header and a 10-byte SRTP auth tag, and the
39169
+ * datagram adds 8 (UDP) + 20 (IPv4) — 50 bytes on ~1378, i.e. **3.6 %**. The
39170
+ * remaining ~6 % is margin for a VBV overshoot inside the buffer window.
39171
+ * Reserving it is the difference between "at the ceiling" and "over it".
39172
+ */
39173
+ var BITRATE_HEADROOM = .9;
39174
+ /** Encoder slots a `stream-params` provider exposes, in the cap's own order. */
39175
+ var ENCODER_PROFILE_KEYS = [
39176
+ "main",
39177
+ "sub",
39178
+ "ext"
39179
+ ];
39180
+ /**
39181
+ * Resolve every profile slot's rate from the camera's configuration, with the
39182
+ * broker's flow reading kept alongside it as a lower bound.
39183
+ *
39184
+ * A slot with neither simply carries two nulls — absence is never rendered as
39185
+ * a number, because a wrong number here silently re-creates the overshoot this
39186
+ * module exists to end.
39187
+ */
39188
+ function resolveProfileBitrates(input) {
39189
+ const camStreamById = /* @__PURE__ */ new Map();
39190
+ for (const stream of input.camStreams) camStreamById.set(stream.camStreamId, stream);
39191
+ const measuredByProfile = /* @__PURE__ */ new Map();
39192
+ for (const choice of input.choices) {
39193
+ if (choice.target.kind !== "profile") continue;
39194
+ const plausible = plausibleMeasured(choice.bitrateKbps);
39195
+ if (plausible !== null) measuredByProfile.set(choice.target.profile, plausible);
39196
+ }
39197
+ const out = /* @__PURE__ */ new Map();
39198
+ for (const slot of input.slots) {
39199
+ const source = slot.sourceCamStreamId === null ? void 0 : camStreamById.get(slot.sourceCamStreamId);
39200
+ out.set(slot.profile, {
39201
+ profile: slot.profile,
39202
+ publishedKbps: publishedRateFor(slot, source, input.streamParams),
39203
+ measuredKbps: measuredByProfile.get(slot.profile) ?? null
39204
+ });
39205
+ }
39206
+ for (const [profile, measured] of measuredByProfile) {
39207
+ if (out.has(profile)) continue;
39208
+ out.set(profile, {
39209
+ profile,
39210
+ publishedKbps: null,
39211
+ measuredKbps: measured
39212
+ });
39213
+ }
39214
+ return out;
39215
+ }
39216
+ /**
39217
+ * The rate the encoder may actually use, or `null` when the controller
39218
+ * negotiated no usable ceiling.
39219
+ */
39220
+ function budgetForNegotiatedRate(negotiatedMaxBitrateKbps) {
39221
+ if (!Number.isFinite(negotiatedMaxBitrateKbps) || negotiatedMaxBitrateKbps <= 0) return null;
39222
+ const budget = Math.floor(negotiatedMaxBitrateKbps * BITRATE_HEADROOM);
39223
+ return budget > 0 ? budget : null;
39224
+ }
39225
+ /**
39226
+ * Does this slot fit?
39227
+ *
39228
+ * Only the CONFIGURED rate can answer yes. The measured rate is a lower bound,
39229
+ * so it is allowed to answer no — including against an optimistic publication.
39230
+ */
39231
+ function classifyBitrateFit(evidence, budgetKbps) {
39232
+ if (evidence === void 0) return "unknown";
39233
+ const { publishedKbps, measuredKbps } = evidence;
39234
+ if (measuredKbps !== null && measuredKbps > budgetKbps) return "over-budget";
39235
+ if (publishedKbps === null) return "unknown";
39236
+ return publishedKbps <= budgetKbps ? "fits" : "over-budget";
39237
+ }
39238
+ /**
39239
+ * Pick the stream to serve, and decide whether it can be passed through.
39240
+ *
39241
+ * The selection runs the SAME `pickPreferredRtspEntry` the advertisement is
39242
+ * derived from (D51) — only the candidate set narrows. When at least one
39243
+ * pass-through-capable slot fits the budget, the picker resolves the target
39244
+ * resolution among those and we copy. Otherwise the picker resolves among ALL
39245
+ * entries — so the transcode decodes the slot closest to the negotiated
39246
+ * resolution rather than the largest one on the camera — and we re-encode.
39247
+ *
39248
+ * A pinned `streamPreference` is never overridden by the budget: the pinned
39249
+ * slot is transcoded rather than swapped for a cheaper one.
39250
+ *
39251
+ * Returns `null` when nothing is publishable at all.
39252
+ */
39253
+ function selectStreamForBudget(input) {
39254
+ const budgetKbps = budgetForNegotiatedRate(input.negotiatedMaxBitrateKbps);
39255
+ const notes = fitNotes(input.entries, input.bitrates, budgetKbps);
39256
+ const fallback = pickPreferredRtspEntry(input.entries, input.pref, input.deviceId, { targetResolution: input.targetResolution });
39257
+ if (fallback === null) return null;
39258
+ const fallbackProfile = toCamProfile$1(fallback.profileId);
39259
+ if (budgetKbps === null) {
39260
+ const base = {
39261
+ picked: fallback,
39262
+ profile: fallbackProfile,
39263
+ budgetKbps: null,
39264
+ notes
39265
+ };
39266
+ return canPassThrough(fallback.codec) ? {
39267
+ kind: "copy",
39268
+ reason: "no-negotiated-budget",
39269
+ ...base
39270
+ } : {
39271
+ kind: "transcode",
39272
+ reason: "source-codec",
39273
+ ...base
39274
+ };
39275
+ }
39276
+ const affordable = (input.pref !== "auto" && fallbackProfile === input.pref ? input.entries.filter((entry) => entry.profile === fallbackProfile) : input.entries).filter((entry) => {
39277
+ if (!canPassThrough(entry.codec)) return false;
39278
+ return classifyBitrateFit(input.bitrates.get(entry.profile), budgetKbps) === "fits";
39279
+ });
39280
+ if (affordable.length > 0) {
39281
+ const picked = pickPreferredRtspEntry(affordable, input.pref, input.deviceId, { targetResolution: input.targetResolution });
39282
+ if (picked !== null) return {
39283
+ kind: "copy",
39284
+ reason: "source-fits-budget",
39285
+ picked,
39286
+ profile: toCamProfile$1(picked.profileId),
39287
+ budgetKbps,
39288
+ notes
39289
+ };
39290
+ }
39291
+ if (canPassThrough(fallback.codec)) return {
39292
+ kind: "copy",
39293
+ reason: "over-budget-tolerated",
39294
+ picked: fallback,
39295
+ profile: fallbackProfile,
39296
+ budgetKbps,
39297
+ notes
39298
+ };
39299
+ return {
39300
+ kind: "transcode",
39301
+ reason: transcodeReason(fallback, fallbackProfile, input.bitrates, budgetKbps),
39302
+ picked: fallback,
39303
+ profile: fallbackProfile,
39304
+ budgetKbps,
39305
+ notes
39306
+ };
39307
+ }
39308
+ /**
39309
+ * Fill in a codec the profile restream entry did not carry, from its broker
39310
+ * slot. `getProfileRtspEntries` has historically omitted it for legacy
39311
+ * entries, and a slot whose codec is unknown must not be mistaken for H.264.
39312
+ */
39313
+ function withSlotCodecs(entries, slots) {
39314
+ const codecByProfile = /* @__PURE__ */ new Map();
39315
+ for (const slot of slots) if (slot.codec !== void 0) codecByProfile.set(slot.profile, slot.codec);
39316
+ return entries.map((entry) => {
39317
+ if (entry.codec !== void 0) return entry;
39318
+ const codec = codecByProfile.get(entry.profile);
39319
+ return codec === void 0 ? entry : {
39320
+ ...entry,
39321
+ codec
39322
+ };
39323
+ });
39324
+ }
39325
+ /**
39326
+ * The rate we will actually deliver: never more than the controller asked for,
39327
+ * and never more than the source produces. `-r` above the source rate makes
39328
+ * ffmpeg DUPLICATE frames, which spends the budget on nothing.
39329
+ */
39330
+ function deliverableFps(negotiatedFps, slotFps) {
39331
+ if (slotFps === null || !Number.isFinite(slotFps) || slotFps <= 0) return negotiatedFps;
39332
+ return Math.min(negotiatedFps, Math.floor(slotFps));
39333
+ }
39334
+ /**
39335
+ * The ffmpeg video-output arguments.
39336
+ *
39337
+ * Pass-through carries `-bsf:v dump_extra` so every IDR inlines its own
39338
+ * SPS/PPS — sources that publish parameter sets only in the RTSP SDP hand iOS
39339
+ * a keyframe it cannot decode otherwise.
39340
+ *
39341
+ * The transcode's cap is three flags, not one: `-b:v` is an average and on its
39342
+ * own permits exactly the burst that was measured. `-maxrate` plus a
39343
+ * **one-second** `-bufsize` bounds any one-second window at the negotiated
39344
+ * rate, which is also the only lever available on the 3.03 s peak jitter — the
39345
+ * VBV window is what forces x264 to size a key frame to fit rather than
39346
+ * emitting it as one tight burst.
39347
+ */
39348
+ /**
39349
+ * Seconds between forced IDRs on the transcode path.
39350
+ *
39351
+ * A join can only start decoding at a key frame, so this is the worst-case
39352
+ * wait a controller pays before the first picture — and the bound the RTSP
39353
+ * join-burst withhold falls back on when it declines to replay a wide GOP.
39354
+ */
39355
+ var KEYFRAME_INTERVAL_SEC = 4;
39356
+ function buildVideoEncodeArgs(input) {
39357
+ if (!input.transcode) return [
39358
+ "-c:v",
39359
+ "copy",
39360
+ "-bsf:v",
39361
+ "dump_extra"
39362
+ ];
39363
+ const rate = input.budgetKbps === null ? [] : [
39364
+ "-b:v",
39365
+ `${input.budgetKbps}k`,
39366
+ "-maxrate",
39367
+ `${input.budgetKbps}k`,
39368
+ "-bufsize",
39369
+ `${input.budgetKbps}k`
39370
+ ];
39371
+ return [
39372
+ "-c:v",
39373
+ "libx264",
39374
+ "-preset",
39375
+ "ultrafast",
39376
+ "-tune",
39377
+ "zerolatency",
39378
+ "-pix_fmt",
39379
+ "yuv420p",
39380
+ "-r",
39381
+ String(input.fps),
39382
+ "-s",
39383
+ `${input.width}x${input.height}`,
39384
+ "-g",
39385
+ String(Math.max(1, Math.round(input.fps * KEYFRAME_INTERVAL_SEC))),
39386
+ ...rate,
39387
+ "-profile:v",
39388
+ "baseline",
39389
+ "-level",
39390
+ "3.1",
39391
+ "-bsf:v",
39392
+ "dump_extra"
39393
+ ];
39394
+ }
39395
+ /** Compact `mid=2048pub/9meas:over-budget` rendering for a single log field. */
39396
+ function formatFitNotes(notes) {
39397
+ return notes.map((n) => `${n.profile}=${n.publishedKbps ?? "?"}pub/${n.measuredKbps ?? "?"}meas:${n.verdict}`);
39398
+ }
39399
+ function fitNotes(entries, bitrates, budgetKbps) {
39400
+ return entries.map((entry) => {
39401
+ const evidence = bitrates.get(entry.profile);
39402
+ return {
39403
+ profile: entry.profile,
39404
+ verdict: budgetKbps === null ? "unknown" : classifyBitrateFit(evidence, budgetKbps),
39405
+ publishedKbps: evidence?.publishedKbps ?? null,
39406
+ measuredKbps: evidence?.measuredKbps ?? null
39407
+ };
39408
+ });
39409
+ }
39410
+ function transcodeReason(picked, profile, bitrates, budgetKbps) {
39411
+ if (!canPassThrough(picked.codec)) return "source-codec";
39412
+ if (profile === null) return "unknown-bitrate";
39413
+ return classifyBitrateFit(bitrates.get(profile), budgetKbps) === "over-budget" ? "over-budget" : "unknown-bitrate";
39414
+ }
39415
+ /**
39416
+ * iOS Home renders only H.264 over the classic HAP SRTP path, so an H.265
39417
+ * source can never be passed through. An UNKNOWN codec is treated the same
39418
+ * way: guessing H.264 is how a camera that changed its encoder ends up
39419
+ * shipping bytes no controller can decode.
39420
+ */
39421
+ function canPassThrough(codec) {
39422
+ if (codec === void 0) return false;
39423
+ const lower = codec.toLowerCase();
39424
+ if (lower.includes("h265") || lower.includes("hevc")) return false;
39425
+ return lower.includes("h264") || lower.includes("avc");
39426
+ }
39427
+ function plausibleMeasured(value) {
39428
+ if (value === null || !Number.isFinite(value)) return null;
39429
+ return value >= 64 ? value : null;
39430
+ }
39431
+ /**
39432
+ * Map a profile slot onto the camera encoder feeding it, and read that
39433
+ * encoder's configured bitrate.
39434
+ *
39435
+ * The link is the slot's assigned cam-stream: its resolution (and frame rate,
39436
+ * when two encoders share a resolution) identifies which of `main`/`sub`/`ext`
39437
+ * produces it. Vendor-neutral on purpose — the cam-stream ids (`native:main`,
39438
+ * `native:slot-3`, …) are provider strings and matching on them would work for
39439
+ * exactly one provider. An ambiguous match returns `null`.
39440
+ */
39441
+ function publishedRateFor(slot, source, streamParams) {
39442
+ if (streamParams === null) return null;
39443
+ const resolution = source?.resolution ?? slot.resolution;
39444
+ if (resolution === void 0) return null;
39445
+ const byResolution = ENCODER_PROFILE_KEYS.map((key) => encoderConfig(streamParams, key)).filter((cfg) => cfg !== null && cfg.width === resolution.width && cfg.height === resolution.height);
39446
+ if (byResolution.length === 1) return positiveOrNull(byResolution[0]?.bitrate);
39447
+ const fps = source?.fps;
39448
+ if (fps === void 0) return null;
39449
+ const byFps = byResolution.filter((cfg) => cfg !== null && Math.floor(cfg.framerate) === Math.floor(fps));
39450
+ return byFps.length === 1 ? positiveOrNull(byFps[0]?.bitrate) : null;
39451
+ }
39452
+ function encoderConfig(status, key) {
39453
+ return status[key] ?? null;
39454
+ }
39455
+ function positiveOrNull(value) {
39456
+ if (value === void 0 || !Number.isFinite(value) || value <= 0) return null;
39457
+ return value;
39458
+ }
39459
+ var CAM_PROFILES$1 = [
39460
+ "high",
39461
+ "mid",
39462
+ "low"
39463
+ ];
39464
+ /**
39465
+ * `PickedStream.profileId` is the brokerId suffix, which for a profile-keyed
39466
+ * entry IS the profile name. Anything else addresses a raw cam-stream and must
39467
+ * not be coerced into a profile.
39468
+ */
39469
+ function toCamProfile$1(profileId) {
39470
+ return CAM_PROFILES$1.find((p) => p === profileId) ?? null;
39471
+ }
39472
+ //#endregion
39473
+ //#region src/mappers/builders/stream-bitrate-probe.ts
39474
+ /**
39475
+ * Resolve every profile slot's rate. Never throws: an unresolvable slot ends
39476
+ * up `unknown`, which the selector treats as "cannot prove it fits" and
39477
+ * therefore transcodes — the safe direction on the wire.
39478
+ */
39479
+ async function probeProfileBitrates(input) {
39480
+ const { proxy } = input.bctx;
39481
+ const camStreams = await probe$1(() => proxy.cameraStreams?.getCameraStreams({}), "cameraStreams.getCameraStreams", input.log);
39482
+ const streamParams = await probe$1(() => proxy.streamParams?.getStatus({}), "streamParams.getStatus", input.log);
39483
+ const choices = await probe$1(() => proxy.webrtcSession?.listStreams({}), "webrtcSession.listStreams", input.log);
39484
+ return resolveProfileBitrates({
39485
+ slots: input.slots,
39486
+ camStreams: camStreams ?? [],
39487
+ streamParams,
39488
+ choices: choices ?? []
39489
+ });
39490
+ }
39491
+ async function probe$1(call, label, log) {
39492
+ try {
39493
+ const pending = call();
39494
+ if (pending === void 0) {
39495
+ log.info("export-hap: bitrate probe skipped — cap not bound on this device", { meta: { call: label } });
39496
+ return null;
39497
+ }
39498
+ return await pending;
39499
+ } catch (err) {
39500
+ log.warn("export-hap: bitrate probe failed — the slot rate stays UNKNOWN", { meta: {
39501
+ call: label,
39502
+ error: err instanceof Error ? err.message : String(err)
39503
+ } });
39504
+ return null;
39505
+ }
39506
+ }
39507
+ /**
39508
+ * Two outputs from one input: video SRTP and audio SRTP, one process, one
39509
+ * lifetime, one kill signal. ffmpeg-internal stream selection (`-vn` / `-an`
39510
+ * plus per-output codec args) keeps both flowing through it.
39511
+ */
39512
+ function buildSessionFfmpegArgs(input) {
39513
+ return [
39514
+ "-hide_banner",
39515
+ "-loglevel",
39516
+ "warning",
39517
+ ...input.decodeArgs,
39518
+ "-rtsp_transport",
39519
+ "tcp",
39520
+ "-i",
39521
+ input.rtspUrl,
39522
+ "-an",
39523
+ "-map",
39524
+ "0:v:0",
39525
+ ...input.videoArgs,
39526
+ "-payload_type",
39527
+ String(input.videoPayloadType),
39528
+ "-ssrc",
39529
+ String(input.videoSsrcSigned),
39530
+ "-f",
39531
+ "rtp",
39532
+ input.videoTarget,
39533
+ "-vn",
39534
+ "-map",
39535
+ "0:a:0?",
39536
+ "-af",
39537
+ "aresample=async=1000:first_pts=0",
39538
+ "-c:a",
39539
+ "libopus",
39540
+ "-application",
39541
+ "lowdelay",
39542
+ "-frame_duration",
39543
+ String(input.audioPacketTimeMs),
39544
+ "-flags",
39545
+ "+global_header",
39546
+ "-ar",
39547
+ String(input.audioSampleRateKhz * 1e3),
39548
+ "-b:a",
39549
+ `24k`,
39550
+ "-bufsize",
39551
+ `96k`,
39552
+ "-ac",
39553
+ String(1),
39554
+ "-payload_type",
39555
+ String(input.audioPayloadType),
39556
+ "-ssrc",
39557
+ String(input.audioSsrcSigned),
39558
+ "-f",
39559
+ "rtp",
39560
+ input.audioTarget
39561
+ ];
39562
+ }
39563
+ /**
39564
+ * The resolutions we offer, before rates are attached. Same list the delegate
39565
+ * advertised before R2 — only the frame rate changes, so a controller that had
39566
+ * already negotiated a resolution keeps finding it.
39567
+ */
39568
+ var CANDIDATE_RESOLUTIONS = [
39569
+ [1920, 1080],
39570
+ [1280, 720],
39571
+ [1024, 768],
39572
+ [640, 480],
39573
+ [640, 360],
39574
+ [480, 360],
39575
+ [480, 270],
39576
+ [320, 240],
39577
+ [320, 180]
39578
+ ];
39579
+ /**
39580
+ * Resolve the real frame rate of each profile slot.
39581
+ *
39582
+ * A slot with no measurement, no publication and no assignment simply does not
39583
+ * appear in the map — the caller then advertises {@link ASSUMED_FPS} and says
39584
+ * so. Absence is never silently rendered as a number.
39585
+ */
39586
+ function resolveProfileFps(input) {
39587
+ const publishedByProfile = publishedFpsByProfile(input.slots, input.camStreams);
39588
+ const out = /* @__PURE__ */ new Map();
39589
+ for (const choice of input.choices) {
39590
+ if (choice.target.kind !== "profile") continue;
39591
+ const profile = choice.target.profile;
39592
+ const measured = clampFps(choice.inputFps);
39593
+ if (measured !== null) {
39594
+ out.set(profile, {
39595
+ profile,
39596
+ fps: measured,
39597
+ source: "measured"
39598
+ });
39599
+ continue;
39600
+ }
39601
+ const published = clampFps(publishedByProfile.get(profile) ?? null);
39602
+ out.set(profile, published !== null ? {
39603
+ profile,
39604
+ fps: published,
39605
+ source: "published"
39606
+ } : {
39607
+ profile,
39608
+ fps: 30,
39609
+ source: "assumed"
39610
+ });
39611
+ }
39612
+ for (const [profile, fps] of publishedByProfile) {
39613
+ if (out.has(profile)) continue;
39614
+ const published = clampFps(fps);
39615
+ if (published !== null) out.set(profile, {
39616
+ profile,
39617
+ fps: published,
39618
+ source: "published"
39619
+ });
39620
+ }
39621
+ return out;
39622
+ }
39623
+ /**
39624
+ * Attach a frame rate to each candidate resolution by asking the REAL picker
39625
+ * which slot the START path would dial for it. That coupling is the point: if
39626
+ * the picker's steering changes, the advertisement changes with it instead of
39627
+ * drifting into a second, silently different opinion.
39628
+ */
39629
+ function deriveAdvertisedResolutions(input) {
39630
+ const candidates = input.candidates.length > 0 ? input.candidates : CANDIDATE_RESOLUTIONS;
39631
+ const seen = /* @__PURE__ */ new Set();
39632
+ const out = [];
39633
+ for (const [width, height] of candidates) {
39634
+ const picked = pickPreferredRtspEntry(input.entries, input.pref, input.deviceId, { targetResolution: {
39635
+ width,
39636
+ height
39637
+ } });
39638
+ const profile = picked === null ? null : toCamProfile(picked.profileId);
39639
+ const resolved = profile === null ? void 0 : input.fpsByProfile.get(profile);
39640
+ const advertised = {
39641
+ width,
39642
+ height,
39643
+ fps: resolved?.fps ?? 30,
39644
+ profile,
39645
+ source: resolved?.source ?? "assumed"
39646
+ };
39647
+ const key = `${advertised.width}x${advertised.height}@${advertised.fps}`;
39648
+ if (seen.has(key)) continue;
39649
+ seen.add(key);
39650
+ out.push(advertised);
39651
+ }
39652
+ return out;
39653
+ }
39654
+ /** Project onto the `[width, height, fps]` triples hap-nodejs expects. */
39655
+ function toHapResolutions(advertised) {
39656
+ return advertised.map((a) => [
39657
+ a.width,
39658
+ a.height,
39659
+ a.fps
39660
+ ]);
39661
+ }
39662
+ /** Compact `1280x720@10(measured)` rendering for a single log field. */
39663
+ function formatAdvertisedResolutions(advertised) {
39664
+ return advertised.map((a) => `${a.width}x${a.height}@${a.fps}/${a.profile ?? "-"}(${a.source})`);
39665
+ }
39666
+ function publishedFpsByProfile(slots, camStreams) {
39667
+ const fpsByCamStream = /* @__PURE__ */ new Map();
39668
+ for (const stream of camStreams) if (typeof stream.fps === "number") fpsByCamStream.set(stream.camStreamId, stream.fps);
39669
+ const out = /* @__PURE__ */ new Map();
39670
+ for (const slot of slots) {
39671
+ if (slot.sourceCamStreamId === null) continue;
39672
+ const fps = fpsByCamStream.get(slot.sourceCamStreamId);
39673
+ if (fps !== void 0) out.set(slot.profile, fps);
39674
+ }
39675
+ return out;
39676
+ }
39677
+ /**
39678
+ * Coerce a probe reading into an advertisable integer rate, or null when the
39679
+ * reading carries no information (absent, zero because the broker is idle,
39680
+ * negative, NaN, or below the representable floor).
39681
+ */
39682
+ function clampFps(value) {
39683
+ if (typeof value !== "number" || !Number.isFinite(value)) return null;
39684
+ const floored = Math.floor(value);
39685
+ if (floored < 1) return null;
39686
+ return Math.min(floored, 30);
39687
+ }
39688
+ var CAM_PROFILES = [
39689
+ "high",
39690
+ "mid",
39691
+ "low"
39692
+ ];
39693
+ /**
39694
+ * `PickedStream.profileId` is the brokerId suffix, which for a profile-keyed
39695
+ * entry IS the profile name. Anything else (a raw cam-stream id) is not a
39696
+ * profile and must not be coerced into one.
39697
+ */
39698
+ function toCamProfile(profileId) {
39699
+ return CAM_PROFILES.find((p) => p === profileId) ?? null;
39700
+ }
39701
+ /**
39702
+ * How long after spawn an exit still counts as "hardware init failed".
39703
+ *
39704
+ * Same window the ffmpeg decoder addon uses for its own cascade. A hardware
39705
+ * context that cannot be created fails within milliseconds; anything that ran
39706
+ * longer produced no frames for a different reason, and re-spawning it in
39707
+ * software would just hide that reason.
39708
+ */
39709
+ var HW_DECODE_FALLBACK_WINDOW_MS = 4e3;
39710
+ /** Backends whose decode binds to a DRM render node. */
39711
+ var RENDER_NODE_BACKENDS = ["vaapi", "qsv"];
39712
+ /**
39713
+ * Values of the decoder cap's `hwaccel` field that are operator CHOICES rather
39714
+ * than devices.
39715
+ */
39716
+ var HWACCEL_NON_BACKEND_CHOICES = ["auto", "none"];
39717
+ /**
39718
+ * Every backend the decoder cap can publish, taken from the cap's own UI option
39719
+ * list so this module cannot drift from it. A hand-written copy of the union is
39720
+ * exactly the "hand-written cap interface that rots silently" this repo has
39721
+ * been bitten by.
39722
+ */
39723
+ var HWACCEL_BACKENDS = new Set(HWACCEL_OPTIONS.map((option) => option.value).filter((value) => !HWACCEL_NON_BACKEND_CHOICES.includes(value)));
39724
+ /** Is this string a hardware backend, as opposed to `auto`, `none` or junk? */
39725
+ function isHwAccelBackend(value) {
39726
+ return HWACCEL_BACKENDS.has(value);
39727
+ }
39728
+ /**
39729
+ * Decide how this session decodes, and produce the ffmpeg input-side flags.
39730
+ *
39731
+ * Every path that ends in software carries a named reason, because a session
39732
+ * that quietly stopped using the GPU and a session that never had one look
39733
+ * identical in a CPU graph.
39734
+ */
39735
+ function selectHwDecode(input) {
39736
+ if (!input.transcode) return software("pass-through");
39737
+ if (input.hardwareAlreadyFailed === true) return software("hardware-attempt-failed");
39738
+ if (input.reading === null) return software("no-decoder-reading");
39739
+ const chosen = (input.reading.hwaccel ?? "").trim();
39740
+ if (chosen === "none") return software("operator-disabled");
39741
+ if (chosen !== "" && chosen !== "auto") return isHwAccelBackend(chosen) ? hardware(chosen, "operator", input) : software("unrecognised-backend");
39742
+ const probed = (input.reading.probedBestHwaccel ?? "").trim();
39743
+ if (probed === "" || probed === "none") return software("not-probed");
39744
+ return isHwAccelBackend(probed) ? hardware(probed, "probed", input) : software("unrecognised-backend");
39745
+ }
39746
+ /**
39747
+ * Did this ffmpeg exit look like a failed hardware init, rather than a
39748
+ * teardown or a fault software would hit too?
39749
+ *
39750
+ * All five conditions are necessary. Dropping the controller-stop check in
39751
+ * particular would respawn on every normal teardown, because iOS restarts a
39752
+ * session it is not enjoying and that is indistinguishable at the exit code.
39753
+ */
39754
+ function shouldRetryInSoftware(input) {
39755
+ if (!input.usedHardware) return false;
39756
+ if (input.hardwareAlreadyFailed) return false;
39757
+ if (input.stopRequestedByController) return false;
39758
+ if (input.videoPacketsForwarded > 0) return false;
39759
+ return input.runtimeMs <= HW_DECODE_FALLBACK_WINDOW_MS;
39760
+ }
39761
+ function software(reason) {
39762
+ return {
39763
+ kind: "software",
39764
+ reason,
39765
+ args: []
39766
+ };
39767
+ }
39768
+ function hardware(backend, source, input) {
39769
+ return {
39770
+ kind: "hardware",
39771
+ backend,
39772
+ source,
39773
+ args: decodeArgs(backend, input)
39774
+ };
39775
+ }
39776
+ /**
39777
+ * The input-side flags, and nothing else.
39778
+ *
39779
+ * No `-hwaccel_output_format`: the decoded frames have to land in system
39780
+ * memory for libx264 to scale and encode them. Setting it would keep them on
39781
+ * the GPU, which only pays off with a GPU scale filter — and that is the
39782
+ * decoder addon's job, not a two-output SRTP session's.
39783
+ */
39784
+ function decodeArgs(backend, input) {
39785
+ if (backend === "videotoolbox" && input.platform === "darwin") return ["-hwaccel", "auto"];
39786
+ const args = ["-hwaccel", backend];
39787
+ if (RENDER_NODE_BACKENDS.includes(backend)) args.push("-hwaccel_device", input.renderDevice ?? "/dev/dri/renderD128");
39788
+ return args;
39789
+ }
39790
+ //#endregion
39791
+ //#region src/mappers/builders/stream-hwaccel-probe.ts
39792
+ /**
39793
+ * Read this node's decode-hwaccel state, or `null` when nothing answered.
39794
+ *
39795
+ * Never throws. `null` means "we do not know", which
39796
+ * {@link import('./stream-hwaccel.js').selectHwDecode} turns into software —
39797
+ * the safe direction, because a guess here costs the whole stream.
39798
+ */
39799
+ async function probeDecoderHwaccel(input) {
39800
+ const { ctx, log } = input;
39801
+ const nodeId = ctx.kernel?.localNodeId;
39802
+ if (nodeId === void 0 || nodeId.length === 0) {
39803
+ log.warn("export-hap: hwaccel probe skipped — no local node id, decoding in SOFTWARE");
39804
+ return null;
39805
+ }
39806
+ try {
39807
+ const info = await ctx.api.decoder.getInfo.query(void 0, nodePin(nodeId));
39808
+ if (info === null || info === void 0) {
39809
+ log.info("export-hap: hwaccel probe returned nothing — decoding in SOFTWARE", { meta: { nodeId } });
39810
+ return null;
39811
+ }
39812
+ return {
39813
+ hwaccel: info.hwaccel ?? null,
39814
+ probedBestHwaccel: info.probedBestHwaccel ?? null
39815
+ };
39816
+ } catch (err) {
39817
+ log.warn("export-hap: hwaccel probe failed — decoding in SOFTWARE", { meta: {
39818
+ nodeId,
39819
+ error: err instanceof Error ? err.message : String(err)
39820
+ } });
39821
+ return null;
39822
+ }
39823
+ }
39824
+ //#endregion
39825
+ //#region src/mappers/builders/stream-telemetry.ts
39826
+ /**
39827
+ * Every branch on the streaming path that discards work, and its starting
39828
+ * count. This object is the single source of the reason set: the union below
39829
+ * is its `keyof`, so adding a silent `return` without adding a reason here is
39830
+ * a compile error rather than an invisible hole.
39831
+ */
39832
+ var ZERO_DROP_COUNTERS = {
39833
+ /** Outbound: the leg has no SRTP session (prepareStream init failed). */
39834
+ "no-srtp-session": 0,
39835
+ /** Outbound: the leg has no return-path gate — forwarding was never armed. */
39836
+ "no-gate": 0,
39837
+ /** Outbound: werift refused to encrypt the packet. */
39838
+ "srtp-encrypt-failed": 0,
39839
+ /** Outbound: the UDP send itself errored. */
39840
+ "srtp-send-failed": 0,
39841
+ /** Outbound RTCP: the leg has no SRTCP context, so no Sender Report can go out. */
39842
+ "rtcp-no-srtcp": 0,
39843
+ /** Outbound RTCP: building or encrypting the Sender Report failed. */
39844
+ "rtcp-encrypt-failed": 0,
39845
+ /** Inbound RTCP: the leg has no SRTCP context, so the controller's report is unreadable. */
39846
+ "inbound-rtcp-no-srtcp": 0,
39847
+ /** Inbound RTCP: SRTCP decryption of a controller packet failed. */
39848
+ "inbound-rtcp-decrypt-failed": 0,
39849
+ /** Inbound RTCP: the decrypted bytes did not parse as RTCP. */
39850
+ "inbound-rtcp-parse-failed": 0,
39851
+ /** Upstream: packet shorter than an RTP header. */
39852
+ "upstream-short-packet": 0,
39853
+ /** Upstream: no inbound SRTP context (init failed at prepareStream). */
39854
+ "upstream-no-srtp": 0,
39855
+ /** Upstream: SRTP authentication/decrypt failed. */
39856
+ "upstream-decrypt-failed": 0,
39857
+ /** Upstream: decrypted bytes did not parse as RTP. */
39858
+ "upstream-parse-failed": 0,
39859
+ /** Upstream: audio arrived before `handleStreamRequest('start')` settled. */
39860
+ "upstream-before-start": 0,
39861
+ /** Upstream: payload type is not the negotiated audio PT (RTCP / keepalive). */
39862
+ "upstream-pt-mismatch": 0,
39863
+ /** Upstream: RTP carried a zero-length payload. */
39864
+ "upstream-empty-payload": 0,
39865
+ /** Upstream: no camera-side talk session, so the frame has nowhere to go. */
39866
+ "upstream-no-talk-session": 0,
39867
+ /** Upstream: the camera-side `pushTalkAudio` rejected the frame. */
39868
+ "upstream-push-failed": 0,
39869
+ /** Snapshot: the `snapshot` cap returned nothing. */
39870
+ "snapshot-unavailable": 0
39871
+ };
39872
+ function emptyDropCounters() {
39873
+ return { ...ZERO_DROP_COUNTERS };
39874
+ }
39875
+ /** Immutable increment — returns a new record, never touches the input. */
39876
+ function recordDrop(counters, reason) {
39877
+ return {
39878
+ ...counters,
39879
+ [reason]: counters[reason] + 1
39880
+ };
39881
+ }
39882
+ /** Only the reasons that actually fired, so a clean session logs `{}`. */
39883
+ function nonZeroDrops(counters) {
39884
+ const out = {};
39885
+ for (const [reason, count] of Object.entries(counters)) if (count > 0) out[reason] = count;
39886
+ return out;
39887
+ }
39888
+ /**
39889
+ * Classify a packet received on one of our advertised return ports.
39890
+ *
39891
+ * RTP and RTCP arrive on the same symmetric port here. RTCP packet types are
39892
+ * 200..207, which sit inside the 192..223 band RFC 5761 §4 reserves precisely
39893
+ * so the two can be told apart on a shared socket (an RTP packet's marker bit
39894
+ * plus payload type can never land there for any payload type we negotiate).
39895
+ * The payloads are SRTP/SRTCP-encrypted, but both keep the first two bytes in
39896
+ * the clear.
39897
+ */
39898
+ function classifyInboundPacket(packet) {
39899
+ if (packet.length < 2) return "malformed";
39900
+ if ((packet[0] >> 6 & 3) !== 2) return "malformed";
39901
+ const typeByte = packet[1];
39902
+ if (typeByte >= 192 && typeByte <= 223) return "rtcp";
39903
+ return "rtp";
39904
+ }
39905
+ /**
39906
+ * Loss at or above this is not a start-up transient.
39907
+ *
39908
+ * A stream whose first key frame is slow reliably produces one report in the
39909
+ * low single digits; a transmit-side defect produces tens of percent, because
39910
+ * whatever makes a packet unusable makes most packets unusable. The threshold
39911
+ * sits between those two regimes rather than at any measured boundary — it is
39912
+ * a reading aid, and the raw `worstFractionLostPct` is always in the line
39913
+ * beside it.
39914
+ */
39915
+ var TRANSMIT_SUSPECT_FRACTION_LOST_PCT = 5;
39916
+ function lossVerdict(tally) {
39917
+ if (tally.blocksParsed === 0 || tally.worstFractionLostPct === null) return "no-reports";
39918
+ return tally.worstFractionLostPct >= TRANSMIT_SUSPECT_FRACTION_LOST_PCT ? "transmit-suspect" : "decode-suspect";
39919
+ }
39920
+ /**
39921
+ * Build the meta for `export-hap: stream session summary` — the single line a
39922
+ * future session greps to answer "why did this session die".
39923
+ */
39924
+ function summariseSession(snapshot) {
39925
+ const durationMs = snapshot.startedAtMs !== null && snapshot.endedAtMs !== null ? snapshot.endedAtMs - snapshot.startedAtMs : null;
39926
+ const negotiated = snapshot.negotiated;
39927
+ const slot = snapshot.selectedSlot;
39928
+ return {
39929
+ sessionId: snapshot.sessionId,
39930
+ durationMs,
39931
+ stopRequestedByController: snapshot.stopRequestedByController,
39932
+ ffmpegExitCode: snapshot.ffmpegExit?.code ?? null,
39933
+ ffmpegExitSignal: snapshot.ffmpegExit?.signal ?? null,
39934
+ negotiatedResolution: negotiated ? `${negotiated.width}x${negotiated.height}` : null,
39935
+ negotiatedFps: negotiated?.fps ?? null,
39936
+ negotiatedMaxBitrateKbps: negotiated?.maxBitrateKbps ?? null,
39937
+ selectedProfile: slot?.profile ?? null,
39938
+ selectedBrokerId: slot?.brokerId ?? null,
39939
+ advertisedFps: slot?.advertisedFps ?? null,
39940
+ advertisedFpsSource: slot?.advertisedFpsSource ?? null,
39941
+ deliveredFps: slot?.deliveredFps ?? null,
39942
+ transcode: slot?.transcode ?? null,
39943
+ fitReason: slot?.fitReason ?? null,
39944
+ slotPublishedKbps: slot?.publishedKbps ?? null,
39945
+ slotMeasuredKbps: slot?.measuredKbps ?? null,
39946
+ encodeBudgetKbps: slot?.budgetKbps ?? null,
39947
+ fitNotes: slot?.fitNotes ?? [],
39948
+ videoPacketsForwarded: snapshot.videoPacketsForwarded,
39949
+ audioPacketsForwarded: snapshot.audioPacketsForwarded,
39950
+ videoRtcpSrSent: snapshot.videoRtcpSrSent,
39951
+ audioRtcpSrSent: snapshot.audioRtcpSrSent,
39952
+ videoRtcpReceived: snapshot.videoRtcpReceived,
39953
+ audioRtcpReceived: snapshot.audioRtcpReceived,
39954
+ videoRtpReceived: snapshot.videoRtpReceived,
39955
+ audioRtpReceived: snapshot.audioRtpReceived,
39956
+ videoGate: formatRtcpGate(snapshot.videoGate),
39957
+ audioGate: formatRtcpGate(snapshot.audioGate),
39958
+ videoReceiverReports: snapshot.videoReceiverReports,
39959
+ audioReceiverReports: snapshot.audioReceiverReports,
39960
+ videoLossVerdict: lossVerdict(snapshot.videoReceiverReports),
39961
+ audioLossVerdict: lossVerdict(snapshot.audioReceiverReports),
39962
+ mediaStarved: snapshot.videoPacketsForwarded === 0,
39963
+ drops: nonZeroDrops(snapshot.drops)
39964
+ };
39965
+ }
39966
+ //#endregion
39967
+ //#region src/mappers/builders/camera-streams.ts
38155
39968
  var SRTP_KEY_LEN = 16;
38156
39969
  var SRTP_SALT_LEN = 14;
38157
- var OPUS_BITRATE_KBPS = 24;
38158
- var OPUS_CHANNELS = 1;
38159
- function buildCameraStreamingDelegate(bctx) {
39970
+ /**
39971
+ * Cadence of the per-session heartbeat log.
39972
+ *
39973
+ * This timer LOGS ONLY. It never kills ffmpeg, never closes a socket and never
39974
+ * ends a session. That distinction matters: the two mainstream open-source
39975
+ * HomeKit camera stacks arm an idle watchdog that tears the stream down at
39976
+ * exactly 30 000 ms, and the ~31 s teardown here was misdiagnosed as one for a
39977
+ * long time before it was proven that this accessory has no such timer. Do not
39978
+ * make this one act.
39979
+ */
39980
+ var SESSION_HEARTBEAT_MS = 5e3;
39981
+ /**
39982
+ * Floor between two `export-hap: controller receiver report` lines on one leg.
39983
+ *
39984
+ * The controller sends several Receiver Reports a second — 21 and 34 in two
39985
+ * ~10 s sessions on 2026-08-06 — and every one of them at `info` would drown
39986
+ * the very line it is meant to make findable. The FIRST report on each leg is
39987
+ * always logged, in full and under its own message; after that this throttles
39988
+ * to the heartbeat's cadence so a session still produces a running record of
39989
+ * what the controller thinks without becoming one.
39990
+ */
39991
+ var RECEIVER_REPORT_LOG_INTERVAL_MS = 5e3;
39992
+ function buildCameraStreamingDelegate(bctx, advertised) {
38160
39993
  const { ctx, numericDeviceId } = bctx;
38161
39994
  const log = ctx.logger.withTags({ deviceId: numericDeviceId });
38162
39995
  const sessions = /* @__PURE__ */ new Map();
@@ -38175,7 +40008,7 @@ function buildCameraStreamingDelegate(bctx) {
38175
40008
  });
38176
40009
  },
38177
40010
  handleStreamRequest(request, callback) {
38178
- handleStreamRequest(request, sessions, bctx).then(() => callback()).catch((err) => {
40011
+ handleStreamRequest(request, sessions, bctx, advertised).then(() => callback()).catch((err) => {
38179
40012
  log.warn("export-hap: handleStreamRequest failed", { meta: { error: errMsg$8(err) } });
38180
40013
  callback(err instanceof Error ? err : new Error(errMsg$8(err)));
38181
40014
  });
@@ -38196,58 +40029,7 @@ function buildCameraStreamingDelegate(bctx) {
38196
40029
  H264Level.LEVEL4_0
38197
40030
  ]
38198
40031
  },
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
- ]
40032
+ resolutions: toHapResolutions(advertised.resolutions)
38251
40033
  },
38252
40034
  audio: {
38253
40035
  codecs: [{
@@ -38262,10 +40044,16 @@ function buildCameraStreamingDelegate(bctx) {
38262
40044
  },
38263
40045
  dispose: async () => {
38264
40046
  for (const session of sessions.values()) {
40047
+ session.teardownTrigger = "accessory-dispose";
40048
+ const hadFfmpeg = session.ffmpeg !== null;
38265
40049
  killFfmpeg(session, ctx, numericDeviceId);
40050
+ stopHeartbeat(session);
40051
+ if (!hadFfmpeg) logSessionSummary(session, log, "accessory-dispose-no-ffmpeg");
38266
40052
  await closeIntercomTalkSession(session, bctx).catch(() => void 0);
38267
40053
  closeSocket(session.videoUdp);
38268
40054
  closeSocket(session.audioUdp);
40055
+ closeSocket(session.videoLoopUdp);
40056
+ closeSocket(session.audioLoopUdp);
38269
40057
  }
38270
40058
  sessions.clear();
38271
40059
  }
@@ -38275,7 +40063,10 @@ async function handleSnapshot(bctx, request) {
38275
40063
  const { proxy, ctx, numericDeviceId } = bctx;
38276
40064
  const result = await proxy.snapshot?.getSnapshot({});
38277
40065
  if (!result || typeof result.base64 !== "string") {
38278
- ctx.logger.withTags({ deviceId: numericDeviceId }).debug("export-hap: snapshot returned null");
40066
+ ctx.logger.withTags({ deviceId: numericDeviceId }).info("export-hap: snapshot dropped", { meta: {
40067
+ reason: "snapshot-unavailable",
40068
+ capBound: proxy.snapshot !== void 0
40069
+ } });
38279
40070
  throw new Error("snapshot unavailable");
38280
40071
  }
38281
40072
  return Buffer.from(result.base64, "base64");
@@ -38305,7 +40096,7 @@ async function prepareStream(request, sessions, bctx) {
38305
40096
  },
38306
40097
  profile: import_src.ProtectionProfileAes128CmHmacSha1_80
38307
40098
  });
38308
- const makeOutSrtcp = (key, salt) => new import_src.SrtcpSession({
40099
+ const makeSrtcp = (key, salt) => new import_src.SrtcpSession({
38309
40100
  keys: {
38310
40101
  localMasterKey: key,
38311
40102
  localMasterSalt: salt,
@@ -38316,13 +40107,17 @@ async function prepareStream(request, sessions, bctx) {
38316
40107
  });
38317
40108
  let videoOutSrtp;
38318
40109
  let videoOutSrtcp;
40110
+ let videoInSrtcp;
38319
40111
  let audioOutSrtp;
38320
40112
  let audioOutSrtcp;
40113
+ let audioInSrtcp;
38321
40114
  try {
38322
40115
  videoOutSrtp = makeOutSrtp(request.video.srtp_key, request.video.srtp_salt);
38323
- videoOutSrtcp = makeOutSrtcp(request.video.srtp_key, request.video.srtp_salt);
40116
+ videoOutSrtcp = makeSrtcp(request.video.srtp_key, request.video.srtp_salt);
40117
+ videoInSrtcp = makeSrtcp(request.video.srtp_key, request.video.srtp_salt);
38324
40118
  audioOutSrtp = makeOutSrtp(request.audio.srtp_key, request.audio.srtp_salt);
38325
- audioOutSrtcp = makeOutSrtcp(request.audio.srtp_key, request.audio.srtp_salt);
40119
+ audioOutSrtcp = makeSrtcp(request.audio.srtp_key, request.audio.srtp_salt);
40120
+ audioInSrtcp = makeSrtcp(request.audio.srtp_key, request.audio.srtp_salt);
38326
40121
  } catch (err) {
38327
40122
  closeSocket(videoUdp);
38328
40123
  closeSocket(audioUdp);
@@ -38347,6 +40142,30 @@ async function prepareStream(request, sessions, bctx) {
38347
40142
  const videoSsrc = randomSsrc();
38348
40143
  const audioSsrc = randomSsrc();
38349
40144
  const session = {
40145
+ sessionId: request.sessionID,
40146
+ startedAtMs: null,
40147
+ endedAtMs: null,
40148
+ negotiated: null,
40149
+ selectedSlot: null,
40150
+ ffmpegExit: null,
40151
+ stopRequestedByController: false,
40152
+ teardownTrigger: null,
40153
+ drops: emptyDropCounters(),
40154
+ videoPacketsForwarded: 0,
40155
+ audioPacketsForwarded: 0,
40156
+ videoRtcpSrSent: 0,
40157
+ audioRtcpSrSent: 0,
40158
+ videoRtcpReceived: 0,
40159
+ audioRtcpReceived: 0,
40160
+ videoRtpReceived: 0,
40161
+ audioRtpReceived: 0,
40162
+ videoReceiverReports: emptyReceiverReportTally(),
40163
+ audioReceiverReports: emptyReceiverReportTally(),
40164
+ videoRrLoggedAt: 0,
40165
+ audioRrLoggedAt: 0,
40166
+ videoGate: null,
40167
+ audioGate: null,
40168
+ heartbeat: null,
38350
40169
  hapVideoPort: request.video.port,
38351
40170
  hapAddress: request.targetAddress,
38352
40171
  videoSrtpKey: request.video.srtp_key,
@@ -38355,6 +40174,7 @@ async function prepareStream(request, sessions, bctx) {
38355
40174
  videoLoopUdp,
38356
40175
  videoOutSrtp,
38357
40176
  videoOutSrtcp,
40177
+ videoInSrtcp,
38358
40178
  videoOutPacketCount: 0,
38359
40179
  videoOutOctetCount: 0,
38360
40180
  videoOutLastRtpTimestamp: 0,
@@ -38369,6 +40189,7 @@ async function prepareStream(request, sessions, bctx) {
38369
40189
  audioLoopUdp,
38370
40190
  audioOutSrtp,
38371
40191
  audioOutSrtcp,
40192
+ audioInSrtcp,
38372
40193
  audioSendGate: null,
38373
40194
  ipVersion,
38374
40195
  ffmpeg: null,
@@ -38391,19 +40212,33 @@ async function prepareStream(request, sessions, bctx) {
38391
40212
  audioRtcpIntervalMs: 5e3
38392
40213
  };
38393
40214
  sessions.set(request.sessionID, session);
40215
+ const tagLog = bctx.ctx.logger.withTags({ deviceId: bctx.numericDeviceId });
38394
40216
  session.videoSendGate = makeRtcpGate(videoUdp, 1e3);
38395
40217
  session.audioSendGate = makeRtcpGate(audioUdp, 1e3);
38396
- const tagLog = bctx.ctx.logger.withTags({ deviceId: bctx.numericDeviceId });
38397
- let videoPacketsForwarded = 0;
38398
- let audioPacketsForwarded = 0;
40218
+ session.videoSendGate.then((resolution) => {
40219
+ session.videoGate = resolution;
40220
+ logGateResolved(session, "video", resolution, tagLog);
40221
+ });
40222
+ session.audioSendGate.then((resolution) => {
40223
+ session.audioGate = resolution;
40224
+ logGateResolved(session, "audio", resolution, tagLog);
40225
+ });
40226
+ videoUdp.on("message", (packet) => {
40227
+ countInbound(session, "video", packet, tagLog);
40228
+ });
40229
+ audioUdp.on("message", (packet) => {
40230
+ countInbound(session, "audio", packet, tagLog);
40231
+ });
38399
40232
  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 } });
40233
+ session.videoPacketsForwarded += 1;
40234
+ if (session.videoPacketsForwarded === 1) tagLog.info("export-hap: first video packet from ffmpeg", { meta: {
40235
+ sessionId: session.sessionId,
40236
+ bytes: rtpPacket.length
40237
+ } });
38402
40238
  forwardEncryptedRtp(session, rtpPacket, "video", tagLog);
38403
40239
  });
38404
40240
  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 } });
40241
+ session.audioPacketsForwarded += 1;
38407
40242
  forwardEncryptedRtp(session, rtpPacket, "audio", tagLog);
38408
40243
  });
38409
40244
  audioUdp.on("message", (packet, rinfo) => {
@@ -38411,6 +40246,19 @@ async function prepareStream(request, sessions, bctx) {
38411
40246
  bctx.ctx.logger.withTags({ deviceId: bctx.numericDeviceId }).debug("export-hap: incoming-audio handler error (dropped)", { meta: { error: errMsg$8(err) } });
38412
40247
  });
38413
40248
  });
40249
+ tagLog.info("export-hap: stream prepared", { meta: {
40250
+ sessionId: request.sessionID,
40251
+ controllerAddress: request.targetAddress,
40252
+ addressVersion: ipVersion,
40253
+ localIp,
40254
+ advertisedVideoPort: localVideoPort,
40255
+ advertisedAudioPort: localAudioPort,
40256
+ controllerVideoPort: request.video.port,
40257
+ controllerAudioPort: request.audio.port,
40258
+ videoSsrc,
40259
+ audioSsrc,
40260
+ upstreamAudioDecrypt: upstreamAudioSrtp !== null
40261
+ } });
38414
40262
  return {
38415
40263
  video: {
38416
40264
  port: localVideoPort,
@@ -38485,30 +40333,225 @@ function sameIpv4Subnet(a, mask, b) {
38485
40333
  async function bindLoopback(ipVersion) {
38486
40334
  return bindUdp(ipVersion, ipVersion === "ipv6" ? "::1" : "127.0.0.1");
38487
40335
  }
40336
+ /** Book a named drop. Every silent `return` on the streaming path routes here. */
40337
+ function drop(session, reason) {
40338
+ session.drops = recordDrop(session.drops, reason);
40339
+ }
38488
40340
  /**
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.
40341
+ * Report a gate resolution. `timeout` is the interesting one: it means we are
40342
+ * about to stream into a port the controller never touched, and it was
40343
+ * indistinguishable from a successful probe until now.
38496
40344
  */
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);
40345
+ function logGateResolved(session, leg, resolution, log) {
40346
+ const meta = {
40347
+ sessionId: session.sessionId,
40348
+ leg,
40349
+ reason: resolution.reason,
40350
+ waitedMs: resolution.waitedMs
40351
+ };
40352
+ if (resolution.reason === "packet") log.info("export-hap: RTCP gate opened on a REAL controller packet", { meta });
40353
+ else log.warn("export-hap: RTCP gate opened on the 1s FALLBACK — controller never probed", { meta });
40354
+ }
40355
+ /** Count and classify one packet the controller sent to a return port. */
40356
+ function countInbound(session, leg, packet, log) {
40357
+ const kind = classifyInboundPacket(packet);
40358
+ const firstRtcp = kind === "rtcp" && (leg === "video" ? session.videoRtcpReceived : session.audioRtcpReceived) === 0;
40359
+ if (leg === "video") {
40360
+ if (kind === "rtcp") session.videoRtcpReceived += 1;
40361
+ else if (kind === "rtp") session.videoRtpReceived += 1;
40362
+ } else if (kind === "rtcp") session.audioRtcpReceived += 1;
40363
+ else if (kind === "rtp") session.audioRtpReceived += 1;
40364
+ if (firstRtcp) log.info("export-hap: first inbound RTCP from controller", { meta: {
40365
+ sessionId: session.sessionId,
40366
+ leg,
40367
+ bytes: packet.length
40368
+ } });
40369
+ if (kind === "rtcp") readControllerRtcp(session, leg, packet, log);
40370
+ }
40371
+ /** Fold a decrypted RTCP datagram into the leg's tally. Immutable, per `ReceiverReportTally`. */
40372
+ function storeReceiverReports(session, leg, tally) {
40373
+ if (leg === "video") session.videoReceiverReports = tally;
40374
+ else session.audioReceiverReports = tally;
40375
+ }
40376
+ /**
40377
+ * Decrypt one inbound RTCP datagram and read the controller's Receiver Report.
40378
+ *
40379
+ * This is the datum the 2026-08-06 telemetry left missing. That round proved
40380
+ * iOS DOES send us RTCP on the video leg — 21 and 34 packets across two
40381
+ * sessions, the gate opening on a real controller packet at ~550 ms — which
40382
+ * killed the long-standing "iOS never probes us" belief. But counting packets
40383
+ * says only that the controller spoke; a Receiver Report says WHAT it said,
40384
+ * and that splits the remaining causes into two disjoint families that nothing
40385
+ * else in this system can tell apart. See `LossVerdict`.
40386
+ *
40387
+ * Runs on a UDP `message` handler, so nothing here may throw: a controller
40388
+ * that sends one deformed datagram must not take the session with it. Every
40389
+ * failure books a named drop and increments `unreadable`, because a report we
40390
+ * could not read and a report that never came must never look alike.
40391
+ */
40392
+ function readControllerRtcp(session, leg, packet, log) {
40393
+ const srtcp = leg === "video" ? session.videoInSrtcp : session.audioInSrtcp;
40394
+ const tally = leg === "video" ? session.videoReceiverReports : session.audioReceiverReports;
40395
+ if (!srtcp) {
40396
+ drop(session, "inbound-rtcp-no-srtcp");
40397
+ storeReceiverReports(session, leg, recordUnreadableRtcp(tally));
40398
+ logUnreadableRtcp(session, leg, "no-srtcp-context", log);
40399
+ return;
40400
+ }
40401
+ let plaintext;
40402
+ try {
40403
+ plaintext = srtcp.decrypt(packet);
40404
+ } catch (err) {
40405
+ drop(session, "inbound-rtcp-decrypt-failed");
40406
+ storeReceiverReports(session, leg, recordUnreadableRtcp(tally));
40407
+ logUnreadableRtcp(session, leg, `decrypt: ${errMsg$8(err)}`, log);
40408
+ return;
40409
+ }
40410
+ const outcome = ingestDecryptedRtcp(plaintext, tally);
40411
+ storeReceiverReports(session, leg, outcome.tally);
40412
+ if (outcome.failure !== null) {
40413
+ drop(session, "inbound-rtcp-parse-failed");
40414
+ logUnreadableRtcp(session, leg, `parse: ${outcome.failure}`, log);
40415
+ return;
40416
+ }
40417
+ const isFirst = tally.reportsParsed === 0 && outcome.tally.reportsParsed > 0;
40418
+ logReceiverReports(session, leg, outcome.reports, isFirst, log);
40419
+ }
40420
+ /** Throttled per leg — a controller sending nothing but garbage must not become the log. */
40421
+ function logUnreadableRtcp(session, leg, reason, log) {
40422
+ const tally = leg === "video" ? session.videoReceiverReports : session.audioReceiverReports;
40423
+ if (!shouldLogReceiverReport(session, leg)) return;
40424
+ log.warn("export-hap: inbound RTCP could not be read", { meta: {
40425
+ sessionId: session.sessionId,
40426
+ leg,
40427
+ reason,
40428
+ unreadable: tally.unreadable
40429
+ } });
40430
+ }
40431
+ /**
40432
+ * True at most once per `RECEIVER_REPORT_LOG_INTERVAL_MS` per leg. Stamps the
40433
+ * leg on the way out so the caller cannot forget to.
40434
+ */
40435
+ function shouldLogReceiverReport(session, leg) {
40436
+ const now = Date.now();
40437
+ if (now < (leg === "video" ? session.videoRrLoggedAt : session.audioRrLoggedAt) + RECEIVER_REPORT_LOG_INTERVAL_MS) return false;
40438
+ if (leg === "video") session.videoRrLoggedAt = now;
40439
+ else session.audioRrLoggedAt = now;
40440
+ return true;
40441
+ }
40442
+ /**
40443
+ * Emit the controller's own numbers.
40444
+ *
40445
+ * The FIRST report on a leg gets its own message: that one arriving at all is
40446
+ * the proof iOS is engaged with the stream, and it was worth a year of
40447
+ * argument. Everything after it is throttled to the heartbeat's cadence.
40448
+ */
40449
+ function logReceiverReports(session, leg, reports, isFirst, log) {
40450
+ const tally = leg === "video" ? session.videoReceiverReports : session.audioReceiverReports;
40451
+ const block = reports.flatMap((report) => report.blocks).at(-1);
40452
+ if (!block) return;
40453
+ const reporter = reports.find((report) => report.blocks.length > 0);
40454
+ const meta = {
40455
+ sessionId: session.sessionId,
40456
+ leg,
40457
+ reporterSsrc: reporter?.reporterSsrc ?? null,
40458
+ aboutSsrc: block.aboutSsrc,
40459
+ fractionLostPct: block.fractionLostPct,
40460
+ cumulativePacketsLost: block.cumulativePacketsLost,
40461
+ extendedHighestSequence: block.extendedHighestSequence,
40462
+ jitter: block.jitter,
40463
+ delaySinceLastSrMs: block.delaySinceLastSrMs,
40464
+ reportsParsed: tally.reportsParsed,
40465
+ worstFractionLostPct: tally.worstFractionLostPct
40466
+ };
40467
+ if (isFirst) {
40468
+ shouldLogReceiverReport(session, leg);
40469
+ log.info("export-hap: FIRST RTCP Receiver Report from controller — iOS is receiving and reporting", { meta });
40470
+ return;
40471
+ }
40472
+ if (!shouldLogReceiverReport(session, leg)) return;
40473
+ log.info("export-hap: controller receiver report", { meta });
40474
+ }
40475
+ /** Snapshot every counter into the summary meta. */
40476
+ function sessionSummaryMeta(session) {
40477
+ return summariseSession({
40478
+ sessionId: session.sessionId,
40479
+ startedAtMs: session.startedAtMs,
40480
+ endedAtMs: session.endedAtMs,
40481
+ negotiated: session.negotiated,
40482
+ selectedSlot: session.selectedSlot,
40483
+ videoPacketsForwarded: session.videoPacketsForwarded,
40484
+ audioPacketsForwarded: session.audioPacketsForwarded,
40485
+ videoRtcpSrSent: session.videoRtcpSrSent,
40486
+ audioRtcpSrSent: session.audioRtcpSrSent,
40487
+ videoRtcpReceived: session.videoRtcpReceived,
40488
+ audioRtcpReceived: session.audioRtcpReceived,
40489
+ videoRtpReceived: session.videoRtpReceived,
40490
+ audioRtpReceived: session.audioRtpReceived,
40491
+ videoGate: session.videoGate,
40492
+ audioGate: session.audioGate,
40493
+ videoReceiverReports: session.videoReceiverReports,
40494
+ audioReceiverReports: session.audioReceiverReports,
40495
+ drops: session.drops,
40496
+ ffmpegExit: session.ffmpegExit,
40497
+ stopRequestedByController: session.stopRequestedByController
38509
40498
  });
38510
40499
  }
38511
40500
  /**
40501
+ * Arm the per-session heartbeat. LOGS ONLY — see `SESSION_HEARTBEAT_MS`. It
40502
+ * exists so a session that stops producing media says so while it is still
40503
+ * alive, instead of being reconstructed after the fact from its final line.
40504
+ */
40505
+ function armHeartbeat(session, log) {
40506
+ if (session.heartbeat) clearInterval(session.heartbeat);
40507
+ let lastVideo = session.videoPacketsForwarded;
40508
+ const timer = setInterval(() => {
40509
+ const forwarded = session.videoPacketsForwarded - lastVideo;
40510
+ lastVideo = session.videoPacketsForwarded;
40511
+ log.info("export-hap: stream heartbeat", { meta: {
40512
+ ...sessionSummaryMeta(session),
40513
+ videoPacketsSinceLastBeat: forwarded,
40514
+ videoStalled: forwarded === 0
40515
+ } });
40516
+ }, SESSION_HEARTBEAT_MS);
40517
+ timer.unref();
40518
+ session.heartbeat = timer;
40519
+ }
40520
+ function stopHeartbeat(session) {
40521
+ if (!session.heartbeat) return;
40522
+ clearInterval(session.heartbeat);
40523
+ session.heartbeat = null;
40524
+ }
40525
+ /**
40526
+ * **`export-hap: stream session summary` is the line to grep** when asking why
40527
+ * a HomeKit session died. It carries the negotiated parameters, the slot we
40528
+ * dialled and the rate we had promised for it, packet counts in both
40529
+ * directions on both legs, each leg's gate outcome, ffmpeg's exit, who asked
40530
+ * for the teardown, every named drop — and, since 2026-08-06, what the
40531
+ * controller itself reported receiving.
40532
+ *
40533
+ * Read `videoLossVerdict` first. `transmit-suspect` says the controller is not
40534
+ * getting our packets intact and nothing past the wire matters;
40535
+ * `decode-suspect` says it got them and rendered nothing anyway;
40536
+ * `no-reports` says we still cannot tell, and `videoReceiverReports.unreadable`
40537
+ * then distinguishes "the controller said nothing" from "we could not read what
40538
+ * it said".
40539
+ *
40540
+ * Emitted from the ffmpeg `exit` handler — the only place the exit code is
40541
+ * known — and directly from the teardown paths when there is no ffmpeg to wait
40542
+ * for. It deliberately does NOT touch the heartbeat: iOS restarts a session
40543
+ * in place when it is not getting a picture (one on record was started three
40544
+ * times in 16 s), and stopping the heartbeat on the superseded process's exit
40545
+ * would silence the replacement.
40546
+ */
40547
+ function logSessionSummary(session, log, trigger) {
40548
+ session.endedAtMs = Date.now();
40549
+ log.info("export-hap: stream session summary", { meta: {
40550
+ ...sessionSummaryMeta(session),
40551
+ trigger
40552
+ } });
40553
+ }
40554
+ /**
38512
40555
  * Build the NTP timestamp expected in an RTCP Sender Report.
38513
40556
  *
38514
40557
  * NTP timestamps are 64-bit: upper 32 bits = seconds since 1900-01-01,
@@ -38541,7 +40584,10 @@ function ntpTime() {
38541
40584
  */
38542
40585
  function sendRtcpSr(session, kind, log) {
38543
40586
  const srtcp = kind === "video" ? session.videoOutSrtcp : session.audioOutSrtcp;
38544
- if (!srtcp) return;
40587
+ if (!srtcp) {
40588
+ drop(session, "rtcp-no-srtcp");
40589
+ return;
40590
+ }
38545
40591
  const sink = kind === "video" ? session.videoUdp : session.audioUdp;
38546
40592
  const port = kind === "video" ? session.hapVideoPort : session.hapAudioPort;
38547
40593
  try {
@@ -38556,15 +40602,31 @@ function sendRtcpSr(session, kind, log) {
38556
40602
  });
38557
40603
  const encrypted = srtcp.encrypt(sr.serialize());
38558
40604
  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
- } });
40605
+ if (err) {
40606
+ drop(session, "srtp-send-failed");
40607
+ log.warn("export-hap: RTCP send error", { meta: {
40608
+ sessionId: session.sessionId,
40609
+ kind,
40610
+ error: err.message
40611
+ } });
40612
+ }
38563
40613
  });
38564
- if (kind === "video") session.videoOutLastRtcpAt = Date.now();
38565
- else session.audioOutLastRtcpAt = Date.now();
40614
+ if (kind === "video") {
40615
+ session.videoOutLastRtcpAt = Date.now();
40616
+ session.videoRtcpSrSent += 1;
40617
+ if (session.videoRtcpSrSent === 1) log.info("export-hap: first VIDEO RTCP Sender Report sent", { meta: {
40618
+ sessionId: session.sessionId,
40619
+ rtpTimestamp: session.videoOutLastRtpTimestamp,
40620
+ intervalMs: session.videoRtcpIntervalMs
40621
+ } });
40622
+ } else {
40623
+ session.audioOutLastRtcpAt = Date.now();
40624
+ session.audioRtcpSrSent += 1;
40625
+ }
38566
40626
  } catch (err) {
38567
- log.debug("export-hap: RTCP SR build/encrypt failed", { meta: {
40627
+ drop(session, "rtcp-encrypt-failed");
40628
+ log.warn("export-hap: RTCP SR build/encrypt failed", { meta: {
40629
+ sessionId: session.sessionId,
38568
40630
  kind,
38569
40631
  error: err instanceof Error ? err.message : String(err)
38570
40632
  } });
@@ -38586,7 +40648,14 @@ function forwardEncryptedRtp(session, rtpPacket, kind, log) {
38586
40648
  const sink = kind === "video" ? session.videoUdp : session.audioUdp;
38587
40649
  const port = kind === "video" ? session.hapVideoPort : session.hapAudioPort;
38588
40650
  const gate = kind === "video" ? session.videoSendGate : session.audioSendGate;
38589
- if (!srtp || !gate) return;
40651
+ if (!srtp) {
40652
+ drop(session, "no-srtp-session");
40653
+ return;
40654
+ }
40655
+ if (!gate) {
40656
+ drop(session, "no-gate");
40657
+ return;
40658
+ }
38590
40659
  gate.then(() => {
38591
40660
  try {
38592
40661
  const parsed = import_src.RtpPacket.deSerialize(rtpPacket);
@@ -38622,32 +40691,46 @@ function forwardEncryptedRtp(session, rtpPacket, kind, log) {
38622
40691
  }
38623
40692
  const encrypted = srtp.encrypt(parsed.payload, parsed.header);
38624
40693
  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
- } });
40694
+ if (err) {
40695
+ drop(session, "srtp-send-failed");
40696
+ log.debug("export-hap: SRTP send error", { meta: {
40697
+ sessionId: session.sessionId,
40698
+ kind,
40699
+ error: err.message
40700
+ } });
40701
+ }
38629
40702
  });
38630
40703
  const now = Date.now();
38631
40704
  if (kind === "video") {
38632
40705
  if (firstVideo || now > session.videoOutLastRtcpAt + session.videoRtcpIntervalMs) sendRtcpSr(session, "video", log);
38633
40706
  } else if (firstAudio || now > session.audioOutLastRtcpAt + session.audioRtcpIntervalMs) sendRtcpSr(session, "audio", log);
38634
40707
  } catch (err) {
40708
+ drop(session, "srtp-encrypt-failed");
38635
40709
  log.debug("export-hap: SRTP encrypt failed", { meta: {
40710
+ sessionId: session.sessionId,
38636
40711
  kind,
38637
40712
  error: err instanceof Error ? err.message : String(err)
38638
40713
  } });
38639
40714
  }
38640
40715
  });
38641
40716
  }
38642
- async function handleStreamRequest(request, sessions, bctx) {
40717
+ async function handleStreamRequest(request, sessions, bctx, advertised) {
38643
40718
  const log = bctx.ctx.logger.withTags({ deviceId: bctx.numericDeviceId });
38644
40719
  const session = sessions.get(request.sessionID);
38645
40720
  if (!session) {
38646
- log.warn("export-hap: stream request for unknown session", { meta: { sessionID: request.sessionID } });
40721
+ log.warn("export-hap: stream request for unknown session", { meta: {
40722
+ sessionID: request.sessionID,
40723
+ type: request.type
40724
+ } });
38647
40725
  return;
38648
40726
  }
38649
40727
  if (request.type === "stop") {
40728
+ session.stopRequestedByController = true;
40729
+ session.teardownTrigger = "controller-stop";
40730
+ const hadFfmpeg = session.ffmpeg !== null;
38650
40731
  killFfmpeg(session, bctx.ctx, bctx.numericDeviceId);
40732
+ stopHeartbeat(session);
40733
+ if (!hadFfmpeg) logSessionSummary(session, log, "controller-stop-no-ffmpeg");
38651
40734
  await closeIntercomTalkSession(session, bctx).catch(() => void 0);
38652
40735
  closeSocket(session.videoUdp);
38653
40736
  closeSocket(session.audioUdp);
@@ -38673,6 +40756,27 @@ async function handleStreamRequest(request, sessions, bctx) {
38673
40756
  session.videoOutOctetCount = 0;
38674
40757
  session.videoOutLastRtpTimestamp = 0;
38675
40758
  session.videoOutLastRtcpAt = 0;
40759
+ session.negotiated = {
40760
+ width: request.video.width,
40761
+ height: request.video.height,
40762
+ fps: request.video.fps,
40763
+ maxBitrateKbps: request.video.max_bit_rate,
40764
+ mtu: request.video.mtu,
40765
+ videoPt: request.video.pt,
40766
+ videoSsrc: session.videoSsrc,
40767
+ videoRtcpIntervalMs: session.videoRtcpIntervalMs,
40768
+ audioPt: request.audio.pt,
40769
+ audioSsrc: session.audioOutSsrc,
40770
+ audioSampleRateKhz: request.audio.sample_rate,
40771
+ audioPacketTimeMs: packetTimeMs,
40772
+ audioRtcpIntervalMs: session.audioRtcpIntervalMs,
40773
+ audioMaxBitrateKbps: request.audio.max_bit_rate
40774
+ };
40775
+ session.startedAtMs = Date.now();
40776
+ log.info("export-hap: stream negotiated", { meta: {
40777
+ sessionId: request.sessionID,
40778
+ ...session.negotiated
40779
+ } });
38676
40780
  session.lastStartParams = {
38677
40781
  pt: request.video.pt,
38678
40782
  mtu: request.video.mtu,
@@ -38693,7 +40797,8 @@ async function handleStreamRequest(request, sessions, bctx) {
38693
40797
  sample_rate: request.audio.sample_rate,
38694
40798
  packet_time: packetTimeMs
38695
40799
  };
38696
- await startFfmpegForSession(bctx, session, request.sessionID, startParams);
40800
+ await startFfmpegForSession(bctx, session, request.sessionID, startParams, advertised);
40801
+ armHeartbeat(session, log);
38697
40802
  return;
38698
40803
  }
38699
40804
  if (request.type === "reconfigure") {
@@ -38720,147 +40825,266 @@ async function handleStreamRequest(request, sessions, bctx) {
38720
40825
  sample_rate: session.lastStartParams.audioSampleRateEnum,
38721
40826
  packet_time: session.lastStartParams.audioPacketTimeMs
38722
40827
  };
38723
- await startFfmpegForSession(bctx, session, request.sessionID, startParams);
40828
+ log.info("export-hap: stream reconfigured", { meta: {
40829
+ sessionId: request.sessionID,
40830
+ width: request.video.width,
40831
+ height: request.video.height,
40832
+ fps: request.video.fps,
40833
+ maxBitrateKbps: request.video.max_bit_rate
40834
+ } });
40835
+ await startFfmpegForSession(bctx, session, request.sessionID, startParams, advertised);
40836
+ armHeartbeat(session, log);
38724
40837
  }
38725
40838
  }
38726
- async function startFfmpegForSession(bctx, session, sessionId, video) {
40839
+ async function startFfmpegForSession(bctx, session, sessionId, video, advertised) {
38727
40840
  const { ctx, proxy, numericDeviceId, options } = bctx;
40841
+ const startLog = ctx.logger.withTags({ deviceId: numericDeviceId });
38728
40842
  const entries = await proxy.cameraStreams?.getProfileRtspEntries({}) ?? [];
38729
- if (entries.length === 0) throw new Error(`export-hap: no profile RTSP entries for device ${numericDeviceId}`);
40843
+ if (entries.length === 0) {
40844
+ startLog.warn("export-hap: stream start DROPPED — device publishes no profile RTSP entries", { meta: {
40845
+ sessionId,
40846
+ capBound: proxy.cameraStreams !== void 0
40847
+ } });
40848
+ throw new Error(`export-hap: no profile RTSP entries for device ${numericDeviceId}`);
40849
+ }
38730
40850
  const pref = options.hapDeviceSettings.streamPreference;
38731
- const picked = pickPreferredRtspEntry(entries, pref, numericDeviceId, { targetResolution: {
38732
- width: video.width,
38733
- height: video.height
38734
- } });
38735
- if (!picked) throw new Error(`export-hap: no enabled RTSP entries for device ${numericDeviceId}`);
40851
+ const brokerStreams = await proxy.cameraStreams?.getBrokerStreams({}) ?? [];
40852
+ const bitrates = await probeProfileBitrates({
40853
+ bctx,
40854
+ slots: brokerStreams,
40855
+ log: startLog
40856
+ });
40857
+ const fit = selectStreamForBudget({
40858
+ entries: withSlotCodecs(entries, brokerStreams),
40859
+ deviceId: numericDeviceId,
40860
+ pref,
40861
+ targetResolution: {
40862
+ width: video.width,
40863
+ height: video.height
40864
+ },
40865
+ negotiatedMaxBitrateKbps: video.max_bit_rate,
40866
+ bitrates
40867
+ });
40868
+ if (fit === null) {
40869
+ startLog.warn("export-hap: stream start DROPPED — no ENABLED profile RTSP entry", { meta: {
40870
+ sessionId,
40871
+ streamPreference: pref,
40872
+ targetResolution: `${video.width}x${video.height}`,
40873
+ entries: entries.map((e) => `${e.profile}:${e.enabled ? "enabled" : "disabled"}`)
40874
+ } });
40875
+ throw new Error(`export-hap: no enabled RTSP entries for device ${numericDeviceId}`);
40876
+ }
40877
+ const picked = fit.picked;
38736
40878
  const rtspUrl = picked.url;
38737
- const slot = (await proxy.cameraStreams?.getBrokerStreams({}) ?? []).find((s) => s.brokerId === picked.brokerId);
40879
+ const slot = brokerStreams.find((s) => s.profile === fit.profile);
38738
40880
  const codec = (picked.codec ?? slot?.codec ?? "").toLowerCase();
38739
- const needsTranscode = codec.includes("h265") || codec.includes("hevc");
40881
+ const needsTranscode = fit.kind === "transcode";
40882
+ const pickedProfile = toKnownProfile(picked.profileId);
40883
+ const resolvedFps = pickedProfile === null ? void 0 : advertised.fpsByProfile.get(pickedProfile);
40884
+ const advertisedFps = resolvedFps?.fps ?? video.fps;
40885
+ const advertisedFpsSource = resolvedFps?.source ?? "assumed";
40886
+ const deliveredFps = needsTranscode ? deliverableFps(video.fps, resolvedFps?.fps ?? null) : advertisedFps;
40887
+ const slotEvidence = pickedProfile === null ? void 0 : bitrates.get(pickedProfile);
40888
+ const fitNotes = formatFitNotes(fit.notes);
40889
+ session.selectedSlot = {
40890
+ profile: pickedProfile,
40891
+ brokerId: picked.brokerId,
40892
+ width: picked.resolution?.width ?? null,
40893
+ height: picked.resolution?.height ?? null,
40894
+ advertisedFps,
40895
+ advertisedFpsSource,
40896
+ deliveredFps,
40897
+ codec: codec.length > 0 ? codec : "unknown",
40898
+ transcode: needsTranscode,
40899
+ fitReason: fit.reason,
40900
+ publishedKbps: slotEvidence?.publishedKbps ?? null,
40901
+ measuredKbps: slotEvidence?.measuredKbps ?? null,
40902
+ budgetKbps: fit.budgetKbps,
40903
+ fitNotes
40904
+ };
40905
+ startLog.info("export-hap: stream bitrate fit resolved", { meta: {
40906
+ sessionId,
40907
+ decision: fit.kind,
40908
+ reason: fit.reason,
40909
+ negotiatedMaxBitrateKbps: video.max_bit_rate,
40910
+ encodeBudgetKbps: fit.budgetKbps,
40911
+ profile: pickedProfile,
40912
+ brokerId: picked.brokerId,
40913
+ slotPublishedKbps: slotEvidence?.publishedKbps ?? null,
40914
+ slotMeasuredKbps: slotEvidence?.measuredKbps ?? null,
40915
+ candidates: fitNotes
40916
+ } });
40917
+ if (deliveredFps !== video.fps) startLog.warn("export-hap: negotiated fps does NOT match the rate we are about to deliver", { meta: {
40918
+ sessionId,
40919
+ negotiatedFps: video.fps,
40920
+ slotFps: advertisedFps,
40921
+ slotFpsSource: advertisedFpsSource,
40922
+ deliveredFps,
40923
+ profile: pickedProfile,
40924
+ brokerId: picked.brokerId,
40925
+ transcode: needsTranscode
40926
+ } });
38740
40927
  const videoLoopPort = session.videoLoopUdp.address().port;
38741
40928
  const audioLoopPort = session.audioLoopUdp.address().port;
38742
40929
  const videoTarget = `rtp://127.0.0.1:${videoLoopPort}?pkt_size=${video.mtu}`;
38743
40930
  const audioTarget = `rtp://127.0.0.1:${audioLoopPort}?pkt_size=${video.mtu}`;
38744
- const videoArgs = needsTranscode ? [
38745
- "-c:v",
38746
- "libx264",
38747
- "-preset",
38748
- "ultrafast",
38749
- "-tune",
38750
- "zerolatency",
38751
- "-pix_fmt",
38752
- "yuv420p",
38753
- "-r",
38754
- String(video.fps),
38755
- "-s",
38756
- `${video.width}x${video.height}`,
38757
- "-b:v",
38758
- `${video.max_bit_rate}k`,
38759
- "-bufsize",
38760
- `${video.max_bit_rate * 2}k`,
38761
- "-maxrate",
38762
- `${video.max_bit_rate}k`,
38763
- "-profile:v",
38764
- "baseline",
38765
- "-level",
38766
- "3.1"
38767
- ] : [
38768
- "-c:v",
38769
- "copy",
38770
- "-bsf:v",
38771
- "dump_extra"
38772
- ];
40931
+ const videoArgs = buildVideoEncodeArgs({
40932
+ transcode: needsTranscode,
40933
+ width: video.width,
40934
+ height: video.height,
40935
+ fps: deliveredFps,
40936
+ budgetKbps: fit.budgetKbps
40937
+ });
40938
+ const hwDecode = selectHwDecode({
40939
+ transcode: needsTranscode,
40940
+ reading: needsTranscode ? await probeDecoderHwaccel({
40941
+ ctx,
40942
+ log: startLog
40943
+ }) : null,
40944
+ platform: process.platform
40945
+ });
40946
+ logDecodePath(startLog, sessionId, hwDecode, needsTranscode);
38773
40947
  const videoSsrcSigned = session.videoSsrc | 0;
38774
40948
  const audioSsrcSigned = video.audio_ssrc | 0;
38775
- const args = [
38776
- "-hide_banner",
38777
- "-loglevel",
38778
- "warning",
38779
- "-rtsp_transport",
38780
- "tcp",
38781
- "-i",
40949
+ const buildArgs = (decodeArgs) => buildSessionFfmpegArgs({
40950
+ decodeArgs,
38782
40951
  rtspUrl,
38783
- "-an",
38784
- "-map",
38785
- "0:v:0",
38786
- ...videoArgs,
38787
- "-payload_type",
38788
- String(video.pt),
38789
- "-ssrc",
38790
- String(videoSsrcSigned),
38791
- "-f",
38792
- "rtp",
40952
+ videoArgs,
38793
40953
  videoTarget,
38794
- "-vn",
38795
- "-map",
38796
- "0:a:0?",
38797
- "-af",
38798
- "aresample=async=1000:first_pts=0",
38799
- "-c:a",
38800
- "libopus",
38801
- "-application",
38802
- "lowdelay",
38803
- "-frame_duration",
38804
- String(video.packet_time ?? 20),
38805
- "-flags",
38806
- "+global_header",
38807
- "-ar",
38808
- String((video.sample_rate ?? 16) * 1e3),
38809
- "-b:a",
38810
- `${OPUS_BITRATE_KBPS}k`,
38811
- "-bufsize",
38812
- `${OPUS_BITRATE_KBPS * 4}k`,
38813
- "-ac",
38814
- String(OPUS_CHANNELS),
38815
- "-payload_type",
38816
- String(video.audio_pt),
38817
- "-ssrc",
38818
- String(audioSsrcSigned),
38819
- "-f",
38820
- "rtp",
38821
- audioTarget
38822
- ];
38823
- const log = ctx.logger.withTags({ deviceId: numericDeviceId });
38824
- const proc = spawn("ffmpeg", args, { stdio: [
38825
- "ignore",
38826
- "ignore",
38827
- "pipe"
38828
- ] });
38829
- session.ffmpeg = proc;
38830
- proc.stderr?.on("data", (chunk) => {
38831
- const line = chunk.toString("utf8").trim();
38832
- if (!line) return;
38833
- log.info("export-hap: ffmpeg", { meta: {
38834
- sessionId,
38835
- line
38836
- } });
40954
+ audioTarget,
40955
+ videoPayloadType: video.pt,
40956
+ videoSsrcSigned,
40957
+ audioPayloadType: video.audio_pt,
40958
+ audioSsrcSigned,
40959
+ audioPacketTimeMs: video.packet_time ?? 20,
40960
+ audioSampleRateKhz: video.sample_rate ?? 16
38837
40961
  });
38838
- proc.once("exit", (code, signal) => {
40962
+ const log = ctx.logger.withTags({ deviceId: numericDeviceId });
40963
+ let hardwareAlreadyFailed = false;
40964
+ const spawnFfmpeg = (decision) => {
40965
+ const spawnedAtMs = Date.now();
40966
+ const usedHardware = decision.kind === "hardware";
40967
+ const proc = spawn("ffmpeg", buildArgs(decision.args), { stdio: [
40968
+ "ignore",
40969
+ "ignore",
40970
+ "pipe"
40971
+ ] });
40972
+ session.ffmpeg = proc;
40973
+ proc.stderr?.on("data", (chunk) => {
40974
+ const line = chunk.toString("utf8").trim();
40975
+ if (!line) return;
40976
+ log.info("export-hap: ffmpeg", { meta: {
40977
+ sessionId,
40978
+ line
40979
+ } });
40980
+ });
40981
+ proc.once("exit", (code, signal) => {
40982
+ if (shouldRetryInSoftware({
40983
+ usedHardware,
40984
+ hardwareAlreadyFailed,
40985
+ stopRequestedByController: session.stopRequestedByController,
40986
+ videoPacketsForwarded: session.videoPacketsForwarded,
40987
+ runtimeMs: Date.now() - spawnedAtMs
40988
+ })) {
40989
+ hardwareAlreadyFailed = true;
40990
+ log.warn("export-hap: hardware decode FAILED at init — respawning ffmpeg in software", { meta: {
40991
+ sessionId,
40992
+ backend: decision.kind === "hardware" ? decision.backend : null,
40993
+ backendSource: decision.kind === "hardware" ? decision.source : null,
40994
+ code,
40995
+ signal,
40996
+ runtimeMs: Date.now() - spawnedAtMs
40997
+ } });
40998
+ if (session.ffmpeg === proc) session.ffmpeg = null;
40999
+ spawnFfmpeg({
41000
+ kind: "software",
41001
+ reason: "hardware-attempt-failed",
41002
+ args: []
41003
+ });
41004
+ return;
41005
+ }
41006
+ onFfmpegExit(proc, code, signal);
41007
+ });
41008
+ proc.once("error", (err) => {
41009
+ log.warn("export-hap: ffmpeg spawn failed", { meta: {
41010
+ sessionId,
41011
+ error: err.message
41012
+ } });
41013
+ });
41014
+ };
41015
+ const onFfmpegExit = (proc, code, signal) => {
41016
+ session.ffmpegExit = {
41017
+ code,
41018
+ signal
41019
+ };
38839
41020
  const ok = code === 0 && signal === null;
38840
41021
  const meta = {
38841
41022
  sessionId,
38842
41023
  code,
38843
- signal
41024
+ signal,
41025
+ stopRequestedByController: session.stopRequestedByController,
41026
+ videoPacketsForwarded: session.videoPacketsForwarded
38844
41027
  };
38845
- if (ok) log.info("export-hap: ffmpeg exited", { meta });
38846
- else log.warn("export-hap: ffmpeg exited abnormally", { meta });
41028
+ if (ok || session.stopRequestedByController) log.info("export-hap: ffmpeg exited", { meta });
41029
+ else log.warn("export-hap: ffmpeg exited without a controller stop", { meta });
38847
41030
  if (session.ffmpeg === proc) session.ffmpeg = null;
38848
- });
38849
- proc.once("error", (err) => {
38850
- log.warn("export-hap: ffmpeg spawn failed", { meta: {
38851
- sessionId,
38852
- error: err.message
38853
- } });
38854
- });
41031
+ logSessionSummary(session, log, session.teardownTrigger ?? "ffmpeg-exit");
41032
+ };
41033
+ spawnFfmpeg(hwDecode);
38855
41034
  log.info("export-hap: stream started", { meta: {
38856
41035
  sessionId,
38857
41036
  transcode: needsTranscode,
38858
- rtspUrl: picked.brokerId,
41037
+ brokerId: picked.brokerId,
41038
+ profile: pickedProfile,
41039
+ sourceResolution: picked.resolution === void 0 ? null : `${picked.resolution.width}x${picked.resolution.height}`,
41040
+ sourceCodec: codec.length > 0 ? codec : "unknown",
41041
+ negotiated: `${video.width}x${video.height}@${video.fps} ${video.max_bit_rate}kbps`,
41042
+ slotFps: advertisedFps,
41043
+ slotFpsSource: advertisedFpsSource,
41044
+ deliveredFps,
41045
+ fitReason: fit.reason,
41046
+ encodeBudgetKbps: fit.budgetKbps,
38859
41047
  audioCodec: "opus",
38860
- audioBitrateKbps: OPUS_BITRATE_KBPS
41048
+ audioBitrateKbps: 24,
41049
+ videoDecode: hwDecode.kind === "hardware" ? hwDecode.backend : "software"
41050
+ } });
41051
+ }
41052
+ /**
41053
+ * Say which decode path was resolved, and WHY, once per session.
41054
+ *
41055
+ * At `info` on purpose — Loki carries `info+`, and "did this session use the
41056
+ * GPU" is the first question anyone asks of a transcoding hub. The software
41057
+ * branch is the one that must never be silent: it is a real cost being paid,
41058
+ * and every reason it can be reached is a different fix.
41059
+ */
41060
+ function logDecodePath(log, sessionId, decision, transcode) {
41061
+ if (decision.kind === "hardware") {
41062
+ log.info("export-hap: video decode path resolved — HARDWARE", { meta: {
41063
+ sessionId,
41064
+ backend: decision.backend,
41065
+ backendSource: decision.source,
41066
+ decodeArgs: decision.args
41067
+ } });
41068
+ return;
41069
+ }
41070
+ const level = transcode ? "warn" : "info";
41071
+ const message = level === "warn" ? "export-hap: video decode path resolved — SOFTWARE, this transcode costs a core" : "export-hap: video decode path resolved — none needed";
41072
+ log[level](message, { meta: {
41073
+ sessionId,
41074
+ reason: decision.reason,
41075
+ transcode
38861
41076
  } });
38862
41077
  }
38863
41078
  /**
41079
+ * `PickedStream.profileId` is the brokerId suffix; for a profile-keyed entry
41080
+ * that IS the profile name. Anything else addresses a raw cam-stream and must
41081
+ * not be coerced into a profile.
41082
+ */
41083
+ function toKnownProfile(profileId) {
41084
+ if (profileId === "high" || profileId === "mid" || profileId === "low") return profileId;
41085
+ return null;
41086
+ }
41087
+ /**
38864
41088
  * Handle one incoming SRTP audio packet from iOS Home.
38865
41089
  *
38866
41090
  * End-to-end flow:
@@ -38881,15 +41105,22 @@ async function startFfmpegForSession(bctx, session, sessionId, video) {
38881
41105
  * stays codec-agnostic.
38882
41106
  */
38883
41107
  async function handleIncomingAudioRtp(session, packet, sourceAddress, bctx) {
38884
- if (packet.length < 12) return;
41108
+ if (packet.length < 12) {
41109
+ drop(session, "upstream-short-packet");
41110
+ return;
41111
+ }
38885
41112
  const log = bctx.ctx.logger.withTags({ deviceId: bctx.numericDeviceId });
38886
41113
  session.upstreamRtpPacketsReceived += 1;
38887
- if (!session.upstreamAudioSrtp) return;
41114
+ if (!session.upstreamAudioSrtp) {
41115
+ drop(session, "upstream-no-srtp");
41116
+ return;
41117
+ }
38888
41118
  let decryptedPacket;
38889
41119
  try {
38890
41120
  decryptedPacket = session.upstreamAudioSrtp.decrypt(packet);
38891
41121
  session.upstreamRtpPacketsDecrypted += 1;
38892
41122
  } catch (err) {
41123
+ drop(session, "upstream-decrypt-failed");
38893
41124
  session.upstreamRtpDecryptFailures += 1;
38894
41125
  if (session.upstreamRtpDecryptFailures % 100 === 1) log.debug("export-hap: SRTP decrypt failed (rate-limited)", { meta: {
38895
41126
  failures: session.upstreamRtpDecryptFailures,
@@ -38906,18 +41137,28 @@ async function handleIncomingAudioRtp(session, packet, sourceAddress, bctx) {
38906
41137
  rtpTimestamp = parsedRtp.header.timestamp;
38907
41138
  rtpPayloadType = parsedRtp.header.payloadType;
38908
41139
  } catch (err) {
41140
+ drop(session, "upstream-parse-failed");
38909
41141
  log.debug("export-hap: RTP parse failed after decrypt", { meta: { error: errMsg$8(err) } });
38910
41142
  return;
38911
41143
  }
38912
41144
  const negotiatedAudioPt = session.lastStartParams?.audioPt;
38913
- if (negotiatedAudioPt === void 0) return;
38914
- if (rtpPayloadType !== negotiatedAudioPt) return;
41145
+ if (negotiatedAudioPt === void 0) {
41146
+ drop(session, "upstream-before-start");
41147
+ return;
41148
+ }
41149
+ if (rtpPayloadType !== negotiatedAudioPt) {
41150
+ drop(session, "upstream-pt-mismatch");
41151
+ return;
41152
+ }
38915
41153
  if (session.firstUpstreamRtpTimestamp === null) {
38916
41154
  session.firstUpstreamRtpTimestamp = rtpTimestamp;
38917
41155
  log.info("export-hap: first upstream RTP packet received");
38918
41156
  }
38919
41157
  const payload = decryptedPayload;
38920
- if (payload.length === 0) return;
41158
+ if (payload.length === 0) {
41159
+ drop(session, "upstream-empty-payload");
41160
+ return;
41161
+ }
38921
41162
  if (session.intercomTalkSessionId === null) {
38922
41163
  session.intercomTalkSessionId = "";
38923
41164
  const opened = await openIntercomTalkSession(bctx).catch((err) => {
@@ -38929,7 +41170,10 @@ async function handleIncomingAudioRtp(session, packet, sourceAddress, bctx) {
38929
41170
  log.info("export-hap: intercom talk session opened", { meta: { sessionId: opened.sessionId } });
38930
41171
  }
38931
41172
  }
38932
- if (!session.intercomTalkSessionId) return;
41173
+ if (!session.intercomTalkSessionId) {
41174
+ drop(session, "upstream-no-talk-session");
41175
+ return;
41176
+ }
38933
41177
  const opusSampleRateHz = (session.lastStartParams?.audioSampleRateEnum ?? 16) * 1e3;
38934
41178
  session.intercomPcmSequence += 1;
38935
41179
  session.upstreamPcmFramesDecoded += 1;
@@ -38942,6 +41186,7 @@ async function handleIncomingAudioRtp(session, packet, sourceAddress, bctx) {
38942
41186
  sequenceNumber: session.intercomPcmSequence
38943
41187
  });
38944
41188
  } catch (err) {
41189
+ drop(session, "upstream-push-failed");
38945
41190
  log.debug("export-hap: intercom.pushTalkAudio failed (will re-open on next frame)", { meta: {
38946
41191
  sequenceNumber: session.intercomPcmSequence,
38947
41192
  error: errMsg$8(err)
@@ -39025,140 +41270,6 @@ function errMsg$7(err) {
39025
41270
  return err instanceof Error ? err.message : String(err);
39026
41271
  }
39027
41272
  //#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
41273
  //#region src/mappers/builders/intercom.ts
39163
41274
  async function buildIntercom(input) {
39164
41275
  const { bctx, streamingOptions } = input;
@@ -39186,7 +41297,7 @@ async function buildIntercom(input) {
39186
41297
  var RESET_DEBOUNCE_MS = 5e3;
39187
41298
  async function buildMotionSensor(bctx) {
39188
41299
  const { ctx, accessory, proxy, numericDeviceId, displayName } = bctx;
39189
- const motionService = accessory.addService(Service.MotionSensor, displayName);
41300
+ const motionService = accessory.addService(Service.MotionSensor, hapServiceName([displayName], `Camera ${numericDeviceId}`));
39190
41301
  motionService.setCharacteristic(Characteristic.MotionDetected, false);
39191
41302
  try {
39192
41303
  const detected = await proxy.motion?.isDetected({});
@@ -39241,12 +41352,12 @@ function errMsg$6(err) {
39241
41352
  * camera-enabled switch — distinct from privacy-mask).
39242
41353
  */
39243
41354
  async function buildPrivacySwitch(bctx) {
39244
- const { ctx, accessory, proxy, numericDeviceId } = bctx;
41355
+ const { ctx, accessory, proxy, numericDeviceId, displayName } = bctx;
39245
41356
  const log = ctx.logger.withTags({ deviceId: numericDeviceId });
39246
41357
  const subtype = "privacy-mask";
39247
- const configuredName = "Privacy";
39248
- const service = accessory.addService(Service.Switch, configuredName, subtype);
39249
- service.setCharacteristic(Characteristic.ConfiguredName, configuredName);
41358
+ const serviceName = privacyServiceName(displayName);
41359
+ const service = accessory.addService(Service.Switch, serviceName, subtype);
41360
+ service.setCharacteristic(Characteristic.Name, serviceName);
39250
41361
  try {
39251
41362
  const status = await proxy.privacyMask?.getStatus({});
39252
41363
  if (status && typeof status.enabled === "boolean") service.updateCharacteristic(Characteristic.On, status.enabled);
@@ -39336,18 +41447,23 @@ function ptzPresetLabel(presetName) {
39336
41447
  * `proxy.ptzAutotrack.setEnabled({enabled})`. Initial value is
39337
41448
  * hydrated from `getStatus({})`.
39338
41449
  *
39339
- * Naming: each switch uses a BARE per-action label ("Preset stanza",
39340
- * "Pan Left", "Autotrack") set as BOTH the service name AND its
39341
- * `ConfiguredName`, mirroring `child-switch.ts` / `privacy-switch.ts`.
39342
- * iOS Home renders sibling services on an accessory by their
39343
- * `ConfiguredName`. The old `${displayName} — <action>` form (em-dash
39344
- * U+2014 + redundant camera prefix) was rejected by HAP-NodeJS as an
39345
- * invalid `Name` characteristic, so iOS discarded it and showed generic
39346
- * "Interruttore N".
41450
+ * Naming: `<camera> <action>` "Videocamera ingresso Preset stanza" — built
41451
+ * by `ptzServiceName` and written to the `Name` characteristic.
41452
+ *
41453
+ * TWO separate naming defects have been through this file, and both are fixed
41454
+ * by that one line:
41455
+ * - `${displayName} <action>` embedded an em-dash (U+2014), which is
41456
+ * outside Apple's permitted set. hap-nodejs' `checkName` warned, iOS
41457
+ * discarded the name and showed "Interruttore N". A previous round dropped
41458
+ * the camera prefix along with the em-dash; only the em-dash was the fault.
41459
+ * - The bare label that replaced it was then written to `ConfiguredName`,
41460
+ * which `Service.Switch` does not list, so hap-nodejs rejected the
41461
+ * characteristic outright — SIX rejections per PTZ camera per build, never
41462
+ * reported because only the two switches on the non-PTZ camera were noticed.
39347
41463
  */
39348
41464
  var MOMENTARY_RESET_MS = 1e3;
39349
41465
  async function buildPtz(bctx) {
39350
- const { ctx, accessory, proxy, numericDeviceId, options } = bctx;
41466
+ const { ctx, accessory, proxy, numericDeviceId, displayName, options } = bctx;
39351
41467
  const log = ctx.logger.withTags({ deviceId: numericDeviceId });
39352
41468
  const timers = /* @__PURE__ */ new Set();
39353
41469
  const armReset = (cb, delay) => {
@@ -39359,10 +41475,10 @@ async function buildPtz(bctx) {
39359
41475
  };
39360
41476
  const presets = await readPresets(bctx);
39361
41477
  for (const preset of presets) {
39362
- const label = ptzPresetLabel(preset.name);
41478
+ const label = ptzServiceName(displayName, ptzPresetLabel(preset.name));
39363
41479
  const subtype = `ptz-preset-${preset.id}`;
39364
41480
  const service = accessory.addService(Service.Switch, label, subtype);
39365
- service.setCharacteristic(Characteristic.ConfiguredName, label);
41481
+ service.setCharacteristic(Characteristic.Name, label);
39366
41482
  service.getCharacteristic(Characteristic.On).onSet(async (value) => {
39367
41483
  if (value !== true) return;
39368
41484
  try {
@@ -39377,9 +41493,9 @@ async function buildPtz(bctx) {
39377
41493
  });
39378
41494
  }
39379
41495
  if (proxy.ptz) for (const dir of PTZ_DIRECTIONS) {
39380
- const label = dir.label;
41496
+ const label = ptzServiceName(displayName, dir.label);
39381
41497
  const service = accessory.addService(Service.Switch, label, dir.subtype);
39382
- service.setCharacteristic(Characteristic.ConfiguredName, label);
41498
+ service.setCharacteristic(Characteristic.Name, label);
39383
41499
  service.getCharacteristic(Characteristic.On).onSet(async (value) => {
39384
41500
  if (value !== true) return;
39385
41501
  try {
@@ -39423,12 +41539,12 @@ async function readPresets(bctx) {
39423
41539
  }
39424
41540
  }
39425
41541
  async function tryBuildAutotrack(bctx) {
39426
- const { ctx, accessory, proxy, numericDeviceId } = bctx;
41542
+ const { ctx, accessory, proxy, numericDeviceId, displayName } = bctx;
39427
41543
  if (!proxy.ptzAutotrack) return { async dispose() {} };
39428
41544
  const log = ctx.logger.withTags({ deviceId: numericDeviceId });
39429
- const label = PTZ_AUTOTRACK_LABEL;
41545
+ const label = ptzServiceName(displayName, PTZ_AUTOTRACK_LABEL);
39430
41546
  const service = accessory.addService(Service.Switch, label, "ptz-autotrack");
39431
- service.setCharacteristic(Characteristic.ConfiguredName, label);
41547
+ service.setCharacteristic(Characteristic.Name, label);
39432
41548
  try {
39433
41549
  const status = await proxy.ptzAutotrack.getStatus({});
39434
41550
  if (status && typeof status.enabled === "boolean") service.updateCharacteristic(Characteristic.On, status.enabled);
@@ -39452,6 +41568,65 @@ function errMsg$4(err) {
39452
41568
  return err instanceof Error ? err.message : String(err);
39453
41569
  }
39454
41570
  //#endregion
41571
+ //#region src/mappers/builders/stream-fps-probe.ts
41572
+ async function probeAdvertisedVideoProfile(bctx) {
41573
+ const { ctx, proxy, numericDeviceId, options } = bctx;
41574
+ const log = ctx.logger.withTags({ deviceId: numericDeviceId });
41575
+ const entries = await probe(() => proxy.cameraStreams?.getProfileRtspEntries({}), "cameraStreams.getProfileRtspEntries", log);
41576
+ const slots = await probe(() => proxy.cameraStreams?.getBrokerStreams({}), "cameraStreams.getBrokerStreams", log);
41577
+ const camStreams = await probe(() => proxy.cameraStreams?.getCameraStreams({}), "cameraStreams.getCameraStreams", log);
41578
+ const choices = await probe(() => proxy.webrtcSession?.listStreams({}), "webrtcSession.listStreams", log);
41579
+ const fpsByProfile = resolveProfileFps({
41580
+ choices: choices ?? [],
41581
+ slots: slots ?? [],
41582
+ camStreams: camStreams ?? []
41583
+ });
41584
+ const resolutions = deriveAdvertisedResolutions({
41585
+ candidates: CANDIDATE_RESOLUTIONS,
41586
+ entries: entries ?? [],
41587
+ deviceId: numericDeviceId,
41588
+ pref: options.hapDeviceSettings.streamPreference,
41589
+ fpsByProfile
41590
+ });
41591
+ const assumed = resolutions.filter((r) => r.source === "assumed").length;
41592
+ log.info("export-hap: advertised video profile derived", { meta: {
41593
+ streamPreference: options.hapDeviceSettings.streamPreference,
41594
+ resolutions: formatAdvertisedResolutions(resolutions),
41595
+ profileFps: [...fpsByProfile.values()].map((f) => `${f.profile}=${f.fps}(${f.source})`),
41596
+ assumedCount: assumed,
41597
+ previouslyAdvertisedFps: 30
41598
+ } });
41599
+ if (assumed === resolutions.length) log.warn("export-hap: no measured or published frame rate for ANY profile — advertising the assumed rate", { meta: {
41600
+ entries: entries?.length ?? 0,
41601
+ choices: choices?.length ?? 0
41602
+ } });
41603
+ return {
41604
+ resolutions,
41605
+ fpsByProfile
41606
+ };
41607
+ }
41608
+ /**
41609
+ * Run one cap read. Returns null both when the cap is not bound and when the
41610
+ * call throws — and LOGS which, because "the camera has no telemetry" and "the
41611
+ * telemetry call failed" lead to different fixes.
41612
+ */
41613
+ async function probe(call, label, log) {
41614
+ try {
41615
+ const pending = call();
41616
+ if (pending === void 0) {
41617
+ log.info("export-hap: fps probe skipped — cap not bound on this device", { meta: { call: label } });
41618
+ return null;
41619
+ }
41620
+ return await pending;
41621
+ } catch (err) {
41622
+ log.warn("export-hap: fps probe failed — falling back to a lower-authority source", { meta: {
41623
+ call: label,
41624
+ error: err instanceof Error ? err.message : String(err)
41625
+ } });
41626
+ return null;
41627
+ }
41628
+ }
41629
+ //#endregion
39455
41630
  //#region src/mappers/builders/child-switch.ts
39456
41631
  /**
39457
41632
  * Child-switch builder — turns a camstack accessory child device (siren,
@@ -39496,7 +41671,7 @@ async function buildChildSwitch(bctx, subtype, deviceType) {
39496
41671
  const isLightingDevice = deviceType === DeviceType.Light || deviceType === DeviceType.Generic;
39497
41672
  const useLightbulb = hasBrightness && isLightingDevice;
39498
41673
  const service = useLightbulb ? accessory.addService(Service.Lightbulb, displayName, subtype) : accessory.addService(Service.Switch, displayName, subtype);
39499
- service.setCharacteristic(Characteristic.ConfiguredName, displayName);
41674
+ service.setCharacteristic(Characteristic.Name, displayName);
39500
41675
  try {
39501
41676
  const switchStatus = await proxy.switch?.getStatus({});
39502
41677
  if (switchStatus && typeof switchStatus.on === "boolean") service.updateCharacteristic(Characteristic.On, switchStatus.on);
@@ -39568,7 +41743,7 @@ async function buildChildServicesFor(input) {
39568
41743
  accessory: parentCtx.accessory,
39569
41744
  proxy: childProxy,
39570
41745
  numericDeviceId: child.id,
39571
- displayName: formatChildServiceName(parentDisplayName, child),
41746
+ displayName: childServiceName(parentDisplayName, child),
39572
41747
  options
39573
41748
  };
39574
41749
  const subtype = `child-${child.id}`;
@@ -39599,21 +41774,6 @@ async function listChildren(ctx, parentNumericId) {
39599
41774
  }
39600
41775
  }
39601
41776
  /**
39602
- * Service name displayed inside the camera tile detail in iOS Home.
39603
- * Prefer the child's own role ("Siren", "Floodlight") when meaningful
39604
- * — the camera name is already implied by the surrounding Accessory.
39605
- * Falls back to the child's stored device name when role is empty.
39606
- */
39607
- function formatChildServiceName(parentName, child) {
39608
- const role = child.role && child.role.length > 0 ? toTitleCase(child.role) : null;
39609
- if (role) return role;
39610
- if (child.name.toLowerCase().includes(parentName.toLowerCase())) {
39611
- const stripped = child.name.replace(new RegExp(`\\b${escapeRegex(parentName)}\\b`, "i"), "").replace(/\s+[—-]\s+/, " ").trim();
39612
- if (stripped.length > 0) return stripped;
39613
- }
39614
- return child.name;
39615
- }
39616
- /**
39617
41777
  * Coerce the raw `child.type` string (from `deviceManager.getChildren`)
39618
41778
  * to a `DeviceType` enum value. Unknown / mis-cased values fall back to
39619
41779
  * `Generic` so an unrecognised driver behaves like the safest existing
@@ -39625,12 +41785,6 @@ function asDeviceType(raw) {
39625
41785
  for (const value of Object.values(DeviceType)) if (value === lower) return value;
39626
41786
  return DeviceType.Generic;
39627
41787
  }
39628
- function escapeRegex(s) {
39629
- return s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
39630
- }
39631
- function toTitleCase(raw) {
39632
- return raw.split("-").filter((part) => part.length > 0).map((part) => part.charAt(0).toUpperCase() + part.slice(1)).join(" ");
39633
- }
39634
41788
  function errMsg$2(err) {
39635
41789
  return err instanceof Error ? err.message : String(err);
39636
41790
  }
@@ -39678,24 +41832,18 @@ async function buildCameraAccessory(input) {
39678
41832
  displayName,
39679
41833
  options
39680
41834
  };
39681
- const streams = buildCameraStreamingDelegate(bctx);
41835
+ const streams = buildCameraStreamingDelegate(bctx, await probeAdvertisedVideoProfile(bctx));
39682
41836
  const handles = [];
39683
41837
  if (capNames.has("intercom")) handles.push(await buildIntercom({
39684
41838
  bctx,
39685
41839
  streamingOptions: streams.streamingOptions
39686
41840
  }));
39687
- const hksv = capNames.has("motion-detection") ? buildHksvStub(bctx) : null;
39688
41841
  const controller = new (isDoorbell ? DoorbellController : CameraController)({
39689
41842
  delegate: streams.delegate,
39690
41843
  streamingOptions: streams.streamingOptions,
39691
- cameraStreamCount: 2,
39692
- ...hksv ? { recording: {
39693
- options: hksv.options,
39694
- delegate: hksv.delegate
39695
- } } : {}
41844
+ cameraStreamCount: 2
39696
41845
  });
39697
41846
  accessory.configureController(controller);
39698
- if (hksv) handles.push(hksv.handle);
39699
41847
  if (capNames.has("motion-detection")) handles.push(await buildMotionSensor(bctx));
39700
41848
  if (isDoorbell && controller instanceof DoorbellController) handles.push(await buildDoorbell({
39701
41849
  bctx,