@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.
@@ -799,7 +799,7 @@ var propertyKeyTypes = /* @__PURE__*/ new Set([
799
799
  "number",
800
800
  "symbol"
801
801
  ]);
802
- function escapeRegex$1(str) {
802
+ function escapeRegex(str) {
803
803
  return str.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
804
804
  }
805
805
  function clone(inst, def, params) {
@@ -1529,7 +1529,7 @@ var $ZodCheckUpperCase = /*@__PURE__*/ $constructor("$ZodCheckUpperCase", (inst,
1529
1529
  });
1530
1530
  var $ZodCheckIncludes = /*@__PURE__*/ $constructor("$ZodCheckIncludes", (inst, def) => {
1531
1531
  $ZodCheck.init(inst, def);
1532
- const escapedRegex = escapeRegex$1(def.includes);
1532
+ const escapedRegex = escapeRegex(def.includes);
1533
1533
  const pattern = new RegExp(typeof def.position === "number" ? `^.{${def.position}}${escapedRegex}` : escapedRegex);
1534
1534
  def.pattern = pattern;
1535
1535
  inst._zod.onattach.push((inst) => {
@@ -1552,7 +1552,7 @@ var $ZodCheckIncludes = /*@__PURE__*/ $constructor("$ZodCheckIncludes", (inst, d
1552
1552
  });
1553
1553
  var $ZodCheckStartsWith = /*@__PURE__*/ $constructor("$ZodCheckStartsWith", (inst, def) => {
1554
1554
  $ZodCheck.init(inst, def);
1555
- const pattern = new RegExp(`^${escapeRegex$1(def.prefix)}.*`);
1555
+ const pattern = new RegExp(`^${escapeRegex(def.prefix)}.*`);
1556
1556
  def.pattern ?? (def.pattern = pattern);
1557
1557
  inst._zod.onattach.push((inst) => {
1558
1558
  const bag = inst._zod.bag;
@@ -1574,7 +1574,7 @@ var $ZodCheckStartsWith = /*@__PURE__*/ $constructor("$ZodCheckStartsWith", (ins
1574
1574
  });
1575
1575
  var $ZodCheckEndsWith = /*@__PURE__*/ $constructor("$ZodCheckEndsWith", (inst, def) => {
1576
1576
  $ZodCheck.init(inst, def);
1577
- const pattern = new RegExp(`.*${escapeRegex$1(def.suffix)}$`);
1577
+ const pattern = new RegExp(`.*${escapeRegex(def.suffix)}$`);
1578
1578
  def.pattern ?? (def.pattern = pattern);
1579
1579
  inst._zod.onattach.push((inst) => {
1580
1580
  const bag = inst._zod.bag;
@@ -2810,7 +2810,7 @@ var $ZodEnum = /*@__PURE__*/ $constructor("$ZodEnum", (inst, def) => {
2810
2810
  const values = getEnumValues(def.entries);
2811
2811
  const valuesSet = new Set(values);
2812
2812
  inst._zod.values = valuesSet;
2813
- inst._zod.pattern = new RegExp(`^(${values.filter((k) => propertyKeyTypes.has(typeof k)).map((o) => typeof o === "string" ? escapeRegex$1(o) : o.toString()).join("|")})$`);
2813
+ inst._zod.pattern = new RegExp(`^(${values.filter((k) => propertyKeyTypes.has(typeof k)).map((o) => typeof o === "string" ? escapeRegex(o) : o.toString()).join("|")})$`);
2814
2814
  inst._zod.parse = (payload, _ctx) => {
2815
2815
  const input = payload.value;
2816
2816
  if (valuesSet.has(input)) return payload;
@@ -2828,7 +2828,7 @@ var $ZodLiteral = /*@__PURE__*/ $constructor("$ZodLiteral", (inst, def) => {
2828
2828
  if (def.values.length === 0) throw new Error("Cannot create literal schema with no valid values");
2829
2829
  const values = new Set(def.values);
2830
2830
  inst._zod.values = values;
2831
- inst._zod.pattern = new RegExp(`^(${def.values.map((o) => typeof o === "string" ? escapeRegex$1(o) : o ? escapeRegex$1(o.toString()) : String(o)).join("|")})$`);
2831
+ inst._zod.pattern = new RegExp(`^(${def.values.map((o) => typeof o === "string" ? escapeRegex(o) : o ? escapeRegex(o.toString()) : String(o)).join("|")})$`);
2832
2832
  inst._zod.parse = (payload, _ctx) => {
2833
2833
  const input = payload.value;
2834
2834
  if (values.has(input)) return payload;
@@ -6521,6 +6521,36 @@ var ProfileRtspEntrySchema = object({
6521
6521
  resolution: CamStreamResolutionSchema.optional()
6522
6522
  });
6523
6523
  /**
6524
+ * Per-call node pinning for `ctx.api` capability calls.
6525
+ *
6526
+ * A capability call normally resolves to its DEFAULT provider — a `singleton`
6527
+ * cap resolves to the hub, a device-scoped cap to the device's owning node. To
6528
+ * query a SPECIFIC node's provider instead (e.g. a remote agent's own
6529
+ * in-process `platform-probe` hardware, which the hub cannot probe), pin the
6530
+ * call to that node.
6531
+ *
6532
+ * The nodeId rides OUT-OF-BAND in the tRPC call context (NOT in the validated
6533
+ * method args), so capability method signatures stay `nodeId`-free — node
6534
+ * targeting is a property of the CALL, not of the method. The transport lifts
6535
+ * it from `op.context` onto the `CapCallInput.nodeId` field (`ipcParentLink`),
6536
+ * and the hub parent's `onUnownedCall` passes it to the `CapRouteResolver`,
6537
+ * which classifies a pinned agent node as `agent-child-forward`
6538
+ * (`$agent-cap-fwd.forward` → the agent's in-process provider).
6539
+ *
6540
+ * Usage at a call site:
6541
+ *
6542
+ * await api.platformProbe.getCapabilities.query(undefined, nodePin(nodeId))
6543
+ */
6544
+ /** tRPC `op.context` key carrying a per-call node pin. */
6545
+ var CAP_NODE_PIN_CONTEXT_KEY = "__camstackNodePin";
6546
+ /**
6547
+ * Build the tRPC request options that pin a single capability call to `nodeId`.
6548
+ * Pass as the second argument to `.query(input, …)` / `.mutate(input, …)`.
6549
+ */
6550
+ function nodePin(nodeId) {
6551
+ return { context: { [CAP_NODE_PIN_CONTEXT_KEY]: nodeId } };
6552
+ }
6553
+ /**
6524
6554
  * Output schema shared by the contribution + live methods.
6525
6555
  *
6526
6556
  * Mirrors the `ConfigUISchemaWithValues` shape (sections[] + optional
@@ -6968,6 +6998,39 @@ method(object({ deviceId: number() }), array(StreamSourceEntrySchema)), method(o
6968
6998
  action: string().min(1),
6969
6999
  input: unknown()
6970
7000
  }), unknown(), { kind: "mutation" }), method(object({ deviceId: number() }), _void(), { kind: "mutation" }), method(object({ deviceId: number() }), unknown().nullable()), method(object({ deviceId: number() }), RawStateResultSchema.nullable(), { auth: "protected" });
7001
+ //#endregion
7002
+ //#region ../types/dist/canonical-hash-7nfBbEqR.mjs
7003
+ /**
7004
+ * Deterministic SHA-256 hash of an arbitrary serialisable value. The
7005
+ * canonical form sorts object keys alphabetically at every depth so two
7006
+ * structurally-equal inputs with different key insertion orders produce
7007
+ * the same hash. Returns a 64-char lowercase hex digest.
7008
+ *
7009
+ * Used by export adapters (Alexa, HAP) to short-circuit re-discovery /
7010
+ * accessory-rebuild work when the upstream shape is byte-identical to
7011
+ * the last applied state — preventing user-visible "re-discovery"
7012
+ * notifications on every addon-runner respawn. Each respawn re-fires
7013
+ * `DeviceBindingsChanged` for every cap registration, which without
7014
+ * this guard would propagate redundant pushes.
7015
+ *
7016
+ * Note: this is a SYMPTOMATIC fix layered on top of the binding-change
7017
+ * subscription. The proper fix is a single "device ready" lifecycle
7018
+ * barrier so exports react only when the full cap set has landed —
7019
+ * tracked separately for post-HA-integration work.
7020
+ */
7021
+ function canonicalHash(value) {
7022
+ const canonical = JSON.stringify(value, replaceWithSortedKeys);
7023
+ return (0, node_crypto.createHash)("sha256").update(canonical ?? "").digest("hex");
7024
+ }
7025
+ function replaceWithSortedKeys(_key, value) {
7026
+ if (value && typeof value === "object" && !Array.isArray(value)) {
7027
+ const obj = value;
7028
+ const out = {};
7029
+ for (const k of Object.keys(obj).toSorted()) out[k] = obj[k];
7030
+ return out;
7031
+ }
7032
+ return value;
7033
+ }
6971
7034
  var EncodeProfileSchema = object({
6972
7035
  video: object({
6973
7036
  codec: _enum([
@@ -6980,6 +7043,14 @@ var EncodeProfileSchema = object({
6980
7043
  "main",
6981
7044
  "high"
6982
7045
  ]).optional(),
7046
+ /**
7047
+ * `-level`, e.g. `'3.1'`. A consumer that ADVERTISES a level in its SDP
7048
+ * (`profile-level-id=42e01f` is Baseline 3.1) must constrain the encoder to
7049
+ * it, or it ships a stream that does not match its own advertisement — the
7050
+ * defect class that kept HomeKit black for a year and that Alexa carried
7051
+ * silently. Optional because a browser negotiates the level itself.
7052
+ */
7053
+ level: string().optional(),
6983
7054
  width: number().int().positive().optional(),
6984
7055
  height: number().int().positive().optional(),
6985
7056
  fps: number().positive().optional(),
@@ -7027,6 +7098,29 @@ var EncodeProfileSchema = object({
7027
7098
  outputArgs: array(string()).optional()
7028
7099
  });
7029
7100
  /**
7101
+ * The shape every live egress starts from: H.264 Baseline 3.1 at 720p25.
7102
+ * Baseline because it is the one profile every consumer in this repo decodes
7103
+ * (Echo, iOS, an old browser); 3.1 because that is what the SDPs advertise.
7104
+ */
7105
+ var BASE_LIVE_EGRESS_PROFILE = {
7106
+ video: {
7107
+ codec: "h264",
7108
+ profile: "baseline",
7109
+ level: "3.1",
7110
+ width: 1280,
7111
+ height: 720,
7112
+ fps: 25,
7113
+ bitrateKbps: 2500,
7114
+ gopFrames: 25,
7115
+ bf: 0,
7116
+ preset: "veryfast",
7117
+ tune: "zerolatency"
7118
+ },
7119
+ audio: "passthrough"
7120
+ };
7121
+ ({ ...BASE_LIVE_EGRESS_PROFILE }), { ...BASE_LIVE_EGRESS_PROFILE.video };
7122
+ ({ ...BASE_LIVE_EGRESS_PROFILE });
7123
+ /**
7030
7124
  * Deep wiring healthcheck — snapshot of active reachability probes across
7031
7125
  * every declared capability + widget of every installed plugin, on every
7032
7126
  * node. Produced by the backend `WiringHealthService` and surfaced via
@@ -7076,6 +7170,105 @@ object({
7076
7170
  })
7077
7171
  });
7078
7172
  /**
7173
+ * Per-camera FUNCTION SWITCHES — the one coherent on/off surface over the
7174
+ * pipeline functions an operator thinks in terms of.
7175
+ *
7176
+ * ## This file adds no state
7177
+ *
7178
+ * Every switch here is a VIEW onto an authority that already existed
7179
+ * ([D61](../../../../docs/decisions/adr-0062.md)). The whole point of the
7180
+ * group is that there is exactly one place each function is turned off, and
7181
+ * the group routes to it:
7182
+ *
7183
+ * | Switch | Authority | Proven "off stops the work" gate |
7184
+ * | --- | --- | --- |
7185
+ * | `stream-broker` | `deviceManager.setDisabled` | `StreamBrokerManager.reconcileAllCatalogs` releases the brokers; `ensureBroker` refuses re-creation |
7186
+ * | `object-detection` | `deviceManager.setWrapperActive('detection-pipeline')` | `PipelineSettingsStore.resolvePipelineForDevice` returns `{ steps: [], audio: null }` |
7187
+ * | `audio-analysis` | `deviceManager.setWrapperActive('audio-analysis')` | `AudioSubscriptionController.subscribeAudioStream` returns `null` before opening the stream |
7188
+ * | `recording` | `recording.setDeviceConfig` → `RecordingConfig.enabled` | `band-decision.shouldRecord` returns false; the controller detaches the device |
7189
+ * | `notifications` | `notificationRules.setDeviceMuted` | `NotificationCenter.evaluateAndEnqueue` returns before any rule is evaluated |
7190
+ *
7191
+ * The wrapper-binding pair is not a new idea: `legacy-migrations.ts` already
7192
+ * migrated the legacy `audioEnabled` / `pipelineEnabled` /
7193
+ * `motionDetectionEnabled` booleans ONTO `setWrapperActive`. The group is the
7194
+ * surface that decision never got.
7195
+ *
7196
+ * ## Two rules that are load-bearing
7197
+ *
7198
+ * - **Recording's switch is `enabled`, never the bands.** `bands` is the only
7199
+ * authored intent and `mode` is derived from it (`deriveRecordingMode`).
7200
+ * Expressing "off" by clearing bands destroys the operator's schedule and
7201
+ * turning the camera back on would then silently record nothing.
7202
+ * - **A switch that is off must be reported as off**, not merely produce
7203
+ * nothing. {@link CameraSwitch.enabled} is what a status surface renders as
7204
+ * "disabled by an operator" instead of "broken" — see
7205
+ * `CameraStatus.switchedOff`.
7206
+ */
7207
+ /**
7208
+ * The five functions the operator named (2026-08-05). Deliberately NOT one id
7209
+ * per pipeline step: face recognition and plate/LPR are per-step toggles on
7210
+ * `pipelineOrchestrator.setCameraStepToggle` and belong in the pipeline
7211
+ * editor, not in a five-button safety group.
7212
+ */
7213
+ var CameraSwitchIdSchema = _enum([
7214
+ "stream-broker",
7215
+ "object-detection",
7216
+ "audio-analysis",
7217
+ "recording",
7218
+ "notifications"
7219
+ ]);
7220
+ /**
7221
+ * WHERE the switch's state actually lives. A discriminated union rather than a
7222
+ * string so both the writer (the orchestrator's `setCameraSwitch`) and any
7223
+ * reader can exhaustively narrow — and so "the group added a parallel map" is
7224
+ * a compile error rather than a review comment.
7225
+ */
7226
+ var CameraSwitchAuthoritySchema = discriminatedUnion("kind", [
7227
+ object({ kind: literal("device-disabled") }),
7228
+ object({
7229
+ kind: literal("wrapper-binding"),
7230
+ capName: string()
7231
+ }),
7232
+ object({ kind: literal("recording-config") }),
7233
+ object({ kind: literal("notification-mute") })
7234
+ ]);
7235
+ /**
7236
+ * Why a switch is not offered for this camera. Rendered instead of the
7237
+ * control, never as a dead control — an absent function and a broken one must
7238
+ * not look the same.
7239
+ */
7240
+ var CameraSwitchUnavailableReasonSchema = _enum(["no-provider", "source-unreachable"]);
7241
+ /**
7242
+ * One switch, resolved for one camera.
7243
+ *
7244
+ * `label` and `costWhenOff` travel ON THE WIRE rather than being looked up
7245
+ * client-side: the viewer is a separate repository that does not import
7246
+ * `@camstack/types`, and a cost line duplicated in two clients is a cost line
7247
+ * that will disagree with itself. Five rows per camera is nothing.
7248
+ */
7249
+ var CameraSwitchSchema = object({
7250
+ id: CameraSwitchIdSchema,
7251
+ label: string(),
7252
+ /**
7253
+ * What the operator LOSES while this is off, in one sentence. Required, not
7254
+ * optional: a switch that cannot say what it costs should not ship.
7255
+ */
7256
+ costWhenOff: string(),
7257
+ /** False = do not render a control. `unavailableReason` says why. */
7258
+ available: boolean(),
7259
+ unavailableReason: CameraSwitchUnavailableReasonSchema.optional(),
7260
+ /** Current state. Meaningless when `available` is false — read it as `true`. */
7261
+ enabled: boolean(),
7262
+ authority: CameraSwitchAuthoritySchema
7263
+ });
7264
+ /** The whole group for one camera. */
7265
+ var CameraSwitchGroupSchema = object({
7266
+ deviceId: number().int(),
7267
+ switches: array(CameraSwitchSchema).readonly(),
7268
+ /** Unix ms when the group was composed server-side. */
7269
+ fetchedAt: number()
7270
+ });
7271
+ /**
7079
7272
  * Ops-log — the durable, append-only operations audit shared by the
7080
7273
  * recordings and events management surfaces.
7081
7274
  *
@@ -7094,14 +7287,16 @@ var OpsLogOpSchema = _enum([
7094
7287
  "manual-delete",
7095
7288
  "rescan",
7096
7289
  "retention-run",
7097
- "relocate"
7290
+ "relocate",
7291
+ "orphan-audit"
7098
7292
  ]);
7099
7293
  /** Why the operation ran. */
7100
7294
  var OpsLogReasonSchema = _enum([
7101
7295
  "retention",
7102
7296
  "quota",
7103
7297
  "manual",
7104
- "operator"
7298
+ "operator",
7299
+ "maintenance"
7105
7300
  ]);
7106
7301
  /** One audit row, shared verbatim by both domains. */
7107
7302
  var OpsLogEntrySchema = object({
@@ -9016,6 +9211,100 @@ var RtpSourceSchema = object({
9016
9211
  encoder: string(),
9017
9212
  pipelineKey: string()
9018
9213
  });
9214
+ /**
9215
+ * The encode request — **structured and serialisable, with NO raw-flag escape
9216
+ * hatch.** This is deliberate and it is the one lesson taken from
9217
+ * `getStreamWithCodec`: that method's `outputArgs: string[]` is simultaneously
9218
+ * its extensibility mechanism AND part of `pipelineKeyFor`'s sharing key, so
9219
+ * adding a flag silently forks the shared child, and two consumers that mean
9220
+ * the same thing but spell it differently never share. Here every knob is a
9221
+ * NAMED field: a new requirement becomes a schema field (and a codegen run),
9222
+ * never an opaque array.
9223
+ *
9224
+ * `inputArgs` / `outputArgs` are omitted from the profile for the same reason.
9225
+ * The operator-facing derived-stream transform editor still has them — that is
9226
+ * a different surface (`publishCameraStream({ kind: 'derived' })`) with a
9227
+ * different purpose (reshaping a badly-behaved SOURCE), and it is unchanged.
9228
+ */
9229
+ var EgressEncodeSchema = EncodeProfileSchema.omit({
9230
+ inputArgs: true,
9231
+ outputArgs: true
9232
+ });
9233
+ /**
9234
+ * How the encoder is bounded. `'tight'` is a one-second VBV window for a
9235
+ * consumer whose budget is enforced per second (HomeKit); `'relaxed'` is two
9236
+ * seconds, letting a keyframe spike borrow from the next second (a browser,
9237
+ * an Echo). Named rather than numeric so the INTENT survives.
9238
+ */
9239
+ var EgressRateControlSchema = _enum(["tight", "relaxed"]);
9240
+ var EgressTranscodeRequestSchema = object({
9241
+ deviceId: number().int().nonnegative(),
9242
+ /** Which published stream to read. */
9243
+ source: discriminatedUnion("kind", [object({
9244
+ kind: literal("profile"),
9245
+ profile: CamProfileSchema
9246
+ }), object({
9247
+ kind: literal("cam-stream"),
9248
+ camStreamId: string().min(1)
9249
+ })]),
9250
+ encode: EgressEncodeSchema,
9251
+ rateControl: EgressRateControlSchema.optional(),
9252
+ /**
9253
+ * `-bsf:v`. A consumer that negotiates its OWN SDP (HomeKit) cannot carry
9254
+ * out-of-band extradata and needs `dump_extra` on both the copy and encode
9255
+ * branches. Enumerated, not free text.
9256
+ */
9257
+ bitstreamFilter: _enum([
9258
+ "dump_extra",
9259
+ "h264_mp4toannexb",
9260
+ "hevc_mp4toannexb"
9261
+ ]).optional(),
9262
+ pixelFormat: _enum(["yuv420p", "nv12"]).optional(),
9263
+ /**
9264
+ * Operator/consumer override for decode hardware. ABSENT is the normal case
9265
+ * and the one that matters: the broker then resolves the backend from the
9266
+ * DECODER ADDON's per-node `probedBestHwaccel` (see
9267
+ * `@camstack/types` `ffmpeg/hwaccel.ts`), which is the ranking known to work
9268
+ * on this hardware — never the raw kernel resolver's qsv-first order.
9269
+ */
9270
+ decodeHwAccel: _enum([
9271
+ "auto",
9272
+ "none",
9273
+ "videotoolbox",
9274
+ "vaapi",
9275
+ "qsv",
9276
+ "cuda"
9277
+ ]).optional(),
9278
+ /**
9279
+ * Host to embed in the returned restream `url`. The broker mints hub-local
9280
+ * `127.0.0.1` URLs; a consumer on another node passes a cluster-resolvable
9281
+ * host (`NodeTopologyService.reachableHostByNode`) so the returned URL is
9282
+ * dialable from there. Same contract as `getStreamWithCodec.hostname` —
9283
+ * `substituteRtspHost` rewrites only the dial address, never the restreamer.
9284
+ */
9285
+ hostname: string().optional(),
9286
+ /** Attribution for the broker panel. Never part of the sharing key. */
9287
+ tag: string().optional()
9288
+ });
9289
+ var EgressTranscodeSchema = object({
9290
+ /** Dial-able RTSP url (host-substituted when `hostname` was supplied). */
9291
+ url: string(),
9292
+ /** Release handle. Refcounted — the child dies when the last holder releases. */
9293
+ pipelineKey: string(),
9294
+ videoCodec: _enum(["H264", "H265"]),
9295
+ resolution: object({
9296
+ width: number().int().positive(),
9297
+ height: number().int().positive()
9298
+ }),
9299
+ transcoded: boolean(),
9300
+ encoder: string(),
9301
+ /**
9302
+ * The decode backend the child ACTUALLY ran with — `null` for software.
9303
+ * Returned rather than assumed: a consumer that asked for hardware and got
9304
+ * software needs to be able to see that without reading the broker's logs.
9305
+ */
9306
+ decodeHwAccel: string().nullable()
9307
+ });
9019
9308
  method(object({
9020
9309
  deviceId: number().int().nonnegative(),
9021
9310
  camStreamId: string().min(1),
@@ -9125,6 +9414,15 @@ method(object({
9125
9414
  }), {
9126
9415
  kind: "mutation",
9127
9416
  auth: "admin"
9417
+ }), method(EgressTranscodeRequestSchema, EgressTranscodeSchema, {
9418
+ kind: "mutation",
9419
+ auth: "admin"
9420
+ }), method(object({ pipelineKey: string() }), object({
9421
+ released: boolean(),
9422
+ refcount: number().int().nonnegative()
9423
+ }), {
9424
+ kind: "mutation",
9425
+ auth: "admin"
9128
9426
  }), method(SubscribeAudioChunksInputSchema, SubscribeAudioChunksResultSchema, { kind: "mutation" }), method(object({
9129
9427
  subscriptionId: string(),
9130
9428
  maxCount: number().int().positive().default(8)
@@ -9579,6 +9877,62 @@ method(_void(), EngineInfoSchema), method(object({
9579
9877
  indexes: array(CollectionIndexSchema).readonly().optional()
9580
9878
  }), _void(), { kind: "mutation" });
9581
9879
  /**
9880
+ * Stable UI option list for the `hwaccel` setting. Decoder addons
9881
+ * reuse this for `globalSettingsSchema()` so the dropdown is
9882
+ * identical everywhere. Order: auto → off → common backends by
9883
+ * platform affinity (macOS, NVIDIA, Intel/AMD, Windows, Linux).
9884
+ */
9885
+ var HWACCEL_OPTIONS = [
9886
+ {
9887
+ value: "auto",
9888
+ label: "Auto (defer to probed best)"
9889
+ },
9890
+ {
9891
+ value: "none",
9892
+ label: "Off (software)"
9893
+ },
9894
+ {
9895
+ value: "videotoolbox",
9896
+ label: "VideoToolbox (macOS)"
9897
+ },
9898
+ {
9899
+ value: "cuda",
9900
+ label: "CUDA (NVIDIA)"
9901
+ },
9902
+ {
9903
+ value: "nvdec",
9904
+ label: "NVDEC (NVIDIA legacy)"
9905
+ },
9906
+ {
9907
+ value: "vaapi",
9908
+ label: "VAAPI (Linux Intel/AMD)"
9909
+ },
9910
+ {
9911
+ value: "qsv",
9912
+ label: "QuickSync (Intel)"
9913
+ },
9914
+ {
9915
+ value: "d3d11va",
9916
+ label: "D3D11VA (Windows)"
9917
+ },
9918
+ {
9919
+ value: "dxva2",
9920
+ label: "DXVA2 (Windows legacy)"
9921
+ },
9922
+ {
9923
+ value: "amf",
9924
+ label: "AMF (AMD)"
9925
+ },
9926
+ {
9927
+ value: "vdpau",
9928
+ label: "VDPAU (Linux NVIDIA legacy)"
9929
+ },
9930
+ {
9931
+ value: "drm",
9932
+ label: "DRM (Linux generic)"
9933
+ }
9934
+ ];
9935
+ /**
9582
9936
  * shm ring usage stats for a `frameSink: 'shm'` decoder session —
9583
9937
  * exposed via `decoder.getShmStats` so downstream consumers can
9584
9938
  * observe ring pressure (slot count, byte budget, hit/miss ratio).
@@ -12928,7 +13282,7 @@ var DETECTION_SUB_COLORS = {
12928
13282
  zebra: "#404040",
12929
13283
  giraffe: "#d4a373"
12930
13284
  };
12931
- function titleCase(id) {
13285
+ function titleCase$1(id) {
12932
13286
  return id.split(/[-_ ]/).filter((p) => p.length > 0).map((p) => p.charAt(0).toUpperCase() + p.slice(1)).join(" ");
12933
13287
  }
12934
13288
  var entries = /* @__PURE__ */ new Map();
@@ -12967,7 +13321,7 @@ macro("control", "control", TAXONOMY_COLORS.control, "control", "Control");
12967
13321
  for (const [cocoClass, macroClass] of Object.entries(COCO_TO_MACRO.mapping)) {
12968
13322
  if (macroClass !== "vehicle" && macroClass !== "animal") continue;
12969
13323
  if (entries.has(cocoClass)) continue;
12970
- sub(cocoClass, macroClass, "detection", DETECTION_SUB_COLORS[cocoClass] ?? TAXONOMY_COLORS.genericDetection, cocoClass, titleCase(cocoClass));
13324
+ sub(cocoClass, macroClass, "detection", DETECTION_SUB_COLORS[cocoClass] ?? TAXONOMY_COLORS.genericDetection, cocoClass, titleCase$1(cocoClass));
12971
13325
  }
12972
13326
  sub("package-delivered", "package", "package", TAXONOMY_COLORS.package, "package", "Package delivered");
12973
13327
  sub("package-picked-up", "package", "package", TAXONOMY_COLORS.package, "package", "Package picked up");
@@ -13476,12 +13830,13 @@ var NcConditionsSchema = object({
13476
13830
  * source; otherwise the subject's source must equal it. Legacy records
13477
13831
  * with no stamped source are treated as `pipeline`. The union spans both
13478
13832
  * record kinds — object events carry `pipeline` | `onboard`, synthetic
13479
- * tracks carry `sensor`.
13833
+ * tracks carry `sensor` (a linked device) or `audio` (a D62 audio marker).
13480
13834
  */
13481
13835
  source: _enum([
13482
13836
  "pipeline",
13483
13837
  "onboard",
13484
13838
  "sensor",
13839
+ "audio",
13485
13840
  "any"
13486
13841
  ]).optional(),
13487
13842
  /**
@@ -14057,6 +14412,12 @@ method(object({}), object({ rules: array(NcRuleSchema) }), { auth: "admin" }), m
14057
14412
  }), object({ success: literal(true) }), {
14058
14413
  kind: "mutation",
14059
14414
  auth: "admin"
14415
+ }), method(object({}), object({ mutedDeviceIds: array(number().int()).readonly() }), { auth: "admin" }), method(object({
14416
+ deviceId: number().int(),
14417
+ muted: boolean()
14418
+ }), object({ success: literal(true) }), {
14419
+ kind: "mutation",
14420
+ auth: "admin"
14060
14421
  }), method(object({
14061
14422
  rule: NcRuleInputSchema,
14062
14423
  lookbackMinutes: number().int().min(1).max(1440).default(60)
@@ -14395,12 +14756,60 @@ var TrackAudioLabelSchema = object({
14395
14756
  });
14396
14757
  /**
14397
14758
  * How a track was produced. `pipeline` (default / absent) = the spatial
14398
- * detection+tracking pipeline. `sensor` = a SYNTHETIC track projected from a
14399
- * linked sensor/control state change (no positions; carries a snapshot). The
14400
- * spatial subsystems (tracker association, occupancy count, re-id/embedding,
14401
- * resurrection) MUST skip `sensor` tracks they have no bbox trajectory.
14759
+ * detection+tracking pipeline. Every OTHER value is a SYNTHETIC projection
14760
+ * no positions, a single snapshot, and no bbox trajectory at all:
14761
+ *
14762
+ * - `sensor` — a linked sensor/control device state change.
14763
+ * - `audio` — an audio event on the camera itself that was anomalous for
14764
+ * THAT camera, loud, and heard while nothing visual was happening (D62).
14765
+ *
14766
+ * The spatial subsystems (tracker association, occupancy count, re-id /
14767
+ * embedding, resurrection) MUST skip every synthetic source. Test for that
14768
+ * with `isSpatialTrack`, which allow-lists `pipeline` — a `!== 'sensor'`
14769
+ * check silently readmits every source added after it was written.
14770
+ */
14771
+ var TrackSourceSchema = _enum([
14772
+ "pipeline",
14773
+ "sensor",
14774
+ "audio"
14775
+ ]);
14776
+ /**
14777
+ * Per-track OPERATOR flags — set by hand from the admin UI or the viewer, never
14778
+ * by the pipeline. Spread into `TrackSchema` and `KeyEventSchema` from one place
14779
+ * so the two surfaces cannot drift.
14780
+ *
14781
+ * **Absent ≠ false.** A track that has never been touched omits the field; an
14782
+ * explicitly un-flagged track carries `false`. Legacy rows written before the
14783
+ * columns existed read as absent, and a consumer that needs a boolean should say
14784
+ * `flag === true`, not `flag !== false`.
14785
+ *
14786
+ * What the flags DO is deliberately UNDEFINED at the time of writing: they are
14787
+ * operator curation, and the behaviour they drive will be specified separately.
14788
+ * In particular a `markForTrain` track is NOT pinned against retention — see
14789
+ * `docs/decisions/adr-0059.md` for why that is a store-level change, not a flag.
14790
+ */
14791
+ var TrackFlagFields = {
14792
+ /** Operator marked this track as training material. */
14793
+ markForTrain: boolean().optional(),
14794
+ /** Operator marked this track for diagnostic attention. */
14795
+ debug: boolean().optional()
14796
+ };
14797
+ /**
14798
+ * The write half: a PARTIAL patch. An omitted key is left untouched, so setting
14799
+ * one flag can never clear the other — the toggles are independent and are
14800
+ * driven from three surfaces that do not know about each other.
14801
+ */
14802
+ var TrackFlagsPatchSchema = object(TrackFlagFields);
14803
+ /**
14804
+ * The resolved flag state after a write. Both fields are REQUIRED here (absent
14805
+ * collapses to `false`) so a caller can drive a toggle's checked state off the
14806
+ * mutation result without a re-fetch.
14402
14807
  */
14403
- var TrackSourceSchema = _enum(["pipeline", "sensor"]);
14808
+ var TrackFlagsSchema = object({
14809
+ trackId: string(),
14810
+ markForTrain: boolean(),
14811
+ debug: boolean()
14812
+ });
14404
14813
  var TrackSchema = object({
14405
14814
  trackId: string(),
14406
14815
  deviceId: number(),
@@ -14443,7 +14852,8 @@ var TrackSchema = object({
14443
14852
  /** Normalized 0..1 trajectory envelope (see {@link TrackEnvelopeSchema}).
14444
14853
  * Populated from the persisted envelope columns on historical reads;
14445
14854
  * absent on legacy rows, dims-less tracks and active (in-RAM) tracks. */
14446
- envelope: TrackEnvelopeSchema.optional()
14855
+ envelope: TrackEnvelopeSchema.optional(),
14856
+ ...TrackFlagFields
14447
14857
  });
14448
14858
  var BaseEventFields = {
14449
14859
  id: string(),
@@ -14656,7 +15066,8 @@ var KeyEventSchema = object({
14656
15066
  /** Highest-confidence ObjectEvent id for the track (empty when none). */
14657
15067
  bestEventId: string(),
14658
15068
  /** Track lifetime in ms (lastSeen - firstSeen). */
14659
- windowMs: number().optional()
15069
+ windowMs: number().optional(),
15070
+ ...TrackFlagFields
14660
15071
  });
14661
15072
  object({
14662
15073
  trackId: string(),
@@ -14727,6 +15138,104 @@ var EventPruneCountsSchema = object({
14727
15138
  object: number().int(),
14728
15139
  audio: number().int()
14729
15140
  });
15141
+ /**
15142
+ * Re-embed stored tracks from their key frames.
15143
+ *
15144
+ * The reason this is an operator-callable method and not a migration script:
15145
+ * every knob that decides what a vector MEANS — encoder model, crop margin,
15146
+ * squaring — is only changeable if the existing vectors can be regenerated.
15147
+ * Mixing feature spaces in one index makes cosine scores incomparable, and the
15148
+ * symptom is a quality regression with no visible cause.
15149
+ */
15150
+ var RebuildObjectEmbeddingsInput = object({
15151
+ /** Restrict to one camera. Omit for the whole fleet. */
15152
+ deviceId: number().optional(),
15153
+ since: number().optional(),
15154
+ until: number().optional(),
15155
+ /** Stop after this many tracks; the result reports whether more remain. */
15156
+ maxTracks: number().int().positive().optional(),
15157
+ /**
15158
+ * Run every embedding on THIS node instead of round-robining the fleet.
15159
+ *
15160
+ * Named `executeOnNodeId` and not `nodeId` on purpose: an inline `nodeId`
15161
+ * field in cap args is read by `parent-unowned-call.ts` as a ROUTING PIN, so
15162
+ * calling it that would pin the rebuild REQUEST itself to that node — the
15163
+ * rebuild orchestration lives on the hub, and only the per-track step runs
15164
+ * remotely. This field is data; the per-track pin is applied inside.
15165
+ *
15166
+ * Absent ⇒ round-robin over every online node whose runner can serve the
15167
+ * pinned model.
15168
+ */
15169
+ executeOnNodeId: string().optional(),
15170
+ /**
15171
+ * Milliseconds to wait between tracks; omit for the built-in default, `0` to
15172
+ * run flat out.
15173
+ *
15174
+ * A rebuild is bulk maintenance on hub-main's single thread. Measured
15175
+ * 2026-08-06, an unpaced pass held that thread busy 82.2 s out of 120 and
15176
+ * pushed `nodes.topology` from 0.25 s to 26 s for 43 minutes. The value in
15177
+ * force is logged at start and finish so a deliberately slow pass reads
15178
+ * differently from a stalled one.
15179
+ */
15180
+ pacingMs: number().int().nonnegative().optional()
15181
+ });
15182
+ /**
15183
+ * Result of emptying the CLIP index.
15184
+ *
15185
+ * The clean slate before a policy change: a new crop margin or encoder model
15186
+ * leaves two feature spaces in one index whose cosine scores are not
15187
+ * comparable, so wiping and rebuilding is the only way to be sure every vector
15188
+ * means the same thing.
15189
+ */
15190
+ var WipeObjectEmbeddingsResultSchema = object({ deleted: number() });
15191
+ /**
15192
+ * Acknowledgement that a rebuild STARTED.
15193
+ *
15194
+ * The pass costs ~1s per track — 45 minutes for a 2,600-track fleet — so it
15195
+ * runs detached and this returns immediately. Waiting for it made the client
15196
+ * time out while the work carried on server-side, which is the worst of both:
15197
+ * no result and no way to know it was still going. Poll
15198
+ * `getObjectEmbeddingRebuildStatus` for progress.
15199
+ */
15200
+ var RebuildObjectEmbeddingsResultSchema = object({
15201
+ started: boolean(),
15202
+ /** True when a pass was already running; the new request is ignored. */
15203
+ alreadyRunning: boolean()
15204
+ });
15205
+ var RebuildStatusSchema = object({
15206
+ running: boolean(),
15207
+ scanned: number(),
15208
+ rebuilt: number(),
15209
+ /** Tracks whose key frame is gone — nothing to re-embed from. */
15210
+ missingKeyFrame: number(),
15211
+ /** Tracks with no usable detection box. */
15212
+ missingBbox: number(),
15213
+ /**
15214
+ * Tracks an executing node REFUSED rather than broke on — an unreadable key
15215
+ * frame, a step that threw. Separate from `failed` because the remedy is
15216
+ * different, and because a whole camera silently contributing zero vectors
15217
+ * is the shape of failure a rebuild must never hide.
15218
+ */
15219
+ notRunnable: number(),
15220
+ /**
15221
+ * The pass stopped because NO node could serve the pinned model.
15222
+ *
15223
+ * Distinct from `notRunnable` on purpose: that one says "this track was
15224
+ * refused", this one says "the cluster cannot do this work at all" — every
15225
+ * candidate node either lacks the `clip-embedding` step, lacks a build of the
15226
+ * pinned model for its engine format, or dropped out. The remedy is a model /
15227
+ * engine change, not a per-camera one. Non-zero here always comes with
15228
+ * `complete: false`.
15229
+ */
15230
+ noCapableNode: number(),
15231
+ failed: number(),
15232
+ /** Set once a pass ends: true only when EVERYTHING was covered. */
15233
+ complete: boolean().nullable(),
15234
+ startedAtMs: number().nullable(),
15235
+ finishedAtMs: number().nullable(),
15236
+ /** Present when the pass ended by throwing. */
15237
+ error: string().nullable()
15238
+ });
14730
15239
  DeviceType.Camera, method(object({ deviceId: number() }), array(TrackSchema).readonly()), method(object({
14731
15240
  deviceId: number(),
14732
15241
  trackId: string()
@@ -14790,7 +15299,12 @@ DeviceType.Camera, method(object({ deviceId: number() }), array(TrackSchema).rea
14790
15299
  }), {
14791
15300
  kind: "mutation",
14792
15301
  auth: "admin"
14793
- }), method(object({}), EventStoreFootprintSchema, {
15302
+ }), method(object({
15303
+ /** Log/audit scope only — the trackId is globally unique on its own. */
15304
+ deviceId: number(),
15305
+ trackId: string(),
15306
+ flags: TrackFlagsPatchSchema
15307
+ }), TrackFlagsSchema, { kind: "mutation" }), method(object({}), EventStoreFootprintSchema, {
14794
15308
  kind: "query",
14795
15309
  auth: "admin"
14796
15310
  }), method(object({
@@ -14820,7 +15334,13 @@ DeviceType.Camera, method(object({ deviceId: number() }), array(TrackSchema).rea
14820
15334
  }), array(MediaFileSchema).readonly()), method(object({
14821
15335
  trackId: string(),
14822
15336
  kinds: array(MediaFileKindEnum).optional()
14823
- }), array(MediaFileSchema).readonly()), method(object({ trackId: string() }), array(MediaFileInfoSchema).readonly()), method(SearchObjectEventsInput, array(ScoredObjectEventSchema).readonly()), object({
15337
+ }), array(MediaFileSchema).readonly()), method(object({ trackId: string() }), array(MediaFileInfoSchema).readonly()), method(SearchObjectEventsInput, array(ScoredObjectEventSchema).readonly()), method(object({}), WipeObjectEmbeddingsResultSchema, {
15338
+ kind: "mutation",
15339
+ auth: "admin"
15340
+ }), method(RebuildObjectEmbeddingsInput, RebuildObjectEmbeddingsResultSchema, {
15341
+ kind: "mutation",
15342
+ auth: "admin"
15343
+ }), method(object({}), RebuildStatusSchema), object({
14824
15344
  deviceId: number(),
14825
15345
  timestamp: number(),
14826
15346
  frameWidth: number(),
@@ -15394,6 +15914,53 @@ var DetailResultSchema = object({
15394
15914
  nativeFaceShortSidePx: number().optional()
15395
15915
  });
15396
15916
  /**
15917
+ * Why an executing node REFUSED a stateless step run (`runStatelessStep`).
15918
+ *
15919
+ * A refusal is a first-class answer, not an error, because the caller's next
15920
+ * move depends on WHICH one it is — and because "the pass produced nothing"
15921
+ * must never be reachable without a named, counted cause. The two tiers:
15922
+ *
15923
+ * - **node-level** (`unknown-step`, `model-not-servable`) — this node can
15924
+ * never serve this (step, model) pair. The caller drops it from its rotation
15925
+ * and retries the same work elsewhere; nothing about the work changes.
15926
+ * - **work-level** (`unreadable-frame`, `execution-failed`) — this node is
15927
+ * fine, this one request is not. Retrying it on another node would only
15928
+ * spread the same failure.
15929
+ */
15930
+ var StatelessStepRefusalSchema = _enum([
15931
+ "unknown-step",
15932
+ "model-not-servable",
15933
+ "unreadable-frame",
15934
+ "execution-failed"
15935
+ ]);
15936
+ /**
15937
+ * Answer to `runStatelessStep` — a discriminated union rather than a nullable
15938
+ * result, because `null` is exactly what made the camera-bound detail path
15939
+ * unable to tell "refused" from "never asked".
15940
+ */
15941
+ var RunStatelessStepResultSchema = discriminatedUnion("kind", [object({
15942
+ kind: literal("ran"),
15943
+ /** The node that actually executed it — the pin, echoed back for the log. */
15944
+ nodeId: string(),
15945
+ /**
15946
+ * The model the step ran with.
15947
+ *
15948
+ * The node verified this exact id has a build for the format it dispatched
15949
+ * on BEFORE running, so the executor's format resolution returns it
15950
+ * unchanged. A caller that pinned a model must compare this field and
15951
+ * treat a mismatch as a refusal — the whole point of the pin is that a
15952
+ * pass writes one feature space.
15953
+ */
15954
+ modelId: string(),
15955
+ details: array(DetailResultSchema)
15956
+ }), object({
15957
+ kind: literal("refused"),
15958
+ nodeId: string(),
15959
+ reason: StatelessStepRefusalSchema,
15960
+ /** Human-readable specifics — the format tried, the formats shipped, etc. */
15961
+ detail: string()
15962
+ })]);
15963
+ /**
15397
15964
  * Per-camera tunable ranges + defaults. Single source of truth used
15398
15965
  * by both the Zod data schema (validation + default fallback) and
15399
15966
  * the device settings UI (slider min/max/step). Touch one place and
@@ -15729,10 +16296,46 @@ method(RunnerCameraConfigSchema, object({ success: literal(true) }), { kind: "mu
15729
16296
  }), NativeCropResultSchema.nullable()), method(object({
15730
16297
  deviceId: number(),
15731
16298
  frameHandle: FrameHandleSchema.optional(),
16299
+ /**
16300
+ * FULL FRAME (base64 JPEG). The runner derives the crop rectangle from
16301
+ * `parent.bbox` with the cluster crop convention and cuts it itself —
16302
+ * do NOT pre-crop for this field, that is what `cropJpeg` is.
16303
+ */
16304
+ frameJpeg: string().optional(),
16305
+ /**
16306
+ * PRE-CUT tile (base64 JPEG), used verbatim — NO padding is applied.
16307
+ * The fallback when the lease/session backing the frame is gone and the
16308
+ * caller already holds a crop.
16309
+ */
15732
16310
  cropJpeg: string().optional(),
15733
16311
  parent: DetailParentSchema,
15734
16312
  steps: array(string()).optional()
15735
- }), object({ details: array(DetailResultSchema) }).nullable(), { kind: "mutation" });
16313
+ }), object({ details: array(DetailResultSchema) }).nullable(), { kind: "mutation" }), method(object({
16314
+ /** Catalog step id, e.g. `clip-embedding`. */
16315
+ stepId: string(),
16316
+ /**
16317
+ * REQUIRED model pin. The node runs this exact model or refuses with
16318
+ * `model-not-servable` — it never substitutes a format default, because
16319
+ * a fleet pass that round-robins across nodes would then fill one index
16320
+ * from several encoders.
16321
+ */
16322
+ modelId: string(),
16323
+ /** FULL FRAME, base64 JPEG. The runner cuts — do NOT pre-crop. */
16324
+ frameJpeg: string(),
16325
+ /**
16326
+ * The subject box, NORMALISED [0,1] against `frameJpeg`. Normalised on
16327
+ * purpose: the caller stores boxes against a downscaled analysis frame
16328
+ * while the stored key frame is native-resolution, and the only side
16329
+ * that reliably knows the image's pixel dimensions is the side that
16330
+ * decodes it. Denormalising here removes a second reader of the
16331
+ * dimensions and the class of mismatch that comes with it.
16332
+ */
16333
+ bbox: NativeCropBboxSchema,
16334
+ /** Parent class of the subject (`person`, `vehicle`, …) — carried into the result. */
16335
+ className: string(),
16336
+ /** Camera the pixels came from. Diagnostics + log tags ONLY — never routing. */
16337
+ sourceDeviceId: number()
16338
+ }), RunStatelessStepResultSchema, { kind: "mutation" });
15736
16339
  var CameraPipelineConfigSchema = object({
15737
16340
  engine: PipelineEngineChoiceSchema.optional(),
15738
16341
  steps: array(PipelineStepInputSchema).readonly(),
@@ -16030,6 +16633,20 @@ var CameraStatusSchema = object({
16030
16633
  detection: CameraDetectionStatusSchema.nullable(),
16031
16634
  audio: CameraAudioStatusSchema.nullable(),
16032
16635
  recording: CameraRecordingStatusSchema.nullable(),
16636
+ /**
16637
+ * Per-camera function switches an OPERATOR has turned off
16638
+ * ([D61](../../../../docs/decisions/adr-0067.md)).
16639
+ *
16640
+ * This is the difference between DISABLED and BROKEN. A camera whose
16641
+ * `detection` block reports zero fps and whose `switchedOff` contains
16642
+ * `'object-detection'` was switched off by a person; the same camera with an
16643
+ * empty list is failing. Every status surface must render the two
16644
+ * differently — a quiet camera that looks identical to a dead one is the
16645
+ * silence-reads-as-never-happened trap this repo keeps paying for.
16646
+ *
16647
+ * Empty when nothing is off. Never contains a switch no provider offers.
16648
+ */
16649
+ switchedOff: array(CameraSwitchIdSchema).readonly(),
16033
16650
  /** Unix timestamp (ms) when this snapshot was composed server-side. */
16034
16651
  fetchedAt: number()
16035
16652
  });
@@ -16198,7 +16815,14 @@ method(object({
16198
16815
  }), method(object({
16199
16816
  deviceId: number(),
16200
16817
  agentNodeId: string().optional()
16201
- }), CameraPipelineConfigSchema), method(object({ deviceId: number() }), CameraStatusSchema), method(object({ deviceIds: array(number()).optional() }), array(CameraStatusSchema).readonly()), method(_void(), array(PipelineTemplateSchema).readonly()), method(object({
16818
+ }), CameraPipelineConfigSchema), method(object({ deviceId: number() }), CameraSwitchGroupSchema), method(object({
16819
+ deviceId: number(),
16820
+ switchId: CameraSwitchIdSchema,
16821
+ enabled: boolean()
16822
+ }), CameraSwitchGroupSchema, {
16823
+ kind: "mutation",
16824
+ auth: "admin"
16825
+ }), method(object({ deviceId: number() }), CameraStatusSchema), method(object({ deviceIds: array(number()).optional() }), array(CameraStatusSchema).readonly()), method(_void(), array(PipelineTemplateSchema).readonly()), method(object({
16202
16826
  name: string(),
16203
16827
  description: string().optional(),
16204
16828
  config: CameraPipelineConfigSchema
@@ -17054,6 +17678,24 @@ var VectorDeleteByFilterInputSchema = object({
17054
17678
  filter: VectorFilterSchema
17055
17679
  });
17056
17680
  var VectorDeleteResultSchema = object({ deleted: number() });
17681
+ var VectorGetInputSchema = object({
17682
+ index: string(),
17683
+ ids: array(string())
17684
+ });
17685
+ /**
17686
+ * Metadata for the requested ids, WITHOUT their vectors.
17687
+ *
17688
+ * The only caller is a best-of gate that compares a candidate's confidence
17689
+ * against the stored one, and shipping 512 floats back to answer "is 0.91 >
17690
+ * 0.87" would undo the point of the compact encoding. Ids with no row are
17691
+ * simply absent — a caller distinguishing "not stored" from "stored" reads the
17692
+ * length, and a null placeholder would invite a `?? 0` that treats a missing
17693
+ * row as confidence zero.
17694
+ */
17695
+ var VectorGetResultSchema = object({ items: array(object({
17696
+ id: string(),
17697
+ metadata: VectorMetadataSchema
17698
+ })) });
17057
17699
  var VectorStatsInputSchema = object({ index: string() });
17058
17700
  var VectorStatsResultSchema = object({
17059
17701
  /** Provider id, so an operator can tell brute force from an ANN index. */
@@ -17066,7 +17708,7 @@ var VectorStatsResultSchema = object({
17066
17708
  /** False when the backend ranks approximately. */
17067
17709
  exact: boolean()
17068
17710
  });
17069
- method(VectorDeclareIndexInputSchema, _void(), { kind: "mutation" }), method(VectorUpsertInputSchema, VectorUpsertResultSchema, { kind: "mutation" }), method(VectorQueryInputSchema, VectorQueryResultSchema), method(VectorDeleteInputSchema, VectorDeleteResultSchema, { kind: "mutation" }), method(VectorDeleteByFilterInputSchema, VectorDeleteResultSchema, { kind: "mutation" }), method(VectorStatsInputSchema, VectorStatsResultSchema);
17711
+ method(VectorDeclareIndexInputSchema, _void(), { kind: "mutation" }), method(VectorUpsertInputSchema, VectorUpsertResultSchema, { kind: "mutation" }), method(VectorQueryInputSchema, VectorQueryResultSchema), method(VectorGetInputSchema, VectorGetResultSchema), method(VectorDeleteInputSchema, VectorDeleteResultSchema, { kind: "mutation" }), method(VectorDeleteByFilterInputSchema, VectorDeleteResultSchema, { kind: "mutation" }), method(VectorStatsInputSchema, VectorStatsResultSchema);
17070
17712
  /**
17071
17713
  * `videoclips` — the unified, navigable-clip surface for a camera.
17072
17714
  *
@@ -25137,6 +25779,12 @@ Object.freeze({
25137
25779
  addonId: null,
25138
25780
  access: "view"
25139
25781
  },
25782
+ "notificationRules.listDeviceMutes": {
25783
+ capName: "notification-rules",
25784
+ capScope: "system",
25785
+ addonId: null,
25786
+ access: "view"
25787
+ },
25140
25788
  "notificationRules.listRules": {
25141
25789
  capName: "notification-rules",
25142
25790
  capScope: "system",
@@ -25155,6 +25803,12 @@ Object.freeze({
25155
25803
  addonId: null,
25156
25804
  access: "create"
25157
25805
  },
25806
+ "notificationRules.setDeviceMuted": {
25807
+ capName: "notification-rules",
25808
+ capScope: "system",
25809
+ addonId: null,
25810
+ access: "create"
25811
+ },
25158
25812
  "notificationRules.setRuleEnabled": {
25159
25813
  capName: "notification-rules",
25160
25814
  capScope: "system",
@@ -25329,6 +25983,12 @@ Object.freeze({
25329
25983
  addonId: null,
25330
25984
  access: "view"
25331
25985
  },
25986
+ "pipelineAnalytics.getObjectEmbeddingRebuildStatus": {
25987
+ capName: "pipeline-analytics",
25988
+ capScope: "device",
25989
+ addonId: null,
25990
+ access: "view"
25991
+ },
25332
25992
  "pipelineAnalytics.getObjectEvents": {
25333
25993
  capName: "pipeline-analytics",
25334
25994
  capScope: "device",
@@ -25407,6 +26067,12 @@ Object.freeze({
25407
26067
  addonId: null,
25408
26068
  access: "create"
25409
26069
  },
26070
+ "pipelineAnalytics.rebuildObjectEmbeddings": {
26071
+ capName: "pipeline-analytics",
26072
+ capScope: "device",
26073
+ addonId: null,
26074
+ access: "create"
26075
+ },
25410
26076
  "pipelineAnalytics.relocateMedia": {
25411
26077
  capName: "pipeline-analytics",
25412
26078
  capScope: "device",
@@ -25419,12 +26085,24 @@ Object.freeze({
25419
26085
  addonId: null,
25420
26086
  access: "view"
25421
26087
  },
26088
+ "pipelineAnalytics.setTrackFlags": {
26089
+ capName: "pipeline-analytics",
26090
+ capScope: "device",
26091
+ addonId: null,
26092
+ access: "create"
26093
+ },
25422
26094
  "pipelineAnalytics.wipeAllAnalytics": {
25423
26095
  capName: "pipeline-analytics",
25424
26096
  capScope: "device",
25425
26097
  addonId: null,
25426
26098
  access: "delete"
25427
26099
  },
26100
+ "pipelineAnalytics.wipeObjectEmbeddings": {
26101
+ capName: "pipeline-analytics",
26102
+ capScope: "device",
26103
+ addonId: null,
26104
+ access: "delete"
26105
+ },
25428
26106
  "pipelineExecutor.cacheFrameInPool": {
25429
26107
  capName: "pipeline-executor",
25430
26108
  capScope: "system",
@@ -25719,6 +26397,12 @@ Object.freeze({
25719
26397
  addonId: null,
25720
26398
  access: "view"
25721
26399
  },
26400
+ "pipelineOrchestrator.getCameraSwitches": {
26401
+ capName: "pipeline-orchestrator",
26402
+ capScope: "system",
26403
+ addonId: null,
26404
+ access: "view"
26405
+ },
25722
26406
  "pipelineOrchestrator.getCapabilityBindings": {
25723
26407
  capName: "pipeline-orchestrator",
25724
26408
  capScope: "system",
@@ -25851,6 +26535,12 @@ Object.freeze({
25851
26535
  addonId: null,
25852
26536
  access: "create"
25853
26537
  },
26538
+ "pipelineOrchestrator.setCameraSwitch": {
26539
+ capName: "pipeline-orchestrator",
26540
+ capScope: "system",
26541
+ addonId: null,
26542
+ access: "create"
26543
+ },
25854
26544
  "pipelineOrchestrator.setCapabilityBinding": {
25855
26545
  capName: "pipeline-orchestrator",
25856
26546
  capScope: "system",
@@ -25941,6 +26631,12 @@ Object.freeze({
25941
26631
  addonId: null,
25942
26632
  access: "create"
25943
26633
  },
26634
+ "pipelineRunner.runStatelessStep": {
26635
+ capName: "pipeline-runner",
26636
+ capScope: "system",
26637
+ addonId: null,
26638
+ access: "create"
26639
+ },
25944
26640
  "plateGallery.assignPlate": {
25945
26641
  capName: "plate-gallery",
25946
26642
  capScope: "system",
@@ -26757,6 +27453,12 @@ Object.freeze({
26757
27453
  addonId: null,
26758
27454
  access: "create"
26759
27455
  },
27456
+ "streamBroker.acquireEgressTranscode": {
27457
+ capName: "stream-broker",
27458
+ capScope: "system",
27459
+ addonId: null,
27460
+ access: "create"
27461
+ },
26760
27462
  "streamBroker.assignProfile": {
26761
27463
  capName: "stream-broker",
26762
27464
  capScope: "system",
@@ -26865,6 +27567,12 @@ Object.freeze({
26865
27567
  addonId: null,
26866
27568
  access: "create"
26867
27569
  },
27570
+ "streamBroker.releaseEgressTranscode": {
27571
+ capName: "stream-broker",
27572
+ capScope: "system",
27573
+ addonId: null,
27574
+ access: "create"
27575
+ },
26868
27576
  "streamBroker.releaseStreamWithCodec": {
26869
27577
  capName: "stream-broker",
26870
27578
  capScope: "system",
@@ -27345,6 +28053,12 @@ Object.freeze({
27345
28053
  addonId: null,
27346
28054
  access: "delete"
27347
28055
  },
28056
+ "vectorStore.getByIds": {
28057
+ capName: "vector-store",
28058
+ capScope: "system",
28059
+ addonId: null,
28060
+ access: "view"
28061
+ },
27348
28062
  "vectorStore.query": {
27349
28063
  capName: "vector-store",
27350
28064
  capScope: "system",
@@ -27619,37 +28333,30 @@ TimelapseRuleInputSchema.extend({
27619
28333
  createdAt: number(),
27620
28334
  updatedAt: number()
27621
28335
  });
27622
- /**
27623
- * Deterministic SHA-256 hash of an arbitrary serialisable value. The
27624
- * canonical form sorts object keys alphabetically at every depth so two
27625
- * structurally-equal inputs with different key insertion orders produce
27626
- * the same hash. Returns a 64-char lowercase hex digest.
27627
- *
27628
- * Used by export adapters (Alexa, HAP) to short-circuit re-discovery /
27629
- * accessory-rebuild work when the upstream shape is byte-identical to
27630
- * the last applied state — preventing user-visible "re-discovery"
27631
- * notifications on every addon-runner respawn. Each respawn re-fires
27632
- * `DeviceBindingsChanged` for every cap registration, which without
27633
- * this guard would propagate redundant pushes.
27634
- *
27635
- * Note: this is a SYMPTOMATIC fix layered on top of the binding-change
27636
- * subscription. The proper fix is a single "device ready" lifecycle
27637
- * barrier so exports react only when the full cap set has landed
27638
- * tracked separately for post-HA-integration work.
27639
- */
27640
- function canonicalHash(value) {
27641
- const canonical = JSON.stringify(value, replaceWithSortedKeys);
27642
- return (0, node_crypto.createHash)("sha256").update(canonical ?? "").digest("hex");
27643
- }
27644
- function replaceWithSortedKeys(_key, value) {
27645
- if (value && typeof value === "object" && !Array.isArray(value)) {
27646
- const obj = value;
27647
- const out = {};
27648
- for (const k of Object.keys(obj).toSorted()) out[k] = obj[k];
27649
- return out;
27650
- }
27651
- return value;
27652
- }
28336
+ object({
28337
+ /**
28338
+ * Fraction of the box's own size added on EACH side before cutting.
28339
+ *
28340
+ * CLIP is trained on natural images WITH surroundings; a pixel-tight crop
28341
+ * removes exactly the context it is strongest on (a dog cut to its outline
28342
+ * is a dark blob). The right value is an empirical question, which is why it
28343
+ * is a setting: 0 / 0.15 / 0.2 / 0.5 are the interesting points.
28344
+ */
28345
+ paddingRatio: number().min(0).max(4),
28346
+ /**
28347
+ * Square the window (in PIXELS) before cutting.
28348
+ *
28349
+ * CLIP's input is square, so a tall bbox resized straight to NxN is squashed
28350
+ * a standing person becomes a shape the model never saw. Squaring costs
28351
+ * extra background, which is context the model wants anyway. Off by default
28352
+ * because the live path has never squared and the stored index reflects that.
28353
+ */
28354
+ square: boolean()
28355
+ });
28356
+ ({
28357
+ paddingRatio: .15,
28358
+ square: false
28359
+ }).paddingRatio;
27653
28360
  /**
27654
28361
  * Compute the stable 64-char lowercase-hex fingerprint of a device's
27655
28362
  * export-relevant shape. Two structurally-equal shapes (any feature order,
@@ -27821,6 +28528,110 @@ function firstExposedAccessorySetupUri(exposed, logger) {
27821
28528
  }
27822
28529
  }
27823
28530
  }
28531
+ /**
28532
+ * hap-nodejs' `checkName` regex, verbatim.
28533
+ *
28534
+ * Duplicated rather than imported because it is `@private Private API` in that
28535
+ * package and not exported. Duplicating a private regex is a liability, so the
28536
+ * guard against drift is behavioural, not textual: `service-naming.spec.ts`
28537
+ * builds real services and asserts hap-nodejs emits ZERO characteristic
28538
+ * warnings — if this expression ever diverges from theirs, that test fails.
28539
+ */
28540
+ var HAP_NAME_PATTERN = /^[\p{L}\p{N}][\p{L}\p{N}\p{Zs}’'&!._:;()/,-]*[\p{L}\p{N}]$/u;
28541
+ /** Characters HAP tolerates INSIDE a name. Anything else becomes a space. */
28542
+ var HAP_NAME_INNER = /[^\p{L}\p{N}\p{Zs}’'&!._:;()/,-]/gu;
28543
+ /** Would hap-nodejs accept this as a `Name` characteristic value? */
28544
+ function isHapServiceName(value) {
28545
+ return HAP_NAME_PATTERN.test(value);
28546
+ }
28547
+ /**
28548
+ * Build one HAP-valid service name from device-derived parts.
28549
+ *
28550
+ * Empty and absent parts are dropped rather than joined, so a missing role
28551
+ * never produces a double space. `fallback` is used ONLY when nothing
28552
+ * device-derived survives sanitisation — it is the last resort, not the
28553
+ * default, because a name that says nothing about the device is the defect
28554
+ * this module exists to end.
28555
+ */
28556
+ function hapServiceName(parts, fallback) {
28557
+ const trimmed = trimToHapName(parts.map((part) => typeof part === "string" ? part : "").map((part) => part.replace(HAP_NAME_INNER, " ")).join(" ").replace(/\s+/gu, " ").trim());
28558
+ if (trimmed !== null) return trimmed;
28559
+ return trimToHapName(fallback.replace(HAP_NAME_INNER, " ").replace(/\s+/gu, " ").trim()) ?? "";
28560
+ }
28561
+ /**
28562
+ * The privacy-mask switch.
28563
+ *
28564
+ * The camera's own name carries the meaning; "Privacy" only says which of the
28565
+ * camera's switches this is. It used to be the WHOLE name, which is why the
28566
+ * operator saw a switch that named neither its camera nor, with two cameras
28567
+ * exposed, which camera it belonged to.
28568
+ */
28569
+ function privacyServiceName(deviceName) {
28570
+ return hapServiceName([deviceName, PRIVACY_SUFFIX], PRIVACY_SUFFIX);
28571
+ }
28572
+ /**
28573
+ * Deliberately not localised, and deliberately not a translation table.
28574
+ *
28575
+ * The device half of the name is the operator's own text and arrives in the
28576
+ * operator's language. This half names a camstack capability (`privacy-mask`)
28577
+ * and there is no locale in an addon's context to resolve it against; the word
28578
+ * is also identical in the operator's language. A translation layer for one
28579
+ * word would be the kind of leftover that reads as verification.
28580
+ */
28581
+ var PRIVACY_SUFFIX = "Privacy";
28582
+ /**
28583
+ * An accessory child (siren, floodlight, spotlight) rendered as a service on
28584
+ * the parent camera.
28585
+ *
28586
+ * The child's OWN stored name wins. It is the string the operator typed, in
28587
+ * the operator's language, and the previous rule threw it away: `role` was
28588
+ * consulted first and title-cased, so every siren on the fleet published as
28589
+ * the English word "Siren" no matter what the operator had called it.
28590
+ *
28591
+ * The parent name is prefixed only when the child's name does not already
28592
+ * carry it — providers name children both ways ("Sirena" and "Videocamera
28593
+ * cucina Sirena") and the result must be one form, not two.
28594
+ */
28595
+ function childServiceName(parentName, child) {
28596
+ const own = child.name.trim();
28597
+ if (own.length > 0) return mentions(own, parentName) ? hapServiceName([own], parentName) : hapServiceName([parentName, own], own);
28598
+ return hapServiceName([parentName, typeof child.role === "string" ? titleCase(child.role) : ""], parentName);
28599
+ }
28600
+ /**
28601
+ * A PTZ action switch.
28602
+ *
28603
+ * The action labels themselves stay in `ptz-labels.ts` — they name a HomeKit
28604
+ * control, not a device — but the service is still qualified by the camera, so
28605
+ * a home with two PTZ cameras does not publish two switches called "Preset
28606
+ * ingresso".
28607
+ */
28608
+ function ptzServiceName(deviceName, actionLabel) {
28609
+ return hapServiceName([deviceName, actionLabel], actionLabel);
28610
+ }
28611
+ /** Does `name` already contain `parentName` as a whole word? */
28612
+ function mentions(name, parentName) {
28613
+ const needle = parentName.trim().toLowerCase();
28614
+ if (needle.length === 0) return true;
28615
+ return name.toLowerCase().includes(needle);
28616
+ }
28617
+ /**
28618
+ * Truncate to the HAP ceiling and shave any leading/trailing character the
28619
+ * pattern forbids. Returns `null` when nothing usable is left — the caller
28620
+ * decides what to do with that, because "fall back" and "drop the part" are
28621
+ * different answers.
28622
+ */
28623
+ function trimToHapName(value) {
28624
+ let out = value.length > 64 ? value.slice(0, 64) : value;
28625
+ while (out.length > 0 && !isAlphanumeric(out[out.length - 1])) out = out.slice(0, -1);
28626
+ while (out.length > 0 && !isAlphanumeric(out[0])) out = out.slice(1);
28627
+ return isHapServiceName(out) ? out : null;
28628
+ }
28629
+ function isAlphanumeric(ch) {
28630
+ return ch !== void 0 && /[\p{L}\p{N}]/u.test(ch);
28631
+ }
28632
+ function titleCase(raw) {
28633
+ return raw.split(/[-_\s]+/u).filter((part) => part.length > 0).map((part) => part.charAt(0).toUpperCase() + part.slice(1)).join(" ");
28634
+ }
27824
28635
  //#endregion
27825
28636
  //#region src/mappers/builders/battery.ts
27826
28637
  /**
@@ -27845,7 +28656,7 @@ var LOW_BATTERY_THRESHOLD_PCT = 20;
27845
28656
  async function buildBattery(bctx) {
27846
28657
  const { ctx, accessory, proxy, numericDeviceId, displayName } = bctx;
27847
28658
  const log = ctx.logger.withTags({ deviceId: numericDeviceId });
27848
- const service = accessory.addService(_homebridge_hap_nodejs.Service.Battery, displayName);
28659
+ const service = accessory.addService(_homebridge_hap_nodejs.Service.Battery, hapServiceName([displayName], `Camera ${numericDeviceId}`));
27849
28660
  try {
27850
28661
  const status = await proxy.battery?.getStatus({});
27851
28662
  if (status) applyToService(service, status);
@@ -38175,13 +38986,1035 @@ var require_src = /* @__PURE__ */ __commonJSMin(((exports) => {
38175
38986
  __exportStar(require_util(), exports);
38176
38987
  }));
38177
38988
  //#endregion
38178
- //#region src/mappers/builders/camera-streams.ts
38989
+ //#region src/mappers/builders/rtcp-gate.ts
38179
38990
  var import_src = require_src();
38991
+ /** `packet@42ms` / `timeout@1000ms` — one compact log field. */
38992
+ function formatRtcpGate(resolution) {
38993
+ if (resolution === null) return "pending";
38994
+ return `${resolution.reason}@${resolution.waitedMs}ms`;
38995
+ }
38996
+ /**
38997
+ * Resolve as soon as ANY packet arrives on `socket`, or after `timeoutMs`.
38998
+ * One-shot: the listener is detached on either path.
38999
+ */
39000
+ function makeRtcpGate(socket, timeoutMs) {
39001
+ const armedAt = Date.now();
39002
+ return new Promise((resolve) => {
39003
+ const onMessage = () => {
39004
+ socket.removeListener("message", onMessage);
39005
+ clearTimeout(timer);
39006
+ resolve({
39007
+ reason: "packet",
39008
+ waitedMs: Date.now() - armedAt
39009
+ });
39010
+ };
39011
+ const timer = setTimeout(() => {
39012
+ socket.removeListener("message", onMessage);
39013
+ resolve({
39014
+ reason: "timeout",
39015
+ waitedMs: Date.now() - armedAt
39016
+ });
39017
+ }, timeoutMs);
39018
+ socket.on("message", onMessage);
39019
+ });
39020
+ }
39021
+ /** V/P/RC byte + PT byte + 16-bit length. */
39022
+ var RTCP_HEADER_BYTES = 4;
39023
+ /** SSRC of the packet sender, first field of both SR and RR bodies. */
39024
+ var RTCP_SENDER_SSRC_BYTES = 4;
39025
+ /** NTP + RTP timestamp, packet count, octet count — present only on an SR. */
39026
+ var RTCP_SENDER_INFO_BYTES = 20;
39027
+ /** One report block: SSRC_n, loss, highest seq, jitter, LSR, DLSR. */
39028
+ var RTCP_REPORT_BLOCK_BYTES = 24;
39029
+ /** `fraction lost` is an 8-bit fixed-point fraction of 256. */
39030
+ var FRACTION_LOST_DENOMINATOR = 256;
39031
+ /** `delay since last SR` counts 1/65536 of a second. */
39032
+ var DLSR_UNITS_PER_SECOND = 65536;
39033
+ /** `cumulative number of packets lost` is a SIGNED 24-bit field. */
39034
+ var SIGNED_24_SIGN_BIT = 8388608;
39035
+ var SIGNED_24_MODULUS = 16777216;
39036
+ function readSigned24(packet, at) {
39037
+ const raw = packet[at] << 16 | packet[at + 1] << 8 | packet[at + 2];
39038
+ return raw >= SIGNED_24_SIGN_BIT ? raw - SIGNED_24_MODULUS : raw;
39039
+ }
39040
+ function readReportBlock(packet, at) {
39041
+ const fractionLostRaw = packet[at + 4];
39042
+ const dlsr = packet.readUInt32BE(at + 20);
39043
+ return {
39044
+ aboutSsrc: packet.readUInt32BE(at),
39045
+ fractionLostRaw,
39046
+ fractionLostPct: Math.round(fractionLostRaw / FRACTION_LOST_DENOMINATOR * 1e3) / 10,
39047
+ cumulativePacketsLost: readSigned24(packet, at + 5),
39048
+ extendedHighestSequence: packet.readUInt32BE(at + 8),
39049
+ jitter: packet.readUInt32BE(at + 12),
39050
+ lastSrTimestamp: packet.readUInt32BE(at + 16),
39051
+ delaySinceLastSrMs: Math.round(dlsr / DLSR_UNITS_PER_SECOND * 1e3)
39052
+ };
39053
+ }
39054
+ /**
39055
+ * Walk a (possibly compound) RTCP datagram and extract every report block.
39056
+ *
39057
+ * An RR is routinely bundled behind an SDES, and a controller with nothing to
39058
+ * report yet sends an RR with a reception-report count of zero. Both are
39059
+ * normal; neither is a failure. What IS a failure is a packet whose declared
39060
+ * length runs past the buffer or whose report count exceeds its own body —
39061
+ * those get named so a decrypt fault cannot masquerade as silence.
39062
+ */
39063
+ function parseCompoundRtcp(packet) {
39064
+ if (packet.length === 0) return {
39065
+ ok: false,
39066
+ failure: "empty",
39067
+ atOffset: 0
39068
+ };
39069
+ const packetTypes = [];
39070
+ const reports = [];
39071
+ let offset = 0;
39072
+ while (offset < packet.length) {
39073
+ if (packet.length - offset < RTCP_HEADER_BYTES) return {
39074
+ ok: false,
39075
+ failure: "short-header",
39076
+ atOffset: offset
39077
+ };
39078
+ const firstByte = packet[offset];
39079
+ if ((firstByte >> 6 & 3) !== 2) return {
39080
+ ok: false,
39081
+ failure: "bad-version",
39082
+ atOffset: offset
39083
+ };
39084
+ const reportCount = firstByte & 31;
39085
+ const packetType = packet[offset + 1];
39086
+ const totalBytes = (packet.readUInt16BE(offset + 2) + 1) * 4;
39087
+ if (offset + totalBytes > packet.length) return {
39088
+ ok: false,
39089
+ failure: "length-overrun",
39090
+ atOffset: offset
39091
+ };
39092
+ packetTypes.push(packetType);
39093
+ if (packetType === 201 || packetType === 200) {
39094
+ const senderSsrcAt = offset + RTCP_HEADER_BYTES;
39095
+ const blocksAt = senderSsrcAt + RTCP_SENDER_SSRC_BYTES + (packetType === 200 ? RTCP_SENDER_INFO_BYTES : 0);
39096
+ if (blocksAt + reportCount * RTCP_REPORT_BLOCK_BYTES > offset + totalBytes) return {
39097
+ ok: false,
39098
+ failure: "truncated-body",
39099
+ atOffset: offset
39100
+ };
39101
+ const blocks = [];
39102
+ for (let index = 0; index < reportCount; index += 1) blocks.push(readReportBlock(packet, blocksAt + index * RTCP_REPORT_BLOCK_BYTES));
39103
+ reports.push({
39104
+ packetType,
39105
+ reporterSsrc: packet.readUInt32BE(senderSsrcAt),
39106
+ blocks
39107
+ });
39108
+ }
39109
+ offset += totalBytes;
39110
+ }
39111
+ return {
39112
+ ok: true,
39113
+ packetTypes,
39114
+ reports
39115
+ };
39116
+ }
39117
+ function emptyReceiverReportTally() {
39118
+ return {
39119
+ reportsParsed: 0,
39120
+ blocksParsed: 0,
39121
+ unreadable: 0,
39122
+ lastFractionLostPct: null,
39123
+ worstFractionLostPct: null,
39124
+ lastCumulativePacketsLost: null,
39125
+ maxCumulativePacketsLost: null,
39126
+ lastJitter: null,
39127
+ maxJitter: null,
39128
+ lastExtendedHighestSequence: null
39129
+ };
39130
+ }
39131
+ function maxOrValue(previous, next) {
39132
+ return previous === null ? next : Math.max(previous, next);
39133
+ }
39134
+ /** Fold one report block into the tally. Returns a new tally. */
39135
+ function applyReceiverReportBlock(tally, block) {
39136
+ return {
39137
+ ...tally,
39138
+ blocksParsed: tally.blocksParsed + 1,
39139
+ lastFractionLostPct: block.fractionLostPct,
39140
+ worstFractionLostPct: maxOrValue(tally.worstFractionLostPct, block.fractionLostPct),
39141
+ lastCumulativePacketsLost: block.cumulativePacketsLost,
39142
+ maxCumulativePacketsLost: maxOrValue(tally.maxCumulativePacketsLost, block.cumulativePacketsLost),
39143
+ lastJitter: block.jitter,
39144
+ maxJitter: maxOrValue(tally.maxJitter, block.jitter),
39145
+ lastExtendedHighestSequence: block.extendedHighestSequence
39146
+ };
39147
+ }
39148
+ /** Book an RTCP datagram we could not read. Counted, never discarded silently. */
39149
+ function recordUnreadableRtcp(tally) {
39150
+ return {
39151
+ ...tally,
39152
+ unreadable: tally.unreadable + 1
39153
+ };
39154
+ }
39155
+ /**
39156
+ * Parse one decrypted RTCP datagram and fold it into a leg's tally.
39157
+ *
39158
+ * This is the seam the delegate calls: it owns SRTCP decryption and logging,
39159
+ * this owns everything that can be asserted from bytes alone. A malformed
39160
+ * packet returns a failure and an incremented `unreadable` — it never throws,
39161
+ * because this runs on a UDP `message` handler where a throw would take the
39162
+ * session with it.
39163
+ */
39164
+ function ingestDecryptedRtcp(plaintext, tally) {
39165
+ const parsed = parseCompoundRtcp(plaintext);
39166
+ if (!parsed.ok) return {
39167
+ tally: recordUnreadableRtcp(tally),
39168
+ reports: [],
39169
+ failure: parsed.failure
39170
+ };
39171
+ let next = tally;
39172
+ for (const report of parsed.reports) {
39173
+ if (report.blocks.length === 0) continue;
39174
+ next = {
39175
+ ...next,
39176
+ reportsParsed: next.reportsParsed + 1
39177
+ };
39178
+ for (const block of report.blocks) next = applyReceiverReportBlock(next, block);
39179
+ }
39180
+ return {
39181
+ tally: next,
39182
+ reports: parsed.reports,
39183
+ failure: null
39184
+ };
39185
+ }
39186
+ //#endregion
39187
+ //#region src/mappers/builders/stream-bitrate.ts
39188
+ /**
39189
+ * Fraction of the negotiated ceiling we actually aim the encoder at.
39190
+ *
39191
+ * `max_bit_rate` is what the controller budgeted for the stream; what crosses
39192
+ * the wire is the encoded payload PLUS its packetisation. At `mtu = 1378` each
39193
+ * packet carries a 12-byte RTP header and a 10-byte SRTP auth tag, and the
39194
+ * datagram adds 8 (UDP) + 20 (IPv4) — 50 bytes on ~1378, i.e. **3.6 %**. The
39195
+ * remaining ~6 % is margin for a VBV overshoot inside the buffer window.
39196
+ * Reserving it is the difference between "at the ceiling" and "over it".
39197
+ */
39198
+ var BITRATE_HEADROOM = .9;
39199
+ /** Encoder slots a `stream-params` provider exposes, in the cap's own order. */
39200
+ var ENCODER_PROFILE_KEYS = [
39201
+ "main",
39202
+ "sub",
39203
+ "ext"
39204
+ ];
39205
+ /**
39206
+ * Resolve every profile slot's rate from the camera's configuration, with the
39207
+ * broker's flow reading kept alongside it as a lower bound.
39208
+ *
39209
+ * A slot with neither simply carries two nulls — absence is never rendered as
39210
+ * a number, because a wrong number here silently re-creates the overshoot this
39211
+ * module exists to end.
39212
+ */
39213
+ function resolveProfileBitrates(input) {
39214
+ const camStreamById = /* @__PURE__ */ new Map();
39215
+ for (const stream of input.camStreams) camStreamById.set(stream.camStreamId, stream);
39216
+ const measuredByProfile = /* @__PURE__ */ new Map();
39217
+ for (const choice of input.choices) {
39218
+ if (choice.target.kind !== "profile") continue;
39219
+ const plausible = plausibleMeasured(choice.bitrateKbps);
39220
+ if (plausible !== null) measuredByProfile.set(choice.target.profile, plausible);
39221
+ }
39222
+ const out = /* @__PURE__ */ new Map();
39223
+ for (const slot of input.slots) {
39224
+ const source = slot.sourceCamStreamId === null ? void 0 : camStreamById.get(slot.sourceCamStreamId);
39225
+ out.set(slot.profile, {
39226
+ profile: slot.profile,
39227
+ publishedKbps: publishedRateFor(slot, source, input.streamParams),
39228
+ measuredKbps: measuredByProfile.get(slot.profile) ?? null
39229
+ });
39230
+ }
39231
+ for (const [profile, measured] of measuredByProfile) {
39232
+ if (out.has(profile)) continue;
39233
+ out.set(profile, {
39234
+ profile,
39235
+ publishedKbps: null,
39236
+ measuredKbps: measured
39237
+ });
39238
+ }
39239
+ return out;
39240
+ }
39241
+ /**
39242
+ * The rate the encoder may actually use, or `null` when the controller
39243
+ * negotiated no usable ceiling.
39244
+ */
39245
+ function budgetForNegotiatedRate(negotiatedMaxBitrateKbps) {
39246
+ if (!Number.isFinite(negotiatedMaxBitrateKbps) || negotiatedMaxBitrateKbps <= 0) return null;
39247
+ const budget = Math.floor(negotiatedMaxBitrateKbps * BITRATE_HEADROOM);
39248
+ return budget > 0 ? budget : null;
39249
+ }
39250
+ /**
39251
+ * Does this slot fit?
39252
+ *
39253
+ * Only the CONFIGURED rate can answer yes. The measured rate is a lower bound,
39254
+ * so it is allowed to answer no — including against an optimistic publication.
39255
+ */
39256
+ function classifyBitrateFit(evidence, budgetKbps) {
39257
+ if (evidence === void 0) return "unknown";
39258
+ const { publishedKbps, measuredKbps } = evidence;
39259
+ if (measuredKbps !== null && measuredKbps > budgetKbps) return "over-budget";
39260
+ if (publishedKbps === null) return "unknown";
39261
+ return publishedKbps <= budgetKbps ? "fits" : "over-budget";
39262
+ }
39263
+ /**
39264
+ * Pick the stream to serve, and decide whether it can be passed through.
39265
+ *
39266
+ * The selection runs the SAME `pickPreferredRtspEntry` the advertisement is
39267
+ * derived from (D51) — only the candidate set narrows. When at least one
39268
+ * pass-through-capable slot fits the budget, the picker resolves the target
39269
+ * resolution among those and we copy. Otherwise the picker resolves among ALL
39270
+ * entries — so the transcode decodes the slot closest to the negotiated
39271
+ * resolution rather than the largest one on the camera — and we re-encode.
39272
+ *
39273
+ * A pinned `streamPreference` is never overridden by the budget: the pinned
39274
+ * slot is transcoded rather than swapped for a cheaper one.
39275
+ *
39276
+ * Returns `null` when nothing is publishable at all.
39277
+ */
39278
+ function selectStreamForBudget(input) {
39279
+ const budgetKbps = budgetForNegotiatedRate(input.negotiatedMaxBitrateKbps);
39280
+ const notes = fitNotes(input.entries, input.bitrates, budgetKbps);
39281
+ const fallback = pickPreferredRtspEntry(input.entries, input.pref, input.deviceId, { targetResolution: input.targetResolution });
39282
+ if (fallback === null) return null;
39283
+ const fallbackProfile = toCamProfile$1(fallback.profileId);
39284
+ if (budgetKbps === null) {
39285
+ const base = {
39286
+ picked: fallback,
39287
+ profile: fallbackProfile,
39288
+ budgetKbps: null,
39289
+ notes
39290
+ };
39291
+ return canPassThrough(fallback.codec) ? {
39292
+ kind: "copy",
39293
+ reason: "no-negotiated-budget",
39294
+ ...base
39295
+ } : {
39296
+ kind: "transcode",
39297
+ reason: "source-codec",
39298
+ ...base
39299
+ };
39300
+ }
39301
+ const affordable = (input.pref !== "auto" && fallbackProfile === input.pref ? input.entries.filter((entry) => entry.profile === fallbackProfile) : input.entries).filter((entry) => {
39302
+ if (!canPassThrough(entry.codec)) return false;
39303
+ return classifyBitrateFit(input.bitrates.get(entry.profile), budgetKbps) === "fits";
39304
+ });
39305
+ if (affordable.length > 0) {
39306
+ const picked = pickPreferredRtspEntry(affordable, input.pref, input.deviceId, { targetResolution: input.targetResolution });
39307
+ if (picked !== null) return {
39308
+ kind: "copy",
39309
+ reason: "source-fits-budget",
39310
+ picked,
39311
+ profile: toCamProfile$1(picked.profileId),
39312
+ budgetKbps,
39313
+ notes
39314
+ };
39315
+ }
39316
+ if (canPassThrough(fallback.codec)) return {
39317
+ kind: "copy",
39318
+ reason: "over-budget-tolerated",
39319
+ picked: fallback,
39320
+ profile: fallbackProfile,
39321
+ budgetKbps,
39322
+ notes
39323
+ };
39324
+ return {
39325
+ kind: "transcode",
39326
+ reason: transcodeReason(fallback, fallbackProfile, input.bitrates, budgetKbps),
39327
+ picked: fallback,
39328
+ profile: fallbackProfile,
39329
+ budgetKbps,
39330
+ notes
39331
+ };
39332
+ }
39333
+ /**
39334
+ * Fill in a codec the profile restream entry did not carry, from its broker
39335
+ * slot. `getProfileRtspEntries` has historically omitted it for legacy
39336
+ * entries, and a slot whose codec is unknown must not be mistaken for H.264.
39337
+ */
39338
+ function withSlotCodecs(entries, slots) {
39339
+ const codecByProfile = /* @__PURE__ */ new Map();
39340
+ for (const slot of slots) if (slot.codec !== void 0) codecByProfile.set(slot.profile, slot.codec);
39341
+ return entries.map((entry) => {
39342
+ if (entry.codec !== void 0) return entry;
39343
+ const codec = codecByProfile.get(entry.profile);
39344
+ return codec === void 0 ? entry : {
39345
+ ...entry,
39346
+ codec
39347
+ };
39348
+ });
39349
+ }
39350
+ /**
39351
+ * The rate we will actually deliver: never more than the controller asked for,
39352
+ * and never more than the source produces. `-r` above the source rate makes
39353
+ * ffmpeg DUPLICATE frames, which spends the budget on nothing.
39354
+ */
39355
+ function deliverableFps(negotiatedFps, slotFps) {
39356
+ if (slotFps === null || !Number.isFinite(slotFps) || slotFps <= 0) return negotiatedFps;
39357
+ return Math.min(negotiatedFps, Math.floor(slotFps));
39358
+ }
39359
+ /**
39360
+ * The ffmpeg video-output arguments.
39361
+ *
39362
+ * Pass-through carries `-bsf:v dump_extra` so every IDR inlines its own
39363
+ * SPS/PPS — sources that publish parameter sets only in the RTSP SDP hand iOS
39364
+ * a keyframe it cannot decode otherwise.
39365
+ *
39366
+ * The transcode's cap is three flags, not one: `-b:v` is an average and on its
39367
+ * own permits exactly the burst that was measured. `-maxrate` plus a
39368
+ * **one-second** `-bufsize` bounds any one-second window at the negotiated
39369
+ * rate, which is also the only lever available on the 3.03 s peak jitter — the
39370
+ * VBV window is what forces x264 to size a key frame to fit rather than
39371
+ * emitting it as one tight burst.
39372
+ */
39373
+ /**
39374
+ * Seconds between forced IDRs on the transcode path.
39375
+ *
39376
+ * A join can only start decoding at a key frame, so this is the worst-case
39377
+ * wait a controller pays before the first picture — and the bound the RTSP
39378
+ * join-burst withhold falls back on when it declines to replay a wide GOP.
39379
+ */
39380
+ var KEYFRAME_INTERVAL_SEC = 4;
39381
+ function buildVideoEncodeArgs(input) {
39382
+ if (!input.transcode) return [
39383
+ "-c:v",
39384
+ "copy",
39385
+ "-bsf:v",
39386
+ "dump_extra"
39387
+ ];
39388
+ const rate = input.budgetKbps === null ? [] : [
39389
+ "-b:v",
39390
+ `${input.budgetKbps}k`,
39391
+ "-maxrate",
39392
+ `${input.budgetKbps}k`,
39393
+ "-bufsize",
39394
+ `${input.budgetKbps}k`
39395
+ ];
39396
+ return [
39397
+ "-c:v",
39398
+ "libx264",
39399
+ "-preset",
39400
+ "ultrafast",
39401
+ "-tune",
39402
+ "zerolatency",
39403
+ "-pix_fmt",
39404
+ "yuv420p",
39405
+ "-r",
39406
+ String(input.fps),
39407
+ "-s",
39408
+ `${input.width}x${input.height}`,
39409
+ "-g",
39410
+ String(Math.max(1, Math.round(input.fps * KEYFRAME_INTERVAL_SEC))),
39411
+ ...rate,
39412
+ "-profile:v",
39413
+ "baseline",
39414
+ "-level",
39415
+ "3.1",
39416
+ "-bsf:v",
39417
+ "dump_extra"
39418
+ ];
39419
+ }
39420
+ /** Compact `mid=2048pub/9meas:over-budget` rendering for a single log field. */
39421
+ function formatFitNotes(notes) {
39422
+ return notes.map((n) => `${n.profile}=${n.publishedKbps ?? "?"}pub/${n.measuredKbps ?? "?"}meas:${n.verdict}`);
39423
+ }
39424
+ function fitNotes(entries, bitrates, budgetKbps) {
39425
+ return entries.map((entry) => {
39426
+ const evidence = bitrates.get(entry.profile);
39427
+ return {
39428
+ profile: entry.profile,
39429
+ verdict: budgetKbps === null ? "unknown" : classifyBitrateFit(evidence, budgetKbps),
39430
+ publishedKbps: evidence?.publishedKbps ?? null,
39431
+ measuredKbps: evidence?.measuredKbps ?? null
39432
+ };
39433
+ });
39434
+ }
39435
+ function transcodeReason(picked, profile, bitrates, budgetKbps) {
39436
+ if (!canPassThrough(picked.codec)) return "source-codec";
39437
+ if (profile === null) return "unknown-bitrate";
39438
+ return classifyBitrateFit(bitrates.get(profile), budgetKbps) === "over-budget" ? "over-budget" : "unknown-bitrate";
39439
+ }
39440
+ /**
39441
+ * iOS Home renders only H.264 over the classic HAP SRTP path, so an H.265
39442
+ * source can never be passed through. An UNKNOWN codec is treated the same
39443
+ * way: guessing H.264 is how a camera that changed its encoder ends up
39444
+ * shipping bytes no controller can decode.
39445
+ */
39446
+ function canPassThrough(codec) {
39447
+ if (codec === void 0) return false;
39448
+ const lower = codec.toLowerCase();
39449
+ if (lower.includes("h265") || lower.includes("hevc")) return false;
39450
+ return lower.includes("h264") || lower.includes("avc");
39451
+ }
39452
+ function plausibleMeasured(value) {
39453
+ if (value === null || !Number.isFinite(value)) return null;
39454
+ return value >= 64 ? value : null;
39455
+ }
39456
+ /**
39457
+ * Map a profile slot onto the camera encoder feeding it, and read that
39458
+ * encoder's configured bitrate.
39459
+ *
39460
+ * The link is the slot's assigned cam-stream: its resolution (and frame rate,
39461
+ * when two encoders share a resolution) identifies which of `main`/`sub`/`ext`
39462
+ * produces it. Vendor-neutral on purpose — the cam-stream ids (`native:main`,
39463
+ * `native:slot-3`, …) are provider strings and matching on them would work for
39464
+ * exactly one provider. An ambiguous match returns `null`.
39465
+ */
39466
+ function publishedRateFor(slot, source, streamParams) {
39467
+ if (streamParams === null) return null;
39468
+ const resolution = source?.resolution ?? slot.resolution;
39469
+ if (resolution === void 0) return null;
39470
+ const byResolution = ENCODER_PROFILE_KEYS.map((key) => encoderConfig(streamParams, key)).filter((cfg) => cfg !== null && cfg.width === resolution.width && cfg.height === resolution.height);
39471
+ if (byResolution.length === 1) return positiveOrNull(byResolution[0]?.bitrate);
39472
+ const fps = source?.fps;
39473
+ if (fps === void 0) return null;
39474
+ const byFps = byResolution.filter((cfg) => cfg !== null && Math.floor(cfg.framerate) === Math.floor(fps));
39475
+ return byFps.length === 1 ? positiveOrNull(byFps[0]?.bitrate) : null;
39476
+ }
39477
+ function encoderConfig(status, key) {
39478
+ return status[key] ?? null;
39479
+ }
39480
+ function positiveOrNull(value) {
39481
+ if (value === void 0 || !Number.isFinite(value) || value <= 0) return null;
39482
+ return value;
39483
+ }
39484
+ var CAM_PROFILES$1 = [
39485
+ "high",
39486
+ "mid",
39487
+ "low"
39488
+ ];
39489
+ /**
39490
+ * `PickedStream.profileId` is the brokerId suffix, which for a profile-keyed
39491
+ * entry IS the profile name. Anything else addresses a raw cam-stream and must
39492
+ * not be coerced into a profile.
39493
+ */
39494
+ function toCamProfile$1(profileId) {
39495
+ return CAM_PROFILES$1.find((p) => p === profileId) ?? null;
39496
+ }
39497
+ //#endregion
39498
+ //#region src/mappers/builders/stream-bitrate-probe.ts
39499
+ /**
39500
+ * Resolve every profile slot's rate. Never throws: an unresolvable slot ends
39501
+ * up `unknown`, which the selector treats as "cannot prove it fits" and
39502
+ * therefore transcodes — the safe direction on the wire.
39503
+ */
39504
+ async function probeProfileBitrates(input) {
39505
+ const { proxy } = input.bctx;
39506
+ const camStreams = await probe$1(() => proxy.cameraStreams?.getCameraStreams({}), "cameraStreams.getCameraStreams", input.log);
39507
+ const streamParams = await probe$1(() => proxy.streamParams?.getStatus({}), "streamParams.getStatus", input.log);
39508
+ const choices = await probe$1(() => proxy.webrtcSession?.listStreams({}), "webrtcSession.listStreams", input.log);
39509
+ return resolveProfileBitrates({
39510
+ slots: input.slots,
39511
+ camStreams: camStreams ?? [],
39512
+ streamParams,
39513
+ choices: choices ?? []
39514
+ });
39515
+ }
39516
+ async function probe$1(call, label, log) {
39517
+ try {
39518
+ const pending = call();
39519
+ if (pending === void 0) {
39520
+ log.info("export-hap: bitrate probe skipped — cap not bound on this device", { meta: { call: label } });
39521
+ return null;
39522
+ }
39523
+ return await pending;
39524
+ } catch (err) {
39525
+ log.warn("export-hap: bitrate probe failed — the slot rate stays UNKNOWN", { meta: {
39526
+ call: label,
39527
+ error: err instanceof Error ? err.message : String(err)
39528
+ } });
39529
+ return null;
39530
+ }
39531
+ }
39532
+ /**
39533
+ * Two outputs from one input: video SRTP and audio SRTP, one process, one
39534
+ * lifetime, one kill signal. ffmpeg-internal stream selection (`-vn` / `-an`
39535
+ * plus per-output codec args) keeps both flowing through it.
39536
+ */
39537
+ function buildSessionFfmpegArgs(input) {
39538
+ return [
39539
+ "-hide_banner",
39540
+ "-loglevel",
39541
+ "warning",
39542
+ ...input.decodeArgs,
39543
+ "-rtsp_transport",
39544
+ "tcp",
39545
+ "-i",
39546
+ input.rtspUrl,
39547
+ "-an",
39548
+ "-map",
39549
+ "0:v:0",
39550
+ ...input.videoArgs,
39551
+ "-payload_type",
39552
+ String(input.videoPayloadType),
39553
+ "-ssrc",
39554
+ String(input.videoSsrcSigned),
39555
+ "-f",
39556
+ "rtp",
39557
+ input.videoTarget,
39558
+ "-vn",
39559
+ "-map",
39560
+ "0:a:0?",
39561
+ "-af",
39562
+ "aresample=async=1000:first_pts=0",
39563
+ "-c:a",
39564
+ "libopus",
39565
+ "-application",
39566
+ "lowdelay",
39567
+ "-frame_duration",
39568
+ String(input.audioPacketTimeMs),
39569
+ "-flags",
39570
+ "+global_header",
39571
+ "-ar",
39572
+ String(input.audioSampleRateKhz * 1e3),
39573
+ "-b:a",
39574
+ `24k`,
39575
+ "-bufsize",
39576
+ `96k`,
39577
+ "-ac",
39578
+ String(1),
39579
+ "-payload_type",
39580
+ String(input.audioPayloadType),
39581
+ "-ssrc",
39582
+ String(input.audioSsrcSigned),
39583
+ "-f",
39584
+ "rtp",
39585
+ input.audioTarget
39586
+ ];
39587
+ }
39588
+ /**
39589
+ * The resolutions we offer, before rates are attached. Same list the delegate
39590
+ * advertised before R2 — only the frame rate changes, so a controller that had
39591
+ * already negotiated a resolution keeps finding it.
39592
+ */
39593
+ var CANDIDATE_RESOLUTIONS = [
39594
+ [1920, 1080],
39595
+ [1280, 720],
39596
+ [1024, 768],
39597
+ [640, 480],
39598
+ [640, 360],
39599
+ [480, 360],
39600
+ [480, 270],
39601
+ [320, 240],
39602
+ [320, 180]
39603
+ ];
39604
+ /**
39605
+ * Resolve the real frame rate of each profile slot.
39606
+ *
39607
+ * A slot with no measurement, no publication and no assignment simply does not
39608
+ * appear in the map — the caller then advertises {@link ASSUMED_FPS} and says
39609
+ * so. Absence is never silently rendered as a number.
39610
+ */
39611
+ function resolveProfileFps(input) {
39612
+ const publishedByProfile = publishedFpsByProfile(input.slots, input.camStreams);
39613
+ const out = /* @__PURE__ */ new Map();
39614
+ for (const choice of input.choices) {
39615
+ if (choice.target.kind !== "profile") continue;
39616
+ const profile = choice.target.profile;
39617
+ const measured = clampFps(choice.inputFps);
39618
+ if (measured !== null) {
39619
+ out.set(profile, {
39620
+ profile,
39621
+ fps: measured,
39622
+ source: "measured"
39623
+ });
39624
+ continue;
39625
+ }
39626
+ const published = clampFps(publishedByProfile.get(profile) ?? null);
39627
+ out.set(profile, published !== null ? {
39628
+ profile,
39629
+ fps: published,
39630
+ source: "published"
39631
+ } : {
39632
+ profile,
39633
+ fps: 30,
39634
+ source: "assumed"
39635
+ });
39636
+ }
39637
+ for (const [profile, fps] of publishedByProfile) {
39638
+ if (out.has(profile)) continue;
39639
+ const published = clampFps(fps);
39640
+ if (published !== null) out.set(profile, {
39641
+ profile,
39642
+ fps: published,
39643
+ source: "published"
39644
+ });
39645
+ }
39646
+ return out;
39647
+ }
39648
+ /**
39649
+ * Attach a frame rate to each candidate resolution by asking the REAL picker
39650
+ * which slot the START path would dial for it. That coupling is the point: if
39651
+ * the picker's steering changes, the advertisement changes with it instead of
39652
+ * drifting into a second, silently different opinion.
39653
+ */
39654
+ function deriveAdvertisedResolutions(input) {
39655
+ const candidates = input.candidates.length > 0 ? input.candidates : CANDIDATE_RESOLUTIONS;
39656
+ const seen = /* @__PURE__ */ new Set();
39657
+ const out = [];
39658
+ for (const [width, height] of candidates) {
39659
+ const picked = pickPreferredRtspEntry(input.entries, input.pref, input.deviceId, { targetResolution: {
39660
+ width,
39661
+ height
39662
+ } });
39663
+ const profile = picked === null ? null : toCamProfile(picked.profileId);
39664
+ const resolved = profile === null ? void 0 : input.fpsByProfile.get(profile);
39665
+ const advertised = {
39666
+ width,
39667
+ height,
39668
+ fps: resolved?.fps ?? 30,
39669
+ profile,
39670
+ source: resolved?.source ?? "assumed"
39671
+ };
39672
+ const key = `${advertised.width}x${advertised.height}@${advertised.fps}`;
39673
+ if (seen.has(key)) continue;
39674
+ seen.add(key);
39675
+ out.push(advertised);
39676
+ }
39677
+ return out;
39678
+ }
39679
+ /** Project onto the `[width, height, fps]` triples hap-nodejs expects. */
39680
+ function toHapResolutions(advertised) {
39681
+ return advertised.map((a) => [
39682
+ a.width,
39683
+ a.height,
39684
+ a.fps
39685
+ ]);
39686
+ }
39687
+ /** Compact `1280x720@10(measured)` rendering for a single log field. */
39688
+ function formatAdvertisedResolutions(advertised) {
39689
+ return advertised.map((a) => `${a.width}x${a.height}@${a.fps}/${a.profile ?? "-"}(${a.source})`);
39690
+ }
39691
+ function publishedFpsByProfile(slots, camStreams) {
39692
+ const fpsByCamStream = /* @__PURE__ */ new Map();
39693
+ for (const stream of camStreams) if (typeof stream.fps === "number") fpsByCamStream.set(stream.camStreamId, stream.fps);
39694
+ const out = /* @__PURE__ */ new Map();
39695
+ for (const slot of slots) {
39696
+ if (slot.sourceCamStreamId === null) continue;
39697
+ const fps = fpsByCamStream.get(slot.sourceCamStreamId);
39698
+ if (fps !== void 0) out.set(slot.profile, fps);
39699
+ }
39700
+ return out;
39701
+ }
39702
+ /**
39703
+ * Coerce a probe reading into an advertisable integer rate, or null when the
39704
+ * reading carries no information (absent, zero because the broker is idle,
39705
+ * negative, NaN, or below the representable floor).
39706
+ */
39707
+ function clampFps(value) {
39708
+ if (typeof value !== "number" || !Number.isFinite(value)) return null;
39709
+ const floored = Math.floor(value);
39710
+ if (floored < 1) return null;
39711
+ return Math.min(floored, 30);
39712
+ }
39713
+ var CAM_PROFILES = [
39714
+ "high",
39715
+ "mid",
39716
+ "low"
39717
+ ];
39718
+ /**
39719
+ * `PickedStream.profileId` is the brokerId suffix, which for a profile-keyed
39720
+ * entry IS the profile name. Anything else (a raw cam-stream id) is not a
39721
+ * profile and must not be coerced into one.
39722
+ */
39723
+ function toCamProfile(profileId) {
39724
+ return CAM_PROFILES.find((p) => p === profileId) ?? null;
39725
+ }
39726
+ /**
39727
+ * How long after spawn an exit still counts as "hardware init failed".
39728
+ *
39729
+ * Same window the ffmpeg decoder addon uses for its own cascade. A hardware
39730
+ * context that cannot be created fails within milliseconds; anything that ran
39731
+ * longer produced no frames for a different reason, and re-spawning it in
39732
+ * software would just hide that reason.
39733
+ */
39734
+ var HW_DECODE_FALLBACK_WINDOW_MS = 4e3;
39735
+ /** Backends whose decode binds to a DRM render node. */
39736
+ var RENDER_NODE_BACKENDS = ["vaapi", "qsv"];
39737
+ /**
39738
+ * Values of the decoder cap's `hwaccel` field that are operator CHOICES rather
39739
+ * than devices.
39740
+ */
39741
+ var HWACCEL_NON_BACKEND_CHOICES = ["auto", "none"];
39742
+ /**
39743
+ * Every backend the decoder cap can publish, taken from the cap's own UI option
39744
+ * list so this module cannot drift from it. A hand-written copy of the union is
39745
+ * exactly the "hand-written cap interface that rots silently" this repo has
39746
+ * been bitten by.
39747
+ */
39748
+ var HWACCEL_BACKENDS = new Set(HWACCEL_OPTIONS.map((option) => option.value).filter((value) => !HWACCEL_NON_BACKEND_CHOICES.includes(value)));
39749
+ /** Is this string a hardware backend, as opposed to `auto`, `none` or junk? */
39750
+ function isHwAccelBackend(value) {
39751
+ return HWACCEL_BACKENDS.has(value);
39752
+ }
39753
+ /**
39754
+ * Decide how this session decodes, and produce the ffmpeg input-side flags.
39755
+ *
39756
+ * Every path that ends in software carries a named reason, because a session
39757
+ * that quietly stopped using the GPU and a session that never had one look
39758
+ * identical in a CPU graph.
39759
+ */
39760
+ function selectHwDecode(input) {
39761
+ if (!input.transcode) return software("pass-through");
39762
+ if (input.hardwareAlreadyFailed === true) return software("hardware-attempt-failed");
39763
+ if (input.reading === null) return software("no-decoder-reading");
39764
+ const chosen = (input.reading.hwaccel ?? "").trim();
39765
+ if (chosen === "none") return software("operator-disabled");
39766
+ if (chosen !== "" && chosen !== "auto") return isHwAccelBackend(chosen) ? hardware(chosen, "operator", input) : software("unrecognised-backend");
39767
+ const probed = (input.reading.probedBestHwaccel ?? "").trim();
39768
+ if (probed === "" || probed === "none") return software("not-probed");
39769
+ return isHwAccelBackend(probed) ? hardware(probed, "probed", input) : software("unrecognised-backend");
39770
+ }
39771
+ /**
39772
+ * Did this ffmpeg exit look like a failed hardware init, rather than a
39773
+ * teardown or a fault software would hit too?
39774
+ *
39775
+ * All five conditions are necessary. Dropping the controller-stop check in
39776
+ * particular would respawn on every normal teardown, because iOS restarts a
39777
+ * session it is not enjoying and that is indistinguishable at the exit code.
39778
+ */
39779
+ function shouldRetryInSoftware(input) {
39780
+ if (!input.usedHardware) return false;
39781
+ if (input.hardwareAlreadyFailed) return false;
39782
+ if (input.stopRequestedByController) return false;
39783
+ if (input.videoPacketsForwarded > 0) return false;
39784
+ return input.runtimeMs <= HW_DECODE_FALLBACK_WINDOW_MS;
39785
+ }
39786
+ function software(reason) {
39787
+ return {
39788
+ kind: "software",
39789
+ reason,
39790
+ args: []
39791
+ };
39792
+ }
39793
+ function hardware(backend, source, input) {
39794
+ return {
39795
+ kind: "hardware",
39796
+ backend,
39797
+ source,
39798
+ args: decodeArgs(backend, input)
39799
+ };
39800
+ }
39801
+ /**
39802
+ * The input-side flags, and nothing else.
39803
+ *
39804
+ * No `-hwaccel_output_format`: the decoded frames have to land in system
39805
+ * memory for libx264 to scale and encode them. Setting it would keep them on
39806
+ * the GPU, which only pays off with a GPU scale filter — and that is the
39807
+ * decoder addon's job, not a two-output SRTP session's.
39808
+ */
39809
+ function decodeArgs(backend, input) {
39810
+ if (backend === "videotoolbox" && input.platform === "darwin") return ["-hwaccel", "auto"];
39811
+ const args = ["-hwaccel", backend];
39812
+ if (RENDER_NODE_BACKENDS.includes(backend)) args.push("-hwaccel_device", input.renderDevice ?? "/dev/dri/renderD128");
39813
+ return args;
39814
+ }
39815
+ //#endregion
39816
+ //#region src/mappers/builders/stream-hwaccel-probe.ts
39817
+ /**
39818
+ * Read this node's decode-hwaccel state, or `null` when nothing answered.
39819
+ *
39820
+ * Never throws. `null` means "we do not know", which
39821
+ * {@link import('./stream-hwaccel.js').selectHwDecode} turns into software —
39822
+ * the safe direction, because a guess here costs the whole stream.
39823
+ */
39824
+ async function probeDecoderHwaccel(input) {
39825
+ const { ctx, log } = input;
39826
+ const nodeId = ctx.kernel?.localNodeId;
39827
+ if (nodeId === void 0 || nodeId.length === 0) {
39828
+ log.warn("export-hap: hwaccel probe skipped — no local node id, decoding in SOFTWARE");
39829
+ return null;
39830
+ }
39831
+ try {
39832
+ const info = await ctx.api.decoder.getInfo.query(void 0, nodePin(nodeId));
39833
+ if (info === null || info === void 0) {
39834
+ log.info("export-hap: hwaccel probe returned nothing — decoding in SOFTWARE", { meta: { nodeId } });
39835
+ return null;
39836
+ }
39837
+ return {
39838
+ hwaccel: info.hwaccel ?? null,
39839
+ probedBestHwaccel: info.probedBestHwaccel ?? null
39840
+ };
39841
+ } catch (err) {
39842
+ log.warn("export-hap: hwaccel probe failed — decoding in SOFTWARE", { meta: {
39843
+ nodeId,
39844
+ error: err instanceof Error ? err.message : String(err)
39845
+ } });
39846
+ return null;
39847
+ }
39848
+ }
39849
+ //#endregion
39850
+ //#region src/mappers/builders/stream-telemetry.ts
39851
+ /**
39852
+ * Every branch on the streaming path that discards work, and its starting
39853
+ * count. This object is the single source of the reason set: the union below
39854
+ * is its `keyof`, so adding a silent `return` without adding a reason here is
39855
+ * a compile error rather than an invisible hole.
39856
+ */
39857
+ var ZERO_DROP_COUNTERS = {
39858
+ /** Outbound: the leg has no SRTP session (prepareStream init failed). */
39859
+ "no-srtp-session": 0,
39860
+ /** Outbound: the leg has no return-path gate — forwarding was never armed. */
39861
+ "no-gate": 0,
39862
+ /** Outbound: werift refused to encrypt the packet. */
39863
+ "srtp-encrypt-failed": 0,
39864
+ /** Outbound: the UDP send itself errored. */
39865
+ "srtp-send-failed": 0,
39866
+ /** Outbound RTCP: the leg has no SRTCP context, so no Sender Report can go out. */
39867
+ "rtcp-no-srtcp": 0,
39868
+ /** Outbound RTCP: building or encrypting the Sender Report failed. */
39869
+ "rtcp-encrypt-failed": 0,
39870
+ /** Inbound RTCP: the leg has no SRTCP context, so the controller's report is unreadable. */
39871
+ "inbound-rtcp-no-srtcp": 0,
39872
+ /** Inbound RTCP: SRTCP decryption of a controller packet failed. */
39873
+ "inbound-rtcp-decrypt-failed": 0,
39874
+ /** Inbound RTCP: the decrypted bytes did not parse as RTCP. */
39875
+ "inbound-rtcp-parse-failed": 0,
39876
+ /** Upstream: packet shorter than an RTP header. */
39877
+ "upstream-short-packet": 0,
39878
+ /** Upstream: no inbound SRTP context (init failed at prepareStream). */
39879
+ "upstream-no-srtp": 0,
39880
+ /** Upstream: SRTP authentication/decrypt failed. */
39881
+ "upstream-decrypt-failed": 0,
39882
+ /** Upstream: decrypted bytes did not parse as RTP. */
39883
+ "upstream-parse-failed": 0,
39884
+ /** Upstream: audio arrived before `handleStreamRequest('start')` settled. */
39885
+ "upstream-before-start": 0,
39886
+ /** Upstream: payload type is not the negotiated audio PT (RTCP / keepalive). */
39887
+ "upstream-pt-mismatch": 0,
39888
+ /** Upstream: RTP carried a zero-length payload. */
39889
+ "upstream-empty-payload": 0,
39890
+ /** Upstream: no camera-side talk session, so the frame has nowhere to go. */
39891
+ "upstream-no-talk-session": 0,
39892
+ /** Upstream: the camera-side `pushTalkAudio` rejected the frame. */
39893
+ "upstream-push-failed": 0,
39894
+ /** Snapshot: the `snapshot` cap returned nothing. */
39895
+ "snapshot-unavailable": 0
39896
+ };
39897
+ function emptyDropCounters() {
39898
+ return { ...ZERO_DROP_COUNTERS };
39899
+ }
39900
+ /** Immutable increment — returns a new record, never touches the input. */
39901
+ function recordDrop(counters, reason) {
39902
+ return {
39903
+ ...counters,
39904
+ [reason]: counters[reason] + 1
39905
+ };
39906
+ }
39907
+ /** Only the reasons that actually fired, so a clean session logs `{}`. */
39908
+ function nonZeroDrops(counters) {
39909
+ const out = {};
39910
+ for (const [reason, count] of Object.entries(counters)) if (count > 0) out[reason] = count;
39911
+ return out;
39912
+ }
39913
+ /**
39914
+ * Classify a packet received on one of our advertised return ports.
39915
+ *
39916
+ * RTP and RTCP arrive on the same symmetric port here. RTCP packet types are
39917
+ * 200..207, which sit inside the 192..223 band RFC 5761 §4 reserves precisely
39918
+ * so the two can be told apart on a shared socket (an RTP packet's marker bit
39919
+ * plus payload type can never land there for any payload type we negotiate).
39920
+ * The payloads are SRTP/SRTCP-encrypted, but both keep the first two bytes in
39921
+ * the clear.
39922
+ */
39923
+ function classifyInboundPacket(packet) {
39924
+ if (packet.length < 2) return "malformed";
39925
+ if ((packet[0] >> 6 & 3) !== 2) return "malformed";
39926
+ const typeByte = packet[1];
39927
+ if (typeByte >= 192 && typeByte <= 223) return "rtcp";
39928
+ return "rtp";
39929
+ }
39930
+ /**
39931
+ * Loss at or above this is not a start-up transient.
39932
+ *
39933
+ * A stream whose first key frame is slow reliably produces one report in the
39934
+ * low single digits; a transmit-side defect produces tens of percent, because
39935
+ * whatever makes a packet unusable makes most packets unusable. The threshold
39936
+ * sits between those two regimes rather than at any measured boundary — it is
39937
+ * a reading aid, and the raw `worstFractionLostPct` is always in the line
39938
+ * beside it.
39939
+ */
39940
+ var TRANSMIT_SUSPECT_FRACTION_LOST_PCT = 5;
39941
+ function lossVerdict(tally) {
39942
+ if (tally.blocksParsed === 0 || tally.worstFractionLostPct === null) return "no-reports";
39943
+ return tally.worstFractionLostPct >= TRANSMIT_SUSPECT_FRACTION_LOST_PCT ? "transmit-suspect" : "decode-suspect";
39944
+ }
39945
+ /**
39946
+ * Build the meta for `export-hap: stream session summary` — the single line a
39947
+ * future session greps to answer "why did this session die".
39948
+ */
39949
+ function summariseSession(snapshot) {
39950
+ const durationMs = snapshot.startedAtMs !== null && snapshot.endedAtMs !== null ? snapshot.endedAtMs - snapshot.startedAtMs : null;
39951
+ const negotiated = snapshot.negotiated;
39952
+ const slot = snapshot.selectedSlot;
39953
+ return {
39954
+ sessionId: snapshot.sessionId,
39955
+ durationMs,
39956
+ stopRequestedByController: snapshot.stopRequestedByController,
39957
+ ffmpegExitCode: snapshot.ffmpegExit?.code ?? null,
39958
+ ffmpegExitSignal: snapshot.ffmpegExit?.signal ?? null,
39959
+ negotiatedResolution: negotiated ? `${negotiated.width}x${negotiated.height}` : null,
39960
+ negotiatedFps: negotiated?.fps ?? null,
39961
+ negotiatedMaxBitrateKbps: negotiated?.maxBitrateKbps ?? null,
39962
+ selectedProfile: slot?.profile ?? null,
39963
+ selectedBrokerId: slot?.brokerId ?? null,
39964
+ advertisedFps: slot?.advertisedFps ?? null,
39965
+ advertisedFpsSource: slot?.advertisedFpsSource ?? null,
39966
+ deliveredFps: slot?.deliveredFps ?? null,
39967
+ transcode: slot?.transcode ?? null,
39968
+ fitReason: slot?.fitReason ?? null,
39969
+ slotPublishedKbps: slot?.publishedKbps ?? null,
39970
+ slotMeasuredKbps: slot?.measuredKbps ?? null,
39971
+ encodeBudgetKbps: slot?.budgetKbps ?? null,
39972
+ fitNotes: slot?.fitNotes ?? [],
39973
+ videoPacketsForwarded: snapshot.videoPacketsForwarded,
39974
+ audioPacketsForwarded: snapshot.audioPacketsForwarded,
39975
+ videoRtcpSrSent: snapshot.videoRtcpSrSent,
39976
+ audioRtcpSrSent: snapshot.audioRtcpSrSent,
39977
+ videoRtcpReceived: snapshot.videoRtcpReceived,
39978
+ audioRtcpReceived: snapshot.audioRtcpReceived,
39979
+ videoRtpReceived: snapshot.videoRtpReceived,
39980
+ audioRtpReceived: snapshot.audioRtpReceived,
39981
+ videoGate: formatRtcpGate(snapshot.videoGate),
39982
+ audioGate: formatRtcpGate(snapshot.audioGate),
39983
+ videoReceiverReports: snapshot.videoReceiverReports,
39984
+ audioReceiverReports: snapshot.audioReceiverReports,
39985
+ videoLossVerdict: lossVerdict(snapshot.videoReceiverReports),
39986
+ audioLossVerdict: lossVerdict(snapshot.audioReceiverReports),
39987
+ mediaStarved: snapshot.videoPacketsForwarded === 0,
39988
+ drops: nonZeroDrops(snapshot.drops)
39989
+ };
39990
+ }
39991
+ //#endregion
39992
+ //#region src/mappers/builders/camera-streams.ts
38180
39993
  var SRTP_KEY_LEN = 16;
38181
39994
  var SRTP_SALT_LEN = 14;
38182
- var OPUS_BITRATE_KBPS = 24;
38183
- var OPUS_CHANNELS = 1;
38184
- function buildCameraStreamingDelegate(bctx) {
39995
+ /**
39996
+ * Cadence of the per-session heartbeat log.
39997
+ *
39998
+ * This timer LOGS ONLY. It never kills ffmpeg, never closes a socket and never
39999
+ * ends a session. That distinction matters: the two mainstream open-source
40000
+ * HomeKit camera stacks arm an idle watchdog that tears the stream down at
40001
+ * exactly 30 000 ms, and the ~31 s teardown here was misdiagnosed as one for a
40002
+ * long time before it was proven that this accessory has no such timer. Do not
40003
+ * make this one act.
40004
+ */
40005
+ var SESSION_HEARTBEAT_MS = 5e3;
40006
+ /**
40007
+ * Floor between two `export-hap: controller receiver report` lines on one leg.
40008
+ *
40009
+ * The controller sends several Receiver Reports a second — 21 and 34 in two
40010
+ * ~10 s sessions on 2026-08-06 — and every one of them at `info` would drown
40011
+ * the very line it is meant to make findable. The FIRST report on each leg is
40012
+ * always logged, in full and under its own message; after that this throttles
40013
+ * to the heartbeat's cadence so a session still produces a running record of
40014
+ * what the controller thinks without becoming one.
40015
+ */
40016
+ var RECEIVER_REPORT_LOG_INTERVAL_MS = 5e3;
40017
+ function buildCameraStreamingDelegate(bctx, advertised) {
38185
40018
  const { ctx, numericDeviceId } = bctx;
38186
40019
  const log = ctx.logger.withTags({ deviceId: numericDeviceId });
38187
40020
  const sessions = /* @__PURE__ */ new Map();
@@ -38200,7 +40033,7 @@ function buildCameraStreamingDelegate(bctx) {
38200
40033
  });
38201
40034
  },
38202
40035
  handleStreamRequest(request, callback) {
38203
- handleStreamRequest(request, sessions, bctx).then(() => callback()).catch((err) => {
40036
+ handleStreamRequest(request, sessions, bctx, advertised).then(() => callback()).catch((err) => {
38204
40037
  log.warn("export-hap: handleStreamRequest failed", { meta: { error: errMsg$8(err) } });
38205
40038
  callback(err instanceof Error ? err : new Error(errMsg$8(err)));
38206
40039
  });
@@ -38221,58 +40054,7 @@ function buildCameraStreamingDelegate(bctx) {
38221
40054
  _homebridge_hap_nodejs.H264Level.LEVEL4_0
38222
40055
  ]
38223
40056
  },
38224
- resolutions: [
38225
- [
38226
- 1920,
38227
- 1080,
38228
- 30
38229
- ],
38230
- [
38231
- 1280,
38232
- 720,
38233
- 30
38234
- ],
38235
- [
38236
- 1024,
38237
- 768,
38238
- 30
38239
- ],
38240
- [
38241
- 640,
38242
- 480,
38243
- 30
38244
- ],
38245
- [
38246
- 640,
38247
- 360,
38248
- 30
38249
- ],
38250
- [
38251
- 480,
38252
- 360,
38253
- 30
38254
- ],
38255
- [
38256
- 480,
38257
- 270,
38258
- 30
38259
- ],
38260
- [
38261
- 320,
38262
- 240,
38263
- 30
38264
- ],
38265
- [
38266
- 320,
38267
- 240,
38268
- 15
38269
- ],
38270
- [
38271
- 320,
38272
- 180,
38273
- 30
38274
- ]
38275
- ]
40057
+ resolutions: toHapResolutions(advertised.resolutions)
38276
40058
  },
38277
40059
  audio: {
38278
40060
  codecs: [{
@@ -38287,10 +40069,16 @@ function buildCameraStreamingDelegate(bctx) {
38287
40069
  },
38288
40070
  dispose: async () => {
38289
40071
  for (const session of sessions.values()) {
40072
+ session.teardownTrigger = "accessory-dispose";
40073
+ const hadFfmpeg = session.ffmpeg !== null;
38290
40074
  killFfmpeg(session, ctx, numericDeviceId);
40075
+ stopHeartbeat(session);
40076
+ if (!hadFfmpeg) logSessionSummary(session, log, "accessory-dispose-no-ffmpeg");
38291
40077
  await closeIntercomTalkSession(session, bctx).catch(() => void 0);
38292
40078
  closeSocket(session.videoUdp);
38293
40079
  closeSocket(session.audioUdp);
40080
+ closeSocket(session.videoLoopUdp);
40081
+ closeSocket(session.audioLoopUdp);
38294
40082
  }
38295
40083
  sessions.clear();
38296
40084
  }
@@ -38300,7 +40088,10 @@ async function handleSnapshot(bctx, request) {
38300
40088
  const { proxy, ctx, numericDeviceId } = bctx;
38301
40089
  const result = await proxy.snapshot?.getSnapshot({});
38302
40090
  if (!result || typeof result.base64 !== "string") {
38303
- ctx.logger.withTags({ deviceId: numericDeviceId }).debug("export-hap: snapshot returned null");
40091
+ ctx.logger.withTags({ deviceId: numericDeviceId }).info("export-hap: snapshot dropped", { meta: {
40092
+ reason: "snapshot-unavailable",
40093
+ capBound: proxy.snapshot !== void 0
40094
+ } });
38304
40095
  throw new Error("snapshot unavailable");
38305
40096
  }
38306
40097
  return Buffer.from(result.base64, "base64");
@@ -38330,7 +40121,7 @@ async function prepareStream(request, sessions, bctx) {
38330
40121
  },
38331
40122
  profile: import_src.ProtectionProfileAes128CmHmacSha1_80
38332
40123
  });
38333
- const makeOutSrtcp = (key, salt) => new import_src.SrtcpSession({
40124
+ const makeSrtcp = (key, salt) => new import_src.SrtcpSession({
38334
40125
  keys: {
38335
40126
  localMasterKey: key,
38336
40127
  localMasterSalt: salt,
@@ -38341,13 +40132,17 @@ async function prepareStream(request, sessions, bctx) {
38341
40132
  });
38342
40133
  let videoOutSrtp;
38343
40134
  let videoOutSrtcp;
40135
+ let videoInSrtcp;
38344
40136
  let audioOutSrtp;
38345
40137
  let audioOutSrtcp;
40138
+ let audioInSrtcp;
38346
40139
  try {
38347
40140
  videoOutSrtp = makeOutSrtp(request.video.srtp_key, request.video.srtp_salt);
38348
- videoOutSrtcp = makeOutSrtcp(request.video.srtp_key, request.video.srtp_salt);
40141
+ videoOutSrtcp = makeSrtcp(request.video.srtp_key, request.video.srtp_salt);
40142
+ videoInSrtcp = makeSrtcp(request.video.srtp_key, request.video.srtp_salt);
38349
40143
  audioOutSrtp = makeOutSrtp(request.audio.srtp_key, request.audio.srtp_salt);
38350
- audioOutSrtcp = makeOutSrtcp(request.audio.srtp_key, request.audio.srtp_salt);
40144
+ audioOutSrtcp = makeSrtcp(request.audio.srtp_key, request.audio.srtp_salt);
40145
+ audioInSrtcp = makeSrtcp(request.audio.srtp_key, request.audio.srtp_salt);
38351
40146
  } catch (err) {
38352
40147
  closeSocket(videoUdp);
38353
40148
  closeSocket(audioUdp);
@@ -38372,6 +40167,30 @@ async function prepareStream(request, sessions, bctx) {
38372
40167
  const videoSsrc = randomSsrc();
38373
40168
  const audioSsrc = randomSsrc();
38374
40169
  const session = {
40170
+ sessionId: request.sessionID,
40171
+ startedAtMs: null,
40172
+ endedAtMs: null,
40173
+ negotiated: null,
40174
+ selectedSlot: null,
40175
+ ffmpegExit: null,
40176
+ stopRequestedByController: false,
40177
+ teardownTrigger: null,
40178
+ drops: emptyDropCounters(),
40179
+ videoPacketsForwarded: 0,
40180
+ audioPacketsForwarded: 0,
40181
+ videoRtcpSrSent: 0,
40182
+ audioRtcpSrSent: 0,
40183
+ videoRtcpReceived: 0,
40184
+ audioRtcpReceived: 0,
40185
+ videoRtpReceived: 0,
40186
+ audioRtpReceived: 0,
40187
+ videoReceiverReports: emptyReceiverReportTally(),
40188
+ audioReceiverReports: emptyReceiverReportTally(),
40189
+ videoRrLoggedAt: 0,
40190
+ audioRrLoggedAt: 0,
40191
+ videoGate: null,
40192
+ audioGate: null,
40193
+ heartbeat: null,
38375
40194
  hapVideoPort: request.video.port,
38376
40195
  hapAddress: request.targetAddress,
38377
40196
  videoSrtpKey: request.video.srtp_key,
@@ -38380,6 +40199,7 @@ async function prepareStream(request, sessions, bctx) {
38380
40199
  videoLoopUdp,
38381
40200
  videoOutSrtp,
38382
40201
  videoOutSrtcp,
40202
+ videoInSrtcp,
38383
40203
  videoOutPacketCount: 0,
38384
40204
  videoOutOctetCount: 0,
38385
40205
  videoOutLastRtpTimestamp: 0,
@@ -38394,6 +40214,7 @@ async function prepareStream(request, sessions, bctx) {
38394
40214
  audioLoopUdp,
38395
40215
  audioOutSrtp,
38396
40216
  audioOutSrtcp,
40217
+ audioInSrtcp,
38397
40218
  audioSendGate: null,
38398
40219
  ipVersion,
38399
40220
  ffmpeg: null,
@@ -38416,19 +40237,33 @@ async function prepareStream(request, sessions, bctx) {
38416
40237
  audioRtcpIntervalMs: 5e3
38417
40238
  };
38418
40239
  sessions.set(request.sessionID, session);
40240
+ const tagLog = bctx.ctx.logger.withTags({ deviceId: bctx.numericDeviceId });
38419
40241
  session.videoSendGate = makeRtcpGate(videoUdp, 1e3);
38420
40242
  session.audioSendGate = makeRtcpGate(audioUdp, 1e3);
38421
- const tagLog = bctx.ctx.logger.withTags({ deviceId: bctx.numericDeviceId });
38422
- let videoPacketsForwarded = 0;
38423
- let audioPacketsForwarded = 0;
40243
+ session.videoSendGate.then((resolution) => {
40244
+ session.videoGate = resolution;
40245
+ logGateResolved(session, "video", resolution, tagLog);
40246
+ });
40247
+ session.audioSendGate.then((resolution) => {
40248
+ session.audioGate = resolution;
40249
+ logGateResolved(session, "audio", resolution, tagLog);
40250
+ });
40251
+ videoUdp.on("message", (packet) => {
40252
+ countInbound(session, "video", packet, tagLog);
40253
+ });
40254
+ audioUdp.on("message", (packet) => {
40255
+ countInbound(session, "audio", packet, tagLog);
40256
+ });
38424
40257
  videoLoopUdp.on("message", (rtpPacket) => {
38425
- videoPacketsForwarded += 1;
38426
- if (videoPacketsForwarded === 1 || videoPacketsForwarded % 500 === 0) tagLog.info("export-hap: video loopback packets forwarded", { meta: { count: videoPacketsForwarded } });
40258
+ session.videoPacketsForwarded += 1;
40259
+ if (session.videoPacketsForwarded === 1) tagLog.info("export-hap: first video packet from ffmpeg", { meta: {
40260
+ sessionId: session.sessionId,
40261
+ bytes: rtpPacket.length
40262
+ } });
38427
40263
  forwardEncryptedRtp(session, rtpPacket, "video", tagLog);
38428
40264
  });
38429
40265
  audioLoopUdp.on("message", (rtpPacket) => {
38430
- audioPacketsForwarded += 1;
38431
- if (audioPacketsForwarded === 1 || audioPacketsForwarded % 100 === 0) tagLog.info("export-hap: audio loopback packets forwarded", { meta: { count: audioPacketsForwarded } });
40266
+ session.audioPacketsForwarded += 1;
38432
40267
  forwardEncryptedRtp(session, rtpPacket, "audio", tagLog);
38433
40268
  });
38434
40269
  audioUdp.on("message", (packet, rinfo) => {
@@ -38436,6 +40271,19 @@ async function prepareStream(request, sessions, bctx) {
38436
40271
  bctx.ctx.logger.withTags({ deviceId: bctx.numericDeviceId }).debug("export-hap: incoming-audio handler error (dropped)", { meta: { error: errMsg$8(err) } });
38437
40272
  });
38438
40273
  });
40274
+ tagLog.info("export-hap: stream prepared", { meta: {
40275
+ sessionId: request.sessionID,
40276
+ controllerAddress: request.targetAddress,
40277
+ addressVersion: ipVersion,
40278
+ localIp,
40279
+ advertisedVideoPort: localVideoPort,
40280
+ advertisedAudioPort: localAudioPort,
40281
+ controllerVideoPort: request.video.port,
40282
+ controllerAudioPort: request.audio.port,
40283
+ videoSsrc,
40284
+ audioSsrc,
40285
+ upstreamAudioDecrypt: upstreamAudioSrtp !== null
40286
+ } });
38439
40287
  return {
38440
40288
  video: {
38441
40289
  port: localVideoPort,
@@ -38510,30 +40358,225 @@ function sameIpv4Subnet(a, mask, b) {
38510
40358
  async function bindLoopback(ipVersion) {
38511
40359
  return bindUdp(ipVersion, ipVersion === "ipv6" ? "::1" : "127.0.0.1");
38512
40360
  }
40361
+ /** Book a named drop. Every silent `return` on the streaming path routes here. */
40362
+ function drop(session, reason) {
40363
+ session.drops = recordDrop(session.drops, reason);
40364
+ }
38513
40365
  /**
38514
- * Build a promise that resolves as soon as ANY packet (RTP or RTCP)
38515
- * arrives on `socket`, OR `timeoutMs` elapses. The loopback reader
38516
- * awaits this before forwarding the first encrypted SRTP frame so
38517
- * iOS sees a return-path handshake before our actual stream begins.
38518
- *
38519
- * One-shot: the listener is removed on the first message OR on
38520
- * timeout — the gate then stays resolved for the rest of the session.
40366
+ * Report a gate resolution. `timeout` is the interesting one: it means we are
40367
+ * about to stream into a port the controller never touched, and it was
40368
+ * indistinguishable from a successful probe until now.
38521
40369
  */
38522
- function makeRtcpGate(socket, timeoutMs) {
38523
- return new Promise((resolve) => {
38524
- const onMessage = () => {
38525
- socket.removeListener("message", onMessage);
38526
- clearTimeout(timer);
38527
- resolve();
38528
- };
38529
- const timer = setTimeout(() => {
38530
- socket.removeListener("message", onMessage);
38531
- resolve();
38532
- }, timeoutMs);
38533
- socket.on("message", onMessage);
40370
+ function logGateResolved(session, leg, resolution, log) {
40371
+ const meta = {
40372
+ sessionId: session.sessionId,
40373
+ leg,
40374
+ reason: resolution.reason,
40375
+ waitedMs: resolution.waitedMs
40376
+ };
40377
+ if (resolution.reason === "packet") log.info("export-hap: RTCP gate opened on a REAL controller packet", { meta });
40378
+ else log.warn("export-hap: RTCP gate opened on the 1s FALLBACK — controller never probed", { meta });
40379
+ }
40380
+ /** Count and classify one packet the controller sent to a return port. */
40381
+ function countInbound(session, leg, packet, log) {
40382
+ const kind = classifyInboundPacket(packet);
40383
+ const firstRtcp = kind === "rtcp" && (leg === "video" ? session.videoRtcpReceived : session.audioRtcpReceived) === 0;
40384
+ if (leg === "video") {
40385
+ if (kind === "rtcp") session.videoRtcpReceived += 1;
40386
+ else if (kind === "rtp") session.videoRtpReceived += 1;
40387
+ } else if (kind === "rtcp") session.audioRtcpReceived += 1;
40388
+ else if (kind === "rtp") session.audioRtpReceived += 1;
40389
+ if (firstRtcp) log.info("export-hap: first inbound RTCP from controller", { meta: {
40390
+ sessionId: session.sessionId,
40391
+ leg,
40392
+ bytes: packet.length
40393
+ } });
40394
+ if (kind === "rtcp") readControllerRtcp(session, leg, packet, log);
40395
+ }
40396
+ /** Fold a decrypted RTCP datagram into the leg's tally. Immutable, per `ReceiverReportTally`. */
40397
+ function storeReceiverReports(session, leg, tally) {
40398
+ if (leg === "video") session.videoReceiverReports = tally;
40399
+ else session.audioReceiverReports = tally;
40400
+ }
40401
+ /**
40402
+ * Decrypt one inbound RTCP datagram and read the controller's Receiver Report.
40403
+ *
40404
+ * This is the datum the 2026-08-06 telemetry left missing. That round proved
40405
+ * iOS DOES send us RTCP on the video leg — 21 and 34 packets across two
40406
+ * sessions, the gate opening on a real controller packet at ~550 ms — which
40407
+ * killed the long-standing "iOS never probes us" belief. But counting packets
40408
+ * says only that the controller spoke; a Receiver Report says WHAT it said,
40409
+ * and that splits the remaining causes into two disjoint families that nothing
40410
+ * else in this system can tell apart. See `LossVerdict`.
40411
+ *
40412
+ * Runs on a UDP `message` handler, so nothing here may throw: a controller
40413
+ * that sends one deformed datagram must not take the session with it. Every
40414
+ * failure books a named drop and increments `unreadable`, because a report we
40415
+ * could not read and a report that never came must never look alike.
40416
+ */
40417
+ function readControllerRtcp(session, leg, packet, log) {
40418
+ const srtcp = leg === "video" ? session.videoInSrtcp : session.audioInSrtcp;
40419
+ const tally = leg === "video" ? session.videoReceiverReports : session.audioReceiverReports;
40420
+ if (!srtcp) {
40421
+ drop(session, "inbound-rtcp-no-srtcp");
40422
+ storeReceiverReports(session, leg, recordUnreadableRtcp(tally));
40423
+ logUnreadableRtcp(session, leg, "no-srtcp-context", log);
40424
+ return;
40425
+ }
40426
+ let plaintext;
40427
+ try {
40428
+ plaintext = srtcp.decrypt(packet);
40429
+ } catch (err) {
40430
+ drop(session, "inbound-rtcp-decrypt-failed");
40431
+ storeReceiverReports(session, leg, recordUnreadableRtcp(tally));
40432
+ logUnreadableRtcp(session, leg, `decrypt: ${errMsg$8(err)}`, log);
40433
+ return;
40434
+ }
40435
+ const outcome = ingestDecryptedRtcp(plaintext, tally);
40436
+ storeReceiverReports(session, leg, outcome.tally);
40437
+ if (outcome.failure !== null) {
40438
+ drop(session, "inbound-rtcp-parse-failed");
40439
+ logUnreadableRtcp(session, leg, `parse: ${outcome.failure}`, log);
40440
+ return;
40441
+ }
40442
+ const isFirst = tally.reportsParsed === 0 && outcome.tally.reportsParsed > 0;
40443
+ logReceiverReports(session, leg, outcome.reports, isFirst, log);
40444
+ }
40445
+ /** Throttled per leg — a controller sending nothing but garbage must not become the log. */
40446
+ function logUnreadableRtcp(session, leg, reason, log) {
40447
+ const tally = leg === "video" ? session.videoReceiverReports : session.audioReceiverReports;
40448
+ if (!shouldLogReceiverReport(session, leg)) return;
40449
+ log.warn("export-hap: inbound RTCP could not be read", { meta: {
40450
+ sessionId: session.sessionId,
40451
+ leg,
40452
+ reason,
40453
+ unreadable: tally.unreadable
40454
+ } });
40455
+ }
40456
+ /**
40457
+ * True at most once per `RECEIVER_REPORT_LOG_INTERVAL_MS` per leg. Stamps the
40458
+ * leg on the way out so the caller cannot forget to.
40459
+ */
40460
+ function shouldLogReceiverReport(session, leg) {
40461
+ const now = Date.now();
40462
+ if (now < (leg === "video" ? session.videoRrLoggedAt : session.audioRrLoggedAt) + RECEIVER_REPORT_LOG_INTERVAL_MS) return false;
40463
+ if (leg === "video") session.videoRrLoggedAt = now;
40464
+ else session.audioRrLoggedAt = now;
40465
+ return true;
40466
+ }
40467
+ /**
40468
+ * Emit the controller's own numbers.
40469
+ *
40470
+ * The FIRST report on a leg gets its own message: that one arriving at all is
40471
+ * the proof iOS is engaged with the stream, and it was worth a year of
40472
+ * argument. Everything after it is throttled to the heartbeat's cadence.
40473
+ */
40474
+ function logReceiverReports(session, leg, reports, isFirst, log) {
40475
+ const tally = leg === "video" ? session.videoReceiverReports : session.audioReceiverReports;
40476
+ const block = reports.flatMap((report) => report.blocks).at(-1);
40477
+ if (!block) return;
40478
+ const reporter = reports.find((report) => report.blocks.length > 0);
40479
+ const meta = {
40480
+ sessionId: session.sessionId,
40481
+ leg,
40482
+ reporterSsrc: reporter?.reporterSsrc ?? null,
40483
+ aboutSsrc: block.aboutSsrc,
40484
+ fractionLostPct: block.fractionLostPct,
40485
+ cumulativePacketsLost: block.cumulativePacketsLost,
40486
+ extendedHighestSequence: block.extendedHighestSequence,
40487
+ jitter: block.jitter,
40488
+ delaySinceLastSrMs: block.delaySinceLastSrMs,
40489
+ reportsParsed: tally.reportsParsed,
40490
+ worstFractionLostPct: tally.worstFractionLostPct
40491
+ };
40492
+ if (isFirst) {
40493
+ shouldLogReceiverReport(session, leg);
40494
+ log.info("export-hap: FIRST RTCP Receiver Report from controller — iOS is receiving and reporting", { meta });
40495
+ return;
40496
+ }
40497
+ if (!shouldLogReceiverReport(session, leg)) return;
40498
+ log.info("export-hap: controller receiver report", { meta });
40499
+ }
40500
+ /** Snapshot every counter into the summary meta. */
40501
+ function sessionSummaryMeta(session) {
40502
+ return summariseSession({
40503
+ sessionId: session.sessionId,
40504
+ startedAtMs: session.startedAtMs,
40505
+ endedAtMs: session.endedAtMs,
40506
+ negotiated: session.negotiated,
40507
+ selectedSlot: session.selectedSlot,
40508
+ videoPacketsForwarded: session.videoPacketsForwarded,
40509
+ audioPacketsForwarded: session.audioPacketsForwarded,
40510
+ videoRtcpSrSent: session.videoRtcpSrSent,
40511
+ audioRtcpSrSent: session.audioRtcpSrSent,
40512
+ videoRtcpReceived: session.videoRtcpReceived,
40513
+ audioRtcpReceived: session.audioRtcpReceived,
40514
+ videoRtpReceived: session.videoRtpReceived,
40515
+ audioRtpReceived: session.audioRtpReceived,
40516
+ videoGate: session.videoGate,
40517
+ audioGate: session.audioGate,
40518
+ videoReceiverReports: session.videoReceiverReports,
40519
+ audioReceiverReports: session.audioReceiverReports,
40520
+ drops: session.drops,
40521
+ ffmpegExit: session.ffmpegExit,
40522
+ stopRequestedByController: session.stopRequestedByController
38534
40523
  });
38535
40524
  }
38536
40525
  /**
40526
+ * Arm the per-session heartbeat. LOGS ONLY — see `SESSION_HEARTBEAT_MS`. It
40527
+ * exists so a session that stops producing media says so while it is still
40528
+ * alive, instead of being reconstructed after the fact from its final line.
40529
+ */
40530
+ function armHeartbeat(session, log) {
40531
+ if (session.heartbeat) clearInterval(session.heartbeat);
40532
+ let lastVideo = session.videoPacketsForwarded;
40533
+ const timer = setInterval(() => {
40534
+ const forwarded = session.videoPacketsForwarded - lastVideo;
40535
+ lastVideo = session.videoPacketsForwarded;
40536
+ log.info("export-hap: stream heartbeat", { meta: {
40537
+ ...sessionSummaryMeta(session),
40538
+ videoPacketsSinceLastBeat: forwarded,
40539
+ videoStalled: forwarded === 0
40540
+ } });
40541
+ }, SESSION_HEARTBEAT_MS);
40542
+ timer.unref();
40543
+ session.heartbeat = timer;
40544
+ }
40545
+ function stopHeartbeat(session) {
40546
+ if (!session.heartbeat) return;
40547
+ clearInterval(session.heartbeat);
40548
+ session.heartbeat = null;
40549
+ }
40550
+ /**
40551
+ * **`export-hap: stream session summary` is the line to grep** when asking why
40552
+ * a HomeKit session died. It carries the negotiated parameters, the slot we
40553
+ * dialled and the rate we had promised for it, packet counts in both
40554
+ * directions on both legs, each leg's gate outcome, ffmpeg's exit, who asked
40555
+ * for the teardown, every named drop — and, since 2026-08-06, what the
40556
+ * controller itself reported receiving.
40557
+ *
40558
+ * Read `videoLossVerdict` first. `transmit-suspect` says the controller is not
40559
+ * getting our packets intact and nothing past the wire matters;
40560
+ * `decode-suspect` says it got them and rendered nothing anyway;
40561
+ * `no-reports` says we still cannot tell, and `videoReceiverReports.unreadable`
40562
+ * then distinguishes "the controller said nothing" from "we could not read what
40563
+ * it said".
40564
+ *
40565
+ * Emitted from the ffmpeg `exit` handler — the only place the exit code is
40566
+ * known — and directly from the teardown paths when there is no ffmpeg to wait
40567
+ * for. It deliberately does NOT touch the heartbeat: iOS restarts a session
40568
+ * in place when it is not getting a picture (one on record was started three
40569
+ * times in 16 s), and stopping the heartbeat on the superseded process's exit
40570
+ * would silence the replacement.
40571
+ */
40572
+ function logSessionSummary(session, log, trigger) {
40573
+ session.endedAtMs = Date.now();
40574
+ log.info("export-hap: stream session summary", { meta: {
40575
+ ...sessionSummaryMeta(session),
40576
+ trigger
40577
+ } });
40578
+ }
40579
+ /**
38537
40580
  * Build the NTP timestamp expected in an RTCP Sender Report.
38538
40581
  *
38539
40582
  * NTP timestamps are 64-bit: upper 32 bits = seconds since 1900-01-01,
@@ -38566,7 +40609,10 @@ function ntpTime() {
38566
40609
  */
38567
40610
  function sendRtcpSr(session, kind, log) {
38568
40611
  const srtcp = kind === "video" ? session.videoOutSrtcp : session.audioOutSrtcp;
38569
- if (!srtcp) return;
40612
+ if (!srtcp) {
40613
+ drop(session, "rtcp-no-srtcp");
40614
+ return;
40615
+ }
38570
40616
  const sink = kind === "video" ? session.videoUdp : session.audioUdp;
38571
40617
  const port = kind === "video" ? session.hapVideoPort : session.hapAudioPort;
38572
40618
  try {
@@ -38581,15 +40627,31 @@ function sendRtcpSr(session, kind, log) {
38581
40627
  });
38582
40628
  const encrypted = srtcp.encrypt(sr.serialize());
38583
40629
  sink.send(encrypted, port, session.hapAddress, (err) => {
38584
- if (err) log.debug("export-hap: RTCP send error", { meta: {
38585
- kind,
38586
- error: err.message
38587
- } });
40630
+ if (err) {
40631
+ drop(session, "srtp-send-failed");
40632
+ log.warn("export-hap: RTCP send error", { meta: {
40633
+ sessionId: session.sessionId,
40634
+ kind,
40635
+ error: err.message
40636
+ } });
40637
+ }
38588
40638
  });
38589
- if (kind === "video") session.videoOutLastRtcpAt = Date.now();
38590
- else session.audioOutLastRtcpAt = Date.now();
40639
+ if (kind === "video") {
40640
+ session.videoOutLastRtcpAt = Date.now();
40641
+ session.videoRtcpSrSent += 1;
40642
+ if (session.videoRtcpSrSent === 1) log.info("export-hap: first VIDEO RTCP Sender Report sent", { meta: {
40643
+ sessionId: session.sessionId,
40644
+ rtpTimestamp: session.videoOutLastRtpTimestamp,
40645
+ intervalMs: session.videoRtcpIntervalMs
40646
+ } });
40647
+ } else {
40648
+ session.audioOutLastRtcpAt = Date.now();
40649
+ session.audioRtcpSrSent += 1;
40650
+ }
38591
40651
  } catch (err) {
38592
- log.debug("export-hap: RTCP SR build/encrypt failed", { meta: {
40652
+ drop(session, "rtcp-encrypt-failed");
40653
+ log.warn("export-hap: RTCP SR build/encrypt failed", { meta: {
40654
+ sessionId: session.sessionId,
38593
40655
  kind,
38594
40656
  error: err instanceof Error ? err.message : String(err)
38595
40657
  } });
@@ -38611,7 +40673,14 @@ function forwardEncryptedRtp(session, rtpPacket, kind, log) {
38611
40673
  const sink = kind === "video" ? session.videoUdp : session.audioUdp;
38612
40674
  const port = kind === "video" ? session.hapVideoPort : session.hapAudioPort;
38613
40675
  const gate = kind === "video" ? session.videoSendGate : session.audioSendGate;
38614
- if (!srtp || !gate) return;
40676
+ if (!srtp) {
40677
+ drop(session, "no-srtp-session");
40678
+ return;
40679
+ }
40680
+ if (!gate) {
40681
+ drop(session, "no-gate");
40682
+ return;
40683
+ }
38615
40684
  gate.then(() => {
38616
40685
  try {
38617
40686
  const parsed = import_src.RtpPacket.deSerialize(rtpPacket);
@@ -38647,32 +40716,46 @@ function forwardEncryptedRtp(session, rtpPacket, kind, log) {
38647
40716
  }
38648
40717
  const encrypted = srtp.encrypt(parsed.payload, parsed.header);
38649
40718
  sink.send(encrypted, port, session.hapAddress, (err) => {
38650
- if (err) log.debug("export-hap: SRTP send error", { meta: {
38651
- kind,
38652
- error: err.message
38653
- } });
40719
+ if (err) {
40720
+ drop(session, "srtp-send-failed");
40721
+ log.debug("export-hap: SRTP send error", { meta: {
40722
+ sessionId: session.sessionId,
40723
+ kind,
40724
+ error: err.message
40725
+ } });
40726
+ }
38654
40727
  });
38655
40728
  const now = Date.now();
38656
40729
  if (kind === "video") {
38657
40730
  if (firstVideo || now > session.videoOutLastRtcpAt + session.videoRtcpIntervalMs) sendRtcpSr(session, "video", log);
38658
40731
  } else if (firstAudio || now > session.audioOutLastRtcpAt + session.audioRtcpIntervalMs) sendRtcpSr(session, "audio", log);
38659
40732
  } catch (err) {
40733
+ drop(session, "srtp-encrypt-failed");
38660
40734
  log.debug("export-hap: SRTP encrypt failed", { meta: {
40735
+ sessionId: session.sessionId,
38661
40736
  kind,
38662
40737
  error: err instanceof Error ? err.message : String(err)
38663
40738
  } });
38664
40739
  }
38665
40740
  });
38666
40741
  }
38667
- async function handleStreamRequest(request, sessions, bctx) {
40742
+ async function handleStreamRequest(request, sessions, bctx, advertised) {
38668
40743
  const log = bctx.ctx.logger.withTags({ deviceId: bctx.numericDeviceId });
38669
40744
  const session = sessions.get(request.sessionID);
38670
40745
  if (!session) {
38671
- log.warn("export-hap: stream request for unknown session", { meta: { sessionID: request.sessionID } });
40746
+ log.warn("export-hap: stream request for unknown session", { meta: {
40747
+ sessionID: request.sessionID,
40748
+ type: request.type
40749
+ } });
38672
40750
  return;
38673
40751
  }
38674
40752
  if (request.type === "stop") {
40753
+ session.stopRequestedByController = true;
40754
+ session.teardownTrigger = "controller-stop";
40755
+ const hadFfmpeg = session.ffmpeg !== null;
38675
40756
  killFfmpeg(session, bctx.ctx, bctx.numericDeviceId);
40757
+ stopHeartbeat(session);
40758
+ if (!hadFfmpeg) logSessionSummary(session, log, "controller-stop-no-ffmpeg");
38676
40759
  await closeIntercomTalkSession(session, bctx).catch(() => void 0);
38677
40760
  closeSocket(session.videoUdp);
38678
40761
  closeSocket(session.audioUdp);
@@ -38698,6 +40781,27 @@ async function handleStreamRequest(request, sessions, bctx) {
38698
40781
  session.videoOutOctetCount = 0;
38699
40782
  session.videoOutLastRtpTimestamp = 0;
38700
40783
  session.videoOutLastRtcpAt = 0;
40784
+ session.negotiated = {
40785
+ width: request.video.width,
40786
+ height: request.video.height,
40787
+ fps: request.video.fps,
40788
+ maxBitrateKbps: request.video.max_bit_rate,
40789
+ mtu: request.video.mtu,
40790
+ videoPt: request.video.pt,
40791
+ videoSsrc: session.videoSsrc,
40792
+ videoRtcpIntervalMs: session.videoRtcpIntervalMs,
40793
+ audioPt: request.audio.pt,
40794
+ audioSsrc: session.audioOutSsrc,
40795
+ audioSampleRateKhz: request.audio.sample_rate,
40796
+ audioPacketTimeMs: packetTimeMs,
40797
+ audioRtcpIntervalMs: session.audioRtcpIntervalMs,
40798
+ audioMaxBitrateKbps: request.audio.max_bit_rate
40799
+ };
40800
+ session.startedAtMs = Date.now();
40801
+ log.info("export-hap: stream negotiated", { meta: {
40802
+ sessionId: request.sessionID,
40803
+ ...session.negotiated
40804
+ } });
38701
40805
  session.lastStartParams = {
38702
40806
  pt: request.video.pt,
38703
40807
  mtu: request.video.mtu,
@@ -38718,7 +40822,8 @@ async function handleStreamRequest(request, sessions, bctx) {
38718
40822
  sample_rate: request.audio.sample_rate,
38719
40823
  packet_time: packetTimeMs
38720
40824
  };
38721
- await startFfmpegForSession(bctx, session, request.sessionID, startParams);
40825
+ await startFfmpegForSession(bctx, session, request.sessionID, startParams, advertised);
40826
+ armHeartbeat(session, log);
38722
40827
  return;
38723
40828
  }
38724
40829
  if (request.type === "reconfigure") {
@@ -38745,147 +40850,266 @@ async function handleStreamRequest(request, sessions, bctx) {
38745
40850
  sample_rate: session.lastStartParams.audioSampleRateEnum,
38746
40851
  packet_time: session.lastStartParams.audioPacketTimeMs
38747
40852
  };
38748
- await startFfmpegForSession(bctx, session, request.sessionID, startParams);
40853
+ log.info("export-hap: stream reconfigured", { meta: {
40854
+ sessionId: request.sessionID,
40855
+ width: request.video.width,
40856
+ height: request.video.height,
40857
+ fps: request.video.fps,
40858
+ maxBitrateKbps: request.video.max_bit_rate
40859
+ } });
40860
+ await startFfmpegForSession(bctx, session, request.sessionID, startParams, advertised);
40861
+ armHeartbeat(session, log);
38749
40862
  }
38750
40863
  }
38751
- async function startFfmpegForSession(bctx, session, sessionId, video) {
40864
+ async function startFfmpegForSession(bctx, session, sessionId, video, advertised) {
38752
40865
  const { ctx, proxy, numericDeviceId, options } = bctx;
40866
+ const startLog = ctx.logger.withTags({ deviceId: numericDeviceId });
38753
40867
  const entries = await proxy.cameraStreams?.getProfileRtspEntries({}) ?? [];
38754
- if (entries.length === 0) throw new Error(`export-hap: no profile RTSP entries for device ${numericDeviceId}`);
40868
+ if (entries.length === 0) {
40869
+ startLog.warn("export-hap: stream start DROPPED — device publishes no profile RTSP entries", { meta: {
40870
+ sessionId,
40871
+ capBound: proxy.cameraStreams !== void 0
40872
+ } });
40873
+ throw new Error(`export-hap: no profile RTSP entries for device ${numericDeviceId}`);
40874
+ }
38755
40875
  const pref = options.hapDeviceSettings.streamPreference;
38756
- const picked = pickPreferredRtspEntry(entries, pref, numericDeviceId, { targetResolution: {
38757
- width: video.width,
38758
- height: video.height
38759
- } });
38760
- if (!picked) throw new Error(`export-hap: no enabled RTSP entries for device ${numericDeviceId}`);
40876
+ const brokerStreams = await proxy.cameraStreams?.getBrokerStreams({}) ?? [];
40877
+ const bitrates = await probeProfileBitrates({
40878
+ bctx,
40879
+ slots: brokerStreams,
40880
+ log: startLog
40881
+ });
40882
+ const fit = selectStreamForBudget({
40883
+ entries: withSlotCodecs(entries, brokerStreams),
40884
+ deviceId: numericDeviceId,
40885
+ pref,
40886
+ targetResolution: {
40887
+ width: video.width,
40888
+ height: video.height
40889
+ },
40890
+ negotiatedMaxBitrateKbps: video.max_bit_rate,
40891
+ bitrates
40892
+ });
40893
+ if (fit === null) {
40894
+ startLog.warn("export-hap: stream start DROPPED — no ENABLED profile RTSP entry", { meta: {
40895
+ sessionId,
40896
+ streamPreference: pref,
40897
+ targetResolution: `${video.width}x${video.height}`,
40898
+ entries: entries.map((e) => `${e.profile}:${e.enabled ? "enabled" : "disabled"}`)
40899
+ } });
40900
+ throw new Error(`export-hap: no enabled RTSP entries for device ${numericDeviceId}`);
40901
+ }
40902
+ const picked = fit.picked;
38761
40903
  const rtspUrl = picked.url;
38762
- const slot = (await proxy.cameraStreams?.getBrokerStreams({}) ?? []).find((s) => s.brokerId === picked.brokerId);
40904
+ const slot = brokerStreams.find((s) => s.profile === fit.profile);
38763
40905
  const codec = (picked.codec ?? slot?.codec ?? "").toLowerCase();
38764
- const needsTranscode = codec.includes("h265") || codec.includes("hevc");
40906
+ const needsTranscode = fit.kind === "transcode";
40907
+ const pickedProfile = toKnownProfile(picked.profileId);
40908
+ const resolvedFps = pickedProfile === null ? void 0 : advertised.fpsByProfile.get(pickedProfile);
40909
+ const advertisedFps = resolvedFps?.fps ?? video.fps;
40910
+ const advertisedFpsSource = resolvedFps?.source ?? "assumed";
40911
+ const deliveredFps = needsTranscode ? deliverableFps(video.fps, resolvedFps?.fps ?? null) : advertisedFps;
40912
+ const slotEvidence = pickedProfile === null ? void 0 : bitrates.get(pickedProfile);
40913
+ const fitNotes = formatFitNotes(fit.notes);
40914
+ session.selectedSlot = {
40915
+ profile: pickedProfile,
40916
+ brokerId: picked.brokerId,
40917
+ width: picked.resolution?.width ?? null,
40918
+ height: picked.resolution?.height ?? null,
40919
+ advertisedFps,
40920
+ advertisedFpsSource,
40921
+ deliveredFps,
40922
+ codec: codec.length > 0 ? codec : "unknown",
40923
+ transcode: needsTranscode,
40924
+ fitReason: fit.reason,
40925
+ publishedKbps: slotEvidence?.publishedKbps ?? null,
40926
+ measuredKbps: slotEvidence?.measuredKbps ?? null,
40927
+ budgetKbps: fit.budgetKbps,
40928
+ fitNotes
40929
+ };
40930
+ startLog.info("export-hap: stream bitrate fit resolved", { meta: {
40931
+ sessionId,
40932
+ decision: fit.kind,
40933
+ reason: fit.reason,
40934
+ negotiatedMaxBitrateKbps: video.max_bit_rate,
40935
+ encodeBudgetKbps: fit.budgetKbps,
40936
+ profile: pickedProfile,
40937
+ brokerId: picked.brokerId,
40938
+ slotPublishedKbps: slotEvidence?.publishedKbps ?? null,
40939
+ slotMeasuredKbps: slotEvidence?.measuredKbps ?? null,
40940
+ candidates: fitNotes
40941
+ } });
40942
+ if (deliveredFps !== video.fps) startLog.warn("export-hap: negotiated fps does NOT match the rate we are about to deliver", { meta: {
40943
+ sessionId,
40944
+ negotiatedFps: video.fps,
40945
+ slotFps: advertisedFps,
40946
+ slotFpsSource: advertisedFpsSource,
40947
+ deliveredFps,
40948
+ profile: pickedProfile,
40949
+ brokerId: picked.brokerId,
40950
+ transcode: needsTranscode
40951
+ } });
38765
40952
  const videoLoopPort = session.videoLoopUdp.address().port;
38766
40953
  const audioLoopPort = session.audioLoopUdp.address().port;
38767
40954
  const videoTarget = `rtp://127.0.0.1:${videoLoopPort}?pkt_size=${video.mtu}`;
38768
40955
  const audioTarget = `rtp://127.0.0.1:${audioLoopPort}?pkt_size=${video.mtu}`;
38769
- const videoArgs = needsTranscode ? [
38770
- "-c:v",
38771
- "libx264",
38772
- "-preset",
38773
- "ultrafast",
38774
- "-tune",
38775
- "zerolatency",
38776
- "-pix_fmt",
38777
- "yuv420p",
38778
- "-r",
38779
- String(video.fps),
38780
- "-s",
38781
- `${video.width}x${video.height}`,
38782
- "-b:v",
38783
- `${video.max_bit_rate}k`,
38784
- "-bufsize",
38785
- `${video.max_bit_rate * 2}k`,
38786
- "-maxrate",
38787
- `${video.max_bit_rate}k`,
38788
- "-profile:v",
38789
- "baseline",
38790
- "-level",
38791
- "3.1"
38792
- ] : [
38793
- "-c:v",
38794
- "copy",
38795
- "-bsf:v",
38796
- "dump_extra"
38797
- ];
40956
+ const videoArgs = buildVideoEncodeArgs({
40957
+ transcode: needsTranscode,
40958
+ width: video.width,
40959
+ height: video.height,
40960
+ fps: deliveredFps,
40961
+ budgetKbps: fit.budgetKbps
40962
+ });
40963
+ const hwDecode = selectHwDecode({
40964
+ transcode: needsTranscode,
40965
+ reading: needsTranscode ? await probeDecoderHwaccel({
40966
+ ctx,
40967
+ log: startLog
40968
+ }) : null,
40969
+ platform: process.platform
40970
+ });
40971
+ logDecodePath(startLog, sessionId, hwDecode, needsTranscode);
38798
40972
  const videoSsrcSigned = session.videoSsrc | 0;
38799
40973
  const audioSsrcSigned = video.audio_ssrc | 0;
38800
- const args = [
38801
- "-hide_banner",
38802
- "-loglevel",
38803
- "warning",
38804
- "-rtsp_transport",
38805
- "tcp",
38806
- "-i",
40974
+ const buildArgs = (decodeArgs) => buildSessionFfmpegArgs({
40975
+ decodeArgs,
38807
40976
  rtspUrl,
38808
- "-an",
38809
- "-map",
38810
- "0:v:0",
38811
- ...videoArgs,
38812
- "-payload_type",
38813
- String(video.pt),
38814
- "-ssrc",
38815
- String(videoSsrcSigned),
38816
- "-f",
38817
- "rtp",
40977
+ videoArgs,
38818
40978
  videoTarget,
38819
- "-vn",
38820
- "-map",
38821
- "0:a:0?",
38822
- "-af",
38823
- "aresample=async=1000:first_pts=0",
38824
- "-c:a",
38825
- "libopus",
38826
- "-application",
38827
- "lowdelay",
38828
- "-frame_duration",
38829
- String(video.packet_time ?? 20),
38830
- "-flags",
38831
- "+global_header",
38832
- "-ar",
38833
- String((video.sample_rate ?? 16) * 1e3),
38834
- "-b:a",
38835
- `${OPUS_BITRATE_KBPS}k`,
38836
- "-bufsize",
38837
- `${OPUS_BITRATE_KBPS * 4}k`,
38838
- "-ac",
38839
- String(OPUS_CHANNELS),
38840
- "-payload_type",
38841
- String(video.audio_pt),
38842
- "-ssrc",
38843
- String(audioSsrcSigned),
38844
- "-f",
38845
- "rtp",
38846
- audioTarget
38847
- ];
38848
- const log = ctx.logger.withTags({ deviceId: numericDeviceId });
38849
- const proc = (0, node_child_process.spawn)("ffmpeg", args, { stdio: [
38850
- "ignore",
38851
- "ignore",
38852
- "pipe"
38853
- ] });
38854
- session.ffmpeg = proc;
38855
- proc.stderr?.on("data", (chunk) => {
38856
- const line = chunk.toString("utf8").trim();
38857
- if (!line) return;
38858
- log.info("export-hap: ffmpeg", { meta: {
38859
- sessionId,
38860
- line
38861
- } });
40979
+ audioTarget,
40980
+ videoPayloadType: video.pt,
40981
+ videoSsrcSigned,
40982
+ audioPayloadType: video.audio_pt,
40983
+ audioSsrcSigned,
40984
+ audioPacketTimeMs: video.packet_time ?? 20,
40985
+ audioSampleRateKhz: video.sample_rate ?? 16
38862
40986
  });
38863
- proc.once("exit", (code, signal) => {
40987
+ const log = ctx.logger.withTags({ deviceId: numericDeviceId });
40988
+ let hardwareAlreadyFailed = false;
40989
+ const spawnFfmpeg = (decision) => {
40990
+ const spawnedAtMs = Date.now();
40991
+ const usedHardware = decision.kind === "hardware";
40992
+ const proc = (0, node_child_process.spawn)("ffmpeg", buildArgs(decision.args), { stdio: [
40993
+ "ignore",
40994
+ "ignore",
40995
+ "pipe"
40996
+ ] });
40997
+ session.ffmpeg = proc;
40998
+ proc.stderr?.on("data", (chunk) => {
40999
+ const line = chunk.toString("utf8").trim();
41000
+ if (!line) return;
41001
+ log.info("export-hap: ffmpeg", { meta: {
41002
+ sessionId,
41003
+ line
41004
+ } });
41005
+ });
41006
+ proc.once("exit", (code, signal) => {
41007
+ if (shouldRetryInSoftware({
41008
+ usedHardware,
41009
+ hardwareAlreadyFailed,
41010
+ stopRequestedByController: session.stopRequestedByController,
41011
+ videoPacketsForwarded: session.videoPacketsForwarded,
41012
+ runtimeMs: Date.now() - spawnedAtMs
41013
+ })) {
41014
+ hardwareAlreadyFailed = true;
41015
+ log.warn("export-hap: hardware decode FAILED at init — respawning ffmpeg in software", { meta: {
41016
+ sessionId,
41017
+ backend: decision.kind === "hardware" ? decision.backend : null,
41018
+ backendSource: decision.kind === "hardware" ? decision.source : null,
41019
+ code,
41020
+ signal,
41021
+ runtimeMs: Date.now() - spawnedAtMs
41022
+ } });
41023
+ if (session.ffmpeg === proc) session.ffmpeg = null;
41024
+ spawnFfmpeg({
41025
+ kind: "software",
41026
+ reason: "hardware-attempt-failed",
41027
+ args: []
41028
+ });
41029
+ return;
41030
+ }
41031
+ onFfmpegExit(proc, code, signal);
41032
+ });
41033
+ proc.once("error", (err) => {
41034
+ log.warn("export-hap: ffmpeg spawn failed", { meta: {
41035
+ sessionId,
41036
+ error: err.message
41037
+ } });
41038
+ });
41039
+ };
41040
+ const onFfmpegExit = (proc, code, signal) => {
41041
+ session.ffmpegExit = {
41042
+ code,
41043
+ signal
41044
+ };
38864
41045
  const ok = code === 0 && signal === null;
38865
41046
  const meta = {
38866
41047
  sessionId,
38867
41048
  code,
38868
- signal
41049
+ signal,
41050
+ stopRequestedByController: session.stopRequestedByController,
41051
+ videoPacketsForwarded: session.videoPacketsForwarded
38869
41052
  };
38870
- if (ok) log.info("export-hap: ffmpeg exited", { meta });
38871
- else log.warn("export-hap: ffmpeg exited abnormally", { meta });
41053
+ if (ok || session.stopRequestedByController) log.info("export-hap: ffmpeg exited", { meta });
41054
+ else log.warn("export-hap: ffmpeg exited without a controller stop", { meta });
38872
41055
  if (session.ffmpeg === proc) session.ffmpeg = null;
38873
- });
38874
- proc.once("error", (err) => {
38875
- log.warn("export-hap: ffmpeg spawn failed", { meta: {
38876
- sessionId,
38877
- error: err.message
38878
- } });
38879
- });
41056
+ logSessionSummary(session, log, session.teardownTrigger ?? "ffmpeg-exit");
41057
+ };
41058
+ spawnFfmpeg(hwDecode);
38880
41059
  log.info("export-hap: stream started", { meta: {
38881
41060
  sessionId,
38882
41061
  transcode: needsTranscode,
38883
- rtspUrl: picked.brokerId,
41062
+ brokerId: picked.brokerId,
41063
+ profile: pickedProfile,
41064
+ sourceResolution: picked.resolution === void 0 ? null : `${picked.resolution.width}x${picked.resolution.height}`,
41065
+ sourceCodec: codec.length > 0 ? codec : "unknown",
41066
+ negotiated: `${video.width}x${video.height}@${video.fps} ${video.max_bit_rate}kbps`,
41067
+ slotFps: advertisedFps,
41068
+ slotFpsSource: advertisedFpsSource,
41069
+ deliveredFps,
41070
+ fitReason: fit.reason,
41071
+ encodeBudgetKbps: fit.budgetKbps,
38884
41072
  audioCodec: "opus",
38885
- audioBitrateKbps: OPUS_BITRATE_KBPS
41073
+ audioBitrateKbps: 24,
41074
+ videoDecode: hwDecode.kind === "hardware" ? hwDecode.backend : "software"
41075
+ } });
41076
+ }
41077
+ /**
41078
+ * Say which decode path was resolved, and WHY, once per session.
41079
+ *
41080
+ * At `info` on purpose — Loki carries `info+`, and "did this session use the
41081
+ * GPU" is the first question anyone asks of a transcoding hub. The software
41082
+ * branch is the one that must never be silent: it is a real cost being paid,
41083
+ * and every reason it can be reached is a different fix.
41084
+ */
41085
+ function logDecodePath(log, sessionId, decision, transcode) {
41086
+ if (decision.kind === "hardware") {
41087
+ log.info("export-hap: video decode path resolved — HARDWARE", { meta: {
41088
+ sessionId,
41089
+ backend: decision.backend,
41090
+ backendSource: decision.source,
41091
+ decodeArgs: decision.args
41092
+ } });
41093
+ return;
41094
+ }
41095
+ const level = transcode ? "warn" : "info";
41096
+ const message = level === "warn" ? "export-hap: video decode path resolved — SOFTWARE, this transcode costs a core" : "export-hap: video decode path resolved — none needed";
41097
+ log[level](message, { meta: {
41098
+ sessionId,
41099
+ reason: decision.reason,
41100
+ transcode
38886
41101
  } });
38887
41102
  }
38888
41103
  /**
41104
+ * `PickedStream.profileId` is the brokerId suffix; for a profile-keyed entry
41105
+ * that IS the profile name. Anything else addresses a raw cam-stream and must
41106
+ * not be coerced into a profile.
41107
+ */
41108
+ function toKnownProfile(profileId) {
41109
+ if (profileId === "high" || profileId === "mid" || profileId === "low") return profileId;
41110
+ return null;
41111
+ }
41112
+ /**
38889
41113
  * Handle one incoming SRTP audio packet from iOS Home.
38890
41114
  *
38891
41115
  * End-to-end flow:
@@ -38906,15 +41130,22 @@ async function startFfmpegForSession(bctx, session, sessionId, video) {
38906
41130
  * stays codec-agnostic.
38907
41131
  */
38908
41132
  async function handleIncomingAudioRtp(session, packet, sourceAddress, bctx) {
38909
- if (packet.length < 12) return;
41133
+ if (packet.length < 12) {
41134
+ drop(session, "upstream-short-packet");
41135
+ return;
41136
+ }
38910
41137
  const log = bctx.ctx.logger.withTags({ deviceId: bctx.numericDeviceId });
38911
41138
  session.upstreamRtpPacketsReceived += 1;
38912
- if (!session.upstreamAudioSrtp) return;
41139
+ if (!session.upstreamAudioSrtp) {
41140
+ drop(session, "upstream-no-srtp");
41141
+ return;
41142
+ }
38913
41143
  let decryptedPacket;
38914
41144
  try {
38915
41145
  decryptedPacket = session.upstreamAudioSrtp.decrypt(packet);
38916
41146
  session.upstreamRtpPacketsDecrypted += 1;
38917
41147
  } catch (err) {
41148
+ drop(session, "upstream-decrypt-failed");
38918
41149
  session.upstreamRtpDecryptFailures += 1;
38919
41150
  if (session.upstreamRtpDecryptFailures % 100 === 1) log.debug("export-hap: SRTP decrypt failed (rate-limited)", { meta: {
38920
41151
  failures: session.upstreamRtpDecryptFailures,
@@ -38931,18 +41162,28 @@ async function handleIncomingAudioRtp(session, packet, sourceAddress, bctx) {
38931
41162
  rtpTimestamp = parsedRtp.header.timestamp;
38932
41163
  rtpPayloadType = parsedRtp.header.payloadType;
38933
41164
  } catch (err) {
41165
+ drop(session, "upstream-parse-failed");
38934
41166
  log.debug("export-hap: RTP parse failed after decrypt", { meta: { error: errMsg$8(err) } });
38935
41167
  return;
38936
41168
  }
38937
41169
  const negotiatedAudioPt = session.lastStartParams?.audioPt;
38938
- if (negotiatedAudioPt === void 0) return;
38939
- if (rtpPayloadType !== negotiatedAudioPt) return;
41170
+ if (negotiatedAudioPt === void 0) {
41171
+ drop(session, "upstream-before-start");
41172
+ return;
41173
+ }
41174
+ if (rtpPayloadType !== negotiatedAudioPt) {
41175
+ drop(session, "upstream-pt-mismatch");
41176
+ return;
41177
+ }
38940
41178
  if (session.firstUpstreamRtpTimestamp === null) {
38941
41179
  session.firstUpstreamRtpTimestamp = rtpTimestamp;
38942
41180
  log.info("export-hap: first upstream RTP packet received");
38943
41181
  }
38944
41182
  const payload = decryptedPayload;
38945
- if (payload.length === 0) return;
41183
+ if (payload.length === 0) {
41184
+ drop(session, "upstream-empty-payload");
41185
+ return;
41186
+ }
38946
41187
  if (session.intercomTalkSessionId === null) {
38947
41188
  session.intercomTalkSessionId = "";
38948
41189
  const opened = await openIntercomTalkSession(bctx).catch((err) => {
@@ -38954,7 +41195,10 @@ async function handleIncomingAudioRtp(session, packet, sourceAddress, bctx) {
38954
41195
  log.info("export-hap: intercom talk session opened", { meta: { sessionId: opened.sessionId } });
38955
41196
  }
38956
41197
  }
38957
- if (!session.intercomTalkSessionId) return;
41198
+ if (!session.intercomTalkSessionId) {
41199
+ drop(session, "upstream-no-talk-session");
41200
+ return;
41201
+ }
38958
41202
  const opusSampleRateHz = (session.lastStartParams?.audioSampleRateEnum ?? 16) * 1e3;
38959
41203
  session.intercomPcmSequence += 1;
38960
41204
  session.upstreamPcmFramesDecoded += 1;
@@ -38967,6 +41211,7 @@ async function handleIncomingAudioRtp(session, packet, sourceAddress, bctx) {
38967
41211
  sequenceNumber: session.intercomPcmSequence
38968
41212
  });
38969
41213
  } catch (err) {
41214
+ drop(session, "upstream-push-failed");
38970
41215
  log.debug("export-hap: intercom.pushTalkAudio failed (will re-open on next frame)", { meta: {
38971
41216
  sequenceNumber: session.intercomPcmSequence,
38972
41217
  error: errMsg$8(err)
@@ -39050,140 +41295,6 @@ function errMsg$7(err) {
39050
41295
  return err instanceof Error ? err.message : String(err);
39051
41296
  }
39052
41297
  //#endregion
39053
- //#region src/mappers/builders/hksv.ts
39054
- /**
39055
- * HomeKit Secure Video (HKSV) — stub recording delegate.
39056
- *
39057
- * Wires the camera as HKSV-capable so iOS Home shows the "Activity"
39058
- * tab, the per-camera recording settings UI ("Stream and Allow
39059
- * Recording" / "Off"), and notifications. The delegate stub:
39060
- * - logs every protocol callback (active toggle, config change,
39061
- * stream request, close);
39062
- * - returns from `handleRecordingStreamRequest` immediately without
39063
- * yielding any fragment — iOS sees "no recording available" but
39064
- * the camera otherwise behaves like an HKSV camera in the UI.
39065
- *
39066
- * Why a stub: a real implementation needs an fMP4 segmenter that runs
39067
- * a per-camera ffmpeg with a sustained pre-buffer, key/IV-derived
39068
- * encrypter, motion-anchored fragment alignment, and storage for the
39069
- * resulting clips. That work belongs behind the `recording` cap
39070
- * (recording + playback-manifest surface) once it grows fragment export,
39071
- * so the segmenter lives in the recorder addon and HKSV becomes a thin
39072
- * wrapper that asks the cap for "fragments since T" and streams them to iOS.
39073
- *
39074
- * Until that cap lands this stub keeps HKSV characteristics visible
39075
- * (a) so the operator can flip "Allow Recording" without HomeKit
39076
- * complaining the camera lacks the service, and (b) so the
39077
- * `CameraController` advertises a `RecordingManagement` service for
39078
- * iOS-side analytics + Activity-tab UX scaffolding.
39079
- *
39080
- * Constructor returns a `{ options, delegate }` pair the caller
39081
- * passes verbatim into `new CameraController({ ..., recording })`.
39082
- */
39083
- var RECORDING_OPTIONS = {
39084
- prebufferLength: 4e3,
39085
- mediaContainerConfiguration: [{
39086
- type: _homebridge_hap_nodejs.MediaContainerType.FRAGMENTED_MP4,
39087
- fragmentLength: 4e3
39088
- }],
39089
- video: {
39090
- type: _homebridge_hap_nodejs.VideoCodecType.H264,
39091
- parameters: {
39092
- profiles: [
39093
- _homebridge_hap_nodejs.H264Profile.BASELINE,
39094
- _homebridge_hap_nodejs.H264Profile.MAIN,
39095
- _homebridge_hap_nodejs.H264Profile.HIGH
39096
- ],
39097
- levels: [
39098
- _homebridge_hap_nodejs.H264Level.LEVEL3_1,
39099
- _homebridge_hap_nodejs.H264Level.LEVEL3_2,
39100
- _homebridge_hap_nodejs.H264Level.LEVEL4_0
39101
- ]
39102
- },
39103
- resolutions: [
39104
- [
39105
- 1920,
39106
- 1080,
39107
- 30
39108
- ],
39109
- [
39110
- 1920,
39111
- 1080,
39112
- 24
39113
- ],
39114
- [
39115
- 1920,
39116
- 1080,
39117
- 15
39118
- ],
39119
- [
39120
- 1280,
39121
- 720,
39122
- 30
39123
- ],
39124
- [
39125
- 1280,
39126
- 720,
39127
- 24
39128
- ],
39129
- [
39130
- 1280,
39131
- 720,
39132
- 15
39133
- ]
39134
- ]
39135
- },
39136
- audio: { codecs: [{
39137
- type: _homebridge_hap_nodejs.AudioRecordingCodecType.AAC_LC,
39138
- bitrateMode: _homebridge_hap_nodejs.AudioBitrate.VARIABLE,
39139
- audioChannels: 1,
39140
- samplerate: [_homebridge_hap_nodejs.AudioRecordingSamplerate.KHZ_16, _homebridge_hap_nodejs.AudioRecordingSamplerate.KHZ_24]
39141
- }] },
39142
- overrideEventTriggerOptions: [_homebridge_hap_nodejs.EventTriggerOption.MOTION]
39143
- };
39144
- function buildHksvStub(bctx) {
39145
- const { ctx, numericDeviceId } = bctx;
39146
- const log = ctx.logger.withTags({ deviceId: numericDeviceId });
39147
- let activeStreamId = null;
39148
- return {
39149
- options: RECORDING_OPTIONS,
39150
- delegate: {
39151
- updateRecordingActive(active) {
39152
- log.info("export-hap: HKSV recording active changed", { meta: { active } });
39153
- },
39154
- updateRecordingConfiguration(configuration) {
39155
- if (!configuration) {
39156
- log.info("export-hap: HKSV recording configuration cleared");
39157
- return;
39158
- }
39159
- log.info("export-hap: HKSV recording configuration applied", { meta: {
39160
- prebufferLength: configuration.prebufferLength,
39161
- fragmentLength: configuration.mediaContainerConfiguration.fragmentLength,
39162
- videoResolution: `${configuration.videoCodec.resolution[0]}x${configuration.videoCodec.resolution[1]}@${configuration.videoCodec.resolution[2]}`,
39163
- videoBitrate: configuration.videoCodec.parameters.bitRate,
39164
- eventTriggers: configuration.eventTriggerTypes
39165
- } });
39166
- },
39167
- async *handleRecordingStreamRequest(streamId, _signal) {
39168
- activeStreamId = streamId;
39169
- log.warn("export-hap: HKSV stream requested — stub returns empty (no clip source yet)", { meta: { streamId } });
39170
- },
39171
- acknowledgeStream(streamId) {
39172
- if (activeStreamId === streamId) activeStreamId = null;
39173
- log.debug("export-hap: HKSV stream acknowledged", { meta: { streamId } });
39174
- },
39175
- closeRecordingStream(streamId, reason) {
39176
- if (activeStreamId === streamId) activeStreamId = null;
39177
- log.info("export-hap: HKSV stream closed", { meta: {
39178
- streamId,
39179
- reason: reason ?? null
39180
- } });
39181
- }
39182
- },
39183
- handle: { async dispose() {} }
39184
- };
39185
- }
39186
- //#endregion
39187
41298
  //#region src/mappers/builders/intercom.ts
39188
41299
  async function buildIntercom(input) {
39189
41300
  const { bctx, streamingOptions } = input;
@@ -39211,7 +41322,7 @@ async function buildIntercom(input) {
39211
41322
  var RESET_DEBOUNCE_MS = 5e3;
39212
41323
  async function buildMotionSensor(bctx) {
39213
41324
  const { ctx, accessory, proxy, numericDeviceId, displayName } = bctx;
39214
- const motionService = accessory.addService(_homebridge_hap_nodejs.Service.MotionSensor, displayName);
41325
+ const motionService = accessory.addService(_homebridge_hap_nodejs.Service.MotionSensor, hapServiceName([displayName], `Camera ${numericDeviceId}`));
39215
41326
  motionService.setCharacteristic(_homebridge_hap_nodejs.Characteristic.MotionDetected, false);
39216
41327
  try {
39217
41328
  const detected = await proxy.motion?.isDetected({});
@@ -39266,12 +41377,12 @@ function errMsg$6(err) {
39266
41377
  * camera-enabled switch — distinct from privacy-mask).
39267
41378
  */
39268
41379
  async function buildPrivacySwitch(bctx) {
39269
- const { ctx, accessory, proxy, numericDeviceId } = bctx;
41380
+ const { ctx, accessory, proxy, numericDeviceId, displayName } = bctx;
39270
41381
  const log = ctx.logger.withTags({ deviceId: numericDeviceId });
39271
41382
  const subtype = "privacy-mask";
39272
- const configuredName = "Privacy";
39273
- const service = accessory.addService(_homebridge_hap_nodejs.Service.Switch, configuredName, subtype);
39274
- service.setCharacteristic(_homebridge_hap_nodejs.Characteristic.ConfiguredName, configuredName);
41383
+ const serviceName = privacyServiceName(displayName);
41384
+ const service = accessory.addService(_homebridge_hap_nodejs.Service.Switch, serviceName, subtype);
41385
+ service.setCharacteristic(_homebridge_hap_nodejs.Characteristic.Name, serviceName);
39275
41386
  try {
39276
41387
  const status = await proxy.privacyMask?.getStatus({});
39277
41388
  if (status && typeof status.enabled === "boolean") service.updateCharacteristic(_homebridge_hap_nodejs.Characteristic.On, status.enabled);
@@ -39361,18 +41472,23 @@ function ptzPresetLabel(presetName) {
39361
41472
  * `proxy.ptzAutotrack.setEnabled({enabled})`. Initial value is
39362
41473
  * hydrated from `getStatus({})`.
39363
41474
  *
39364
- * Naming: each switch uses a BARE per-action label ("Preset stanza",
39365
- * "Pan Left", "Autotrack") set as BOTH the service name AND its
39366
- * `ConfiguredName`, mirroring `child-switch.ts` / `privacy-switch.ts`.
39367
- * iOS Home renders sibling services on an accessory by their
39368
- * `ConfiguredName`. The old `${displayName} — <action>` form (em-dash
39369
- * U+2014 + redundant camera prefix) was rejected by HAP-NodeJS as an
39370
- * invalid `Name` characteristic, so iOS discarded it and showed generic
39371
- * "Interruttore N".
41475
+ * Naming: `<camera> <action>` "Videocamera ingresso Preset stanza" — built
41476
+ * by `ptzServiceName` and written to the `Name` characteristic.
41477
+ *
41478
+ * TWO separate naming defects have been through this file, and both are fixed
41479
+ * by that one line:
41480
+ * - `${displayName} <action>` embedded an em-dash (U+2014), which is
41481
+ * outside Apple's permitted set. hap-nodejs' `checkName` warned, iOS
41482
+ * discarded the name and showed "Interruttore N". A previous round dropped
41483
+ * the camera prefix along with the em-dash; only the em-dash was the fault.
41484
+ * - The bare label that replaced it was then written to `ConfiguredName`,
41485
+ * which `Service.Switch` does not list, so hap-nodejs rejected the
41486
+ * characteristic outright — SIX rejections per PTZ camera per build, never
41487
+ * reported because only the two switches on the non-PTZ camera were noticed.
39372
41488
  */
39373
41489
  var MOMENTARY_RESET_MS = 1e3;
39374
41490
  async function buildPtz(bctx) {
39375
- const { ctx, accessory, proxy, numericDeviceId, options } = bctx;
41491
+ const { ctx, accessory, proxy, numericDeviceId, displayName, options } = bctx;
39376
41492
  const log = ctx.logger.withTags({ deviceId: numericDeviceId });
39377
41493
  const timers = /* @__PURE__ */ new Set();
39378
41494
  const armReset = (cb, delay) => {
@@ -39384,10 +41500,10 @@ async function buildPtz(bctx) {
39384
41500
  };
39385
41501
  const presets = await readPresets(bctx);
39386
41502
  for (const preset of presets) {
39387
- const label = ptzPresetLabel(preset.name);
41503
+ const label = ptzServiceName(displayName, ptzPresetLabel(preset.name));
39388
41504
  const subtype = `ptz-preset-${preset.id}`;
39389
41505
  const service = accessory.addService(_homebridge_hap_nodejs.Service.Switch, label, subtype);
39390
- service.setCharacteristic(_homebridge_hap_nodejs.Characteristic.ConfiguredName, label);
41506
+ service.setCharacteristic(_homebridge_hap_nodejs.Characteristic.Name, label);
39391
41507
  service.getCharacteristic(_homebridge_hap_nodejs.Characteristic.On).onSet(async (value) => {
39392
41508
  if (value !== true) return;
39393
41509
  try {
@@ -39402,9 +41518,9 @@ async function buildPtz(bctx) {
39402
41518
  });
39403
41519
  }
39404
41520
  if (proxy.ptz) for (const dir of PTZ_DIRECTIONS) {
39405
- const label = dir.label;
41521
+ const label = ptzServiceName(displayName, dir.label);
39406
41522
  const service = accessory.addService(_homebridge_hap_nodejs.Service.Switch, label, dir.subtype);
39407
- service.setCharacteristic(_homebridge_hap_nodejs.Characteristic.ConfiguredName, label);
41523
+ service.setCharacteristic(_homebridge_hap_nodejs.Characteristic.Name, label);
39408
41524
  service.getCharacteristic(_homebridge_hap_nodejs.Characteristic.On).onSet(async (value) => {
39409
41525
  if (value !== true) return;
39410
41526
  try {
@@ -39448,12 +41564,12 @@ async function readPresets(bctx) {
39448
41564
  }
39449
41565
  }
39450
41566
  async function tryBuildAutotrack(bctx) {
39451
- const { ctx, accessory, proxy, numericDeviceId } = bctx;
41567
+ const { ctx, accessory, proxy, numericDeviceId, displayName } = bctx;
39452
41568
  if (!proxy.ptzAutotrack) return { async dispose() {} };
39453
41569
  const log = ctx.logger.withTags({ deviceId: numericDeviceId });
39454
- const label = PTZ_AUTOTRACK_LABEL;
41570
+ const label = ptzServiceName(displayName, PTZ_AUTOTRACK_LABEL);
39455
41571
  const service = accessory.addService(_homebridge_hap_nodejs.Service.Switch, label, "ptz-autotrack");
39456
- service.setCharacteristic(_homebridge_hap_nodejs.Characteristic.ConfiguredName, label);
41572
+ service.setCharacteristic(_homebridge_hap_nodejs.Characteristic.Name, label);
39457
41573
  try {
39458
41574
  const status = await proxy.ptzAutotrack.getStatus({});
39459
41575
  if (status && typeof status.enabled === "boolean") service.updateCharacteristic(_homebridge_hap_nodejs.Characteristic.On, status.enabled);
@@ -39477,6 +41593,65 @@ function errMsg$4(err) {
39477
41593
  return err instanceof Error ? err.message : String(err);
39478
41594
  }
39479
41595
  //#endregion
41596
+ //#region src/mappers/builders/stream-fps-probe.ts
41597
+ async function probeAdvertisedVideoProfile(bctx) {
41598
+ const { ctx, proxy, numericDeviceId, options } = bctx;
41599
+ const log = ctx.logger.withTags({ deviceId: numericDeviceId });
41600
+ const entries = await probe(() => proxy.cameraStreams?.getProfileRtspEntries({}), "cameraStreams.getProfileRtspEntries", log);
41601
+ const slots = await probe(() => proxy.cameraStreams?.getBrokerStreams({}), "cameraStreams.getBrokerStreams", log);
41602
+ const camStreams = await probe(() => proxy.cameraStreams?.getCameraStreams({}), "cameraStreams.getCameraStreams", log);
41603
+ const choices = await probe(() => proxy.webrtcSession?.listStreams({}), "webrtcSession.listStreams", log);
41604
+ const fpsByProfile = resolveProfileFps({
41605
+ choices: choices ?? [],
41606
+ slots: slots ?? [],
41607
+ camStreams: camStreams ?? []
41608
+ });
41609
+ const resolutions = deriveAdvertisedResolutions({
41610
+ candidates: CANDIDATE_RESOLUTIONS,
41611
+ entries: entries ?? [],
41612
+ deviceId: numericDeviceId,
41613
+ pref: options.hapDeviceSettings.streamPreference,
41614
+ fpsByProfile
41615
+ });
41616
+ const assumed = resolutions.filter((r) => r.source === "assumed").length;
41617
+ log.info("export-hap: advertised video profile derived", { meta: {
41618
+ streamPreference: options.hapDeviceSettings.streamPreference,
41619
+ resolutions: formatAdvertisedResolutions(resolutions),
41620
+ profileFps: [...fpsByProfile.values()].map((f) => `${f.profile}=${f.fps}(${f.source})`),
41621
+ assumedCount: assumed,
41622
+ previouslyAdvertisedFps: 30
41623
+ } });
41624
+ if (assumed === resolutions.length) log.warn("export-hap: no measured or published frame rate for ANY profile — advertising the assumed rate", { meta: {
41625
+ entries: entries?.length ?? 0,
41626
+ choices: choices?.length ?? 0
41627
+ } });
41628
+ return {
41629
+ resolutions,
41630
+ fpsByProfile
41631
+ };
41632
+ }
41633
+ /**
41634
+ * Run one cap read. Returns null both when the cap is not bound and when the
41635
+ * call throws — and LOGS which, because "the camera has no telemetry" and "the
41636
+ * telemetry call failed" lead to different fixes.
41637
+ */
41638
+ async function probe(call, label, log) {
41639
+ try {
41640
+ const pending = call();
41641
+ if (pending === void 0) {
41642
+ log.info("export-hap: fps probe skipped — cap not bound on this device", { meta: { call: label } });
41643
+ return null;
41644
+ }
41645
+ return await pending;
41646
+ } catch (err) {
41647
+ log.warn("export-hap: fps probe failed — falling back to a lower-authority source", { meta: {
41648
+ call: label,
41649
+ error: err instanceof Error ? err.message : String(err)
41650
+ } });
41651
+ return null;
41652
+ }
41653
+ }
41654
+ //#endregion
39480
41655
  //#region src/mappers/builders/child-switch.ts
39481
41656
  /**
39482
41657
  * Child-switch builder — turns a camstack accessory child device (siren,
@@ -39521,7 +41696,7 @@ async function buildChildSwitch(bctx, subtype, deviceType) {
39521
41696
  const isLightingDevice = deviceType === DeviceType.Light || deviceType === DeviceType.Generic;
39522
41697
  const useLightbulb = hasBrightness && isLightingDevice;
39523
41698
  const service = useLightbulb ? accessory.addService(_homebridge_hap_nodejs.Service.Lightbulb, displayName, subtype) : accessory.addService(_homebridge_hap_nodejs.Service.Switch, displayName, subtype);
39524
- service.setCharacteristic(_homebridge_hap_nodejs.Characteristic.ConfiguredName, displayName);
41699
+ service.setCharacteristic(_homebridge_hap_nodejs.Characteristic.Name, displayName);
39525
41700
  try {
39526
41701
  const switchStatus = await proxy.switch?.getStatus({});
39527
41702
  if (switchStatus && typeof switchStatus.on === "boolean") service.updateCharacteristic(_homebridge_hap_nodejs.Characteristic.On, switchStatus.on);
@@ -39593,7 +41768,7 @@ async function buildChildServicesFor(input) {
39593
41768
  accessory: parentCtx.accessory,
39594
41769
  proxy: childProxy,
39595
41770
  numericDeviceId: child.id,
39596
- displayName: formatChildServiceName(parentDisplayName, child),
41771
+ displayName: childServiceName(parentDisplayName, child),
39597
41772
  options
39598
41773
  };
39599
41774
  const subtype = `child-${child.id}`;
@@ -39624,21 +41799,6 @@ async function listChildren(ctx, parentNumericId) {
39624
41799
  }
39625
41800
  }
39626
41801
  /**
39627
- * Service name displayed inside the camera tile detail in iOS Home.
39628
- * Prefer the child's own role ("Siren", "Floodlight") when meaningful
39629
- * — the camera name is already implied by the surrounding Accessory.
39630
- * Falls back to the child's stored device name when role is empty.
39631
- */
39632
- function formatChildServiceName(parentName, child) {
39633
- const role = child.role && child.role.length > 0 ? toTitleCase(child.role) : null;
39634
- if (role) return role;
39635
- if (child.name.toLowerCase().includes(parentName.toLowerCase())) {
39636
- const stripped = child.name.replace(new RegExp(`\\b${escapeRegex(parentName)}\\b`, "i"), "").replace(/\s+[—-]\s+/, " ").trim();
39637
- if (stripped.length > 0) return stripped;
39638
- }
39639
- return child.name;
39640
- }
39641
- /**
39642
41802
  * Coerce the raw `child.type` string (from `deviceManager.getChildren`)
39643
41803
  * to a `DeviceType` enum value. Unknown / mis-cased values fall back to
39644
41804
  * `Generic` so an unrecognised driver behaves like the safest existing
@@ -39650,12 +41810,6 @@ function asDeviceType(raw) {
39650
41810
  for (const value of Object.values(DeviceType)) if (value === lower) return value;
39651
41811
  return DeviceType.Generic;
39652
41812
  }
39653
- function escapeRegex(s) {
39654
- return s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
39655
- }
39656
- function toTitleCase(raw) {
39657
- return raw.split("-").filter((part) => part.length > 0).map((part) => part.charAt(0).toUpperCase() + part.slice(1)).join(" ");
39658
- }
39659
41813
  function errMsg$2(err) {
39660
41814
  return err instanceof Error ? err.message : String(err);
39661
41815
  }
@@ -39703,24 +41857,18 @@ async function buildCameraAccessory(input) {
39703
41857
  displayName,
39704
41858
  options
39705
41859
  };
39706
- const streams = buildCameraStreamingDelegate(bctx);
41860
+ const streams = buildCameraStreamingDelegate(bctx, await probeAdvertisedVideoProfile(bctx));
39707
41861
  const handles = [];
39708
41862
  if (capNames.has("intercom")) handles.push(await buildIntercom({
39709
41863
  bctx,
39710
41864
  streamingOptions: streams.streamingOptions
39711
41865
  }));
39712
- const hksv = capNames.has("motion-detection") ? buildHksvStub(bctx) : null;
39713
41866
  const controller = new (isDoorbell ? _homebridge_hap_nodejs.DoorbellController : _homebridge_hap_nodejs.CameraController)({
39714
41867
  delegate: streams.delegate,
39715
41868
  streamingOptions: streams.streamingOptions,
39716
- cameraStreamCount: 2,
39717
- ...hksv ? { recording: {
39718
- options: hksv.options,
39719
- delegate: hksv.delegate
39720
- } } : {}
41869
+ cameraStreamCount: 2
39721
41870
  });
39722
41871
  accessory.configureController(controller);
39723
- if (hksv) handles.push(hksv.handle);
39724
41872
  if (capNames.has("motion-detection")) handles.push(await buildMotionSensor(bctx));
39725
41873
  if (isDoorbell && controller instanceof _homebridge_hap_nodejs.DoorbellController) handles.push(await buildDoorbell({
39726
41874
  bctx,