@camstack/addon-export-hap 1.2.12 → 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.
14402
14801
  */
14403
- var TrackSourceSchema = _enum(["pipeline", "sensor"]);
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.
14807
+ */
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(),
@@ -14742,7 +15153,31 @@ var RebuildObjectEmbeddingsInput = object({
14742
15153
  since: number().optional(),
14743
15154
  until: number().optional(),
14744
15155
  /** Stop after this many tracks; the result reports whether more remain. */
14745
- maxTracks: number().int().positive().optional()
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()
14746
15181
  });
14747
15182
  /**
14748
15183
  * Result of emptying the CLIP index.
@@ -14776,13 +15211,23 @@ var RebuildStatusSchema = object({
14776
15211
  /** Tracks with no usable detection box. */
14777
15212
  missingBbox: number(),
14778
15213
  /**
14779
- * Tracks the pipeline REFUSED rather than broke on: the camera is not
14780
- * attached, or `clip-embedding` is not enabled in its step tree. Separate
14781
- * from `failed` because the remedy is a configuration change, not an engine
14782
- * investigation and because a pass over decommissioned cameras would
14783
- * otherwise read as a total engine outage.
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.
14784
15218
  */
14785
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(),
14786
15231
  failed: number(),
14787
15232
  /** Set once a pass ends: true only when EVERYTHING was covered. */
14788
15233
  complete: boolean().nullable(),
@@ -14854,7 +15299,12 @@ DeviceType.Camera, method(object({ deviceId: number() }), array(TrackSchema).rea
14854
15299
  }), {
14855
15300
  kind: "mutation",
14856
15301
  auth: "admin"
14857
- }), 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, {
14858
15308
  kind: "query",
14859
15309
  auth: "admin"
14860
15310
  }), method(object({
@@ -15464,6 +15914,53 @@ var DetailResultSchema = object({
15464
15914
  nativeFaceShortSidePx: number().optional()
15465
15915
  });
15466
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
+ /**
15467
15964
  * Per-camera tunable ranges + defaults. Single source of truth used
15468
15965
  * by both the Zod data schema (validation + default fallback) and
15469
15966
  * the device settings UI (slider min/max/step). Touch one place and
@@ -15813,7 +16310,32 @@ method(RunnerCameraConfigSchema, object({ success: literal(true) }), { kind: "mu
15813
16310
  cropJpeg: string().optional(),
15814
16311
  parent: DetailParentSchema,
15815
16312
  steps: array(string()).optional()
15816
- }), 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" });
15817
16339
  var CameraPipelineConfigSchema = object({
15818
16340
  engine: PipelineEngineChoiceSchema.optional(),
15819
16341
  steps: array(PipelineStepInputSchema).readonly(),
@@ -16111,6 +16633,20 @@ var CameraStatusSchema = object({
16111
16633
  detection: CameraDetectionStatusSchema.nullable(),
16112
16634
  audio: CameraAudioStatusSchema.nullable(),
16113
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(),
16114
16650
  /** Unix timestamp (ms) when this snapshot was composed server-side. */
16115
16651
  fetchedAt: number()
16116
16652
  });
@@ -16279,7 +16815,14 @@ method(object({
16279
16815
  }), method(object({
16280
16816
  deviceId: number(),
16281
16817
  agentNodeId: string().optional()
16282
- }), 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({
16283
16826
  name: string(),
16284
16827
  description: string().optional(),
16285
16828
  config: CameraPipelineConfigSchema
@@ -25236,6 +25779,12 @@ Object.freeze({
25236
25779
  addonId: null,
25237
25780
  access: "view"
25238
25781
  },
25782
+ "notificationRules.listDeviceMutes": {
25783
+ capName: "notification-rules",
25784
+ capScope: "system",
25785
+ addonId: null,
25786
+ access: "view"
25787
+ },
25239
25788
  "notificationRules.listRules": {
25240
25789
  capName: "notification-rules",
25241
25790
  capScope: "system",
@@ -25254,6 +25803,12 @@ Object.freeze({
25254
25803
  addonId: null,
25255
25804
  access: "create"
25256
25805
  },
25806
+ "notificationRules.setDeviceMuted": {
25807
+ capName: "notification-rules",
25808
+ capScope: "system",
25809
+ addonId: null,
25810
+ access: "create"
25811
+ },
25257
25812
  "notificationRules.setRuleEnabled": {
25258
25813
  capName: "notification-rules",
25259
25814
  capScope: "system",
@@ -25530,6 +26085,12 @@ Object.freeze({
25530
26085
  addonId: null,
25531
26086
  access: "view"
25532
26087
  },
26088
+ "pipelineAnalytics.setTrackFlags": {
26089
+ capName: "pipeline-analytics",
26090
+ capScope: "device",
26091
+ addonId: null,
26092
+ access: "create"
26093
+ },
25533
26094
  "pipelineAnalytics.wipeAllAnalytics": {
25534
26095
  capName: "pipeline-analytics",
25535
26096
  capScope: "device",
@@ -25836,6 +26397,12 @@ Object.freeze({
25836
26397
  addonId: null,
25837
26398
  access: "view"
25838
26399
  },
26400
+ "pipelineOrchestrator.getCameraSwitches": {
26401
+ capName: "pipeline-orchestrator",
26402
+ capScope: "system",
26403
+ addonId: null,
26404
+ access: "view"
26405
+ },
25839
26406
  "pipelineOrchestrator.getCapabilityBindings": {
25840
26407
  capName: "pipeline-orchestrator",
25841
26408
  capScope: "system",
@@ -25968,6 +26535,12 @@ Object.freeze({
25968
26535
  addonId: null,
25969
26536
  access: "create"
25970
26537
  },
26538
+ "pipelineOrchestrator.setCameraSwitch": {
26539
+ capName: "pipeline-orchestrator",
26540
+ capScope: "system",
26541
+ addonId: null,
26542
+ access: "create"
26543
+ },
25971
26544
  "pipelineOrchestrator.setCapabilityBinding": {
25972
26545
  capName: "pipeline-orchestrator",
25973
26546
  capScope: "system",
@@ -26058,6 +26631,12 @@ Object.freeze({
26058
26631
  addonId: null,
26059
26632
  access: "create"
26060
26633
  },
26634
+ "pipelineRunner.runStatelessStep": {
26635
+ capName: "pipeline-runner",
26636
+ capScope: "system",
26637
+ addonId: null,
26638
+ access: "create"
26639
+ },
26061
26640
  "plateGallery.assignPlate": {
26062
26641
  capName: "plate-gallery",
26063
26642
  capScope: "system",
@@ -26874,6 +27453,12 @@ Object.freeze({
26874
27453
  addonId: null,
26875
27454
  access: "create"
26876
27455
  },
27456
+ "streamBroker.acquireEgressTranscode": {
27457
+ capName: "stream-broker",
27458
+ capScope: "system",
27459
+ addonId: null,
27460
+ access: "create"
27461
+ },
26877
27462
  "streamBroker.assignProfile": {
26878
27463
  capName: "stream-broker",
26879
27464
  capScope: "system",
@@ -26982,6 +27567,12 @@ Object.freeze({
26982
27567
  addonId: null,
26983
27568
  access: "create"
26984
27569
  },
27570
+ "streamBroker.releaseEgressTranscode": {
27571
+ capName: "stream-broker",
27572
+ capScope: "system",
27573
+ addonId: null,
27574
+ access: "create"
27575
+ },
26985
27576
  "streamBroker.releaseStreamWithCodec": {
26986
27577
  capName: "stream-broker",
26987
27578
  capScope: "system",
@@ -27767,37 +28358,6 @@ object({
27767
28358
  square: false
27768
28359
  }).paddingRatio;
27769
28360
  /**
27770
- * Deterministic SHA-256 hash of an arbitrary serialisable value. The
27771
- * canonical form sorts object keys alphabetically at every depth so two
27772
- * structurally-equal inputs with different key insertion orders produce
27773
- * the same hash. Returns a 64-char lowercase hex digest.
27774
- *
27775
- * Used by export adapters (Alexa, HAP) to short-circuit re-discovery /
27776
- * accessory-rebuild work when the upstream shape is byte-identical to
27777
- * the last applied state — preventing user-visible "re-discovery"
27778
- * notifications on every addon-runner respawn. Each respawn re-fires
27779
- * `DeviceBindingsChanged` for every cap registration, which without
27780
- * this guard would propagate redundant pushes.
27781
- *
27782
- * Note: this is a SYMPTOMATIC fix layered on top of the binding-change
27783
- * subscription. The proper fix is a single "device ready" lifecycle
27784
- * barrier so exports react only when the full cap set has landed —
27785
- * tracked separately for post-HA-integration work.
27786
- */
27787
- function canonicalHash(value) {
27788
- const canonical = JSON.stringify(value, replaceWithSortedKeys);
27789
- return (0, node_crypto.createHash)("sha256").update(canonical ?? "").digest("hex");
27790
- }
27791
- function replaceWithSortedKeys(_key, value) {
27792
- if (value && typeof value === "object" && !Array.isArray(value)) {
27793
- const obj = value;
27794
- const out = {};
27795
- for (const k of Object.keys(obj).toSorted()) out[k] = obj[k];
27796
- return out;
27797
- }
27798
- return value;
27799
- }
27800
- /**
27801
28361
  * Compute the stable 64-char lowercase-hex fingerprint of a device's
27802
28362
  * export-relevant shape. Two structurally-equal shapes (any feature order,
27803
28363
  * any duplicates, any deviceId) hash identically.
@@ -27968,6 +28528,110 @@ function firstExposedAccessorySetupUri(exposed, logger) {
27968
28528
  }
27969
28529
  }
27970
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
+ }
27971
28635
  //#endregion
27972
28636
  //#region src/mappers/builders/battery.ts
27973
28637
  /**
@@ -27992,7 +28656,7 @@ var LOW_BATTERY_THRESHOLD_PCT = 20;
27992
28656
  async function buildBattery(bctx) {
27993
28657
  const { ctx, accessory, proxy, numericDeviceId, displayName } = bctx;
27994
28658
  const log = ctx.logger.withTags({ deviceId: numericDeviceId });
27995
- const service = accessory.addService(_homebridge_hap_nodejs.Service.Battery, displayName);
28659
+ const service = accessory.addService(_homebridge_hap_nodejs.Service.Battery, hapServiceName([displayName], `Camera ${numericDeviceId}`));
27996
28660
  try {
27997
28661
  const status = await proxy.battery?.getStatus({});
27998
28662
  if (status) applyToService(service, status);
@@ -38354,6 +39018,573 @@ function makeRtcpGate(socket, timeoutMs) {
38354
39018
  socket.on("message", onMessage);
38355
39019
  });
38356
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
+ }
38357
39588
  /**
38358
39589
  * The resolutions we offer, before rates are attached. Same list the delegate
38359
39590
  * advertised before R2 — only the frame rate changes, so a controller that had
@@ -38492,6 +39723,129 @@ var CAM_PROFILES = [
38492
39723
  function toCamProfile(profileId) {
38493
39724
  return CAM_PROFILES.find((p) => p === profileId) ?? null;
38494
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
+ }
38495
39849
  //#endregion
38496
39850
  //#region src/mappers/builders/stream-telemetry.ts
38497
39851
  /**
@@ -38513,6 +39867,12 @@ var ZERO_DROP_COUNTERS = {
38513
39867
  "rtcp-no-srtcp": 0,
38514
39868
  /** Outbound RTCP: building or encrypting the Sender Report failed. */
38515
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,
38516
39876
  /** Upstream: packet shorter than an RTP header. */
38517
39877
  "upstream-short-packet": 0,
38518
39878
  /** Upstream: no inbound SRTP context (init failed at prepareStream). */
@@ -38568,6 +39928,21 @@ function classifyInboundPacket(packet) {
38568
39928
  return "rtp";
38569
39929
  }
38570
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
+ /**
38571
39946
  * Build the meta for `export-hap: stream session summary` — the single line a
38572
39947
  * future session greps to answer "why did this session die".
38573
39948
  */
@@ -38588,7 +39963,13 @@ function summariseSession(snapshot) {
38588
39963
  selectedBrokerId: slot?.brokerId ?? null,
38589
39964
  advertisedFps: slot?.advertisedFps ?? null,
38590
39965
  advertisedFpsSource: slot?.advertisedFpsSource ?? null,
39966
+ deliveredFps: slot?.deliveredFps ?? null,
38591
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 ?? [],
38592
39973
  videoPacketsForwarded: snapshot.videoPacketsForwarded,
38593
39974
  audioPacketsForwarded: snapshot.audioPacketsForwarded,
38594
39975
  videoRtcpSrSent: snapshot.videoRtcpSrSent,
@@ -38599,6 +39980,10 @@ function summariseSession(snapshot) {
38599
39980
  audioRtpReceived: snapshot.audioRtpReceived,
38600
39981
  videoGate: formatRtcpGate(snapshot.videoGate),
38601
39982
  audioGate: formatRtcpGate(snapshot.audioGate),
39983
+ videoReceiverReports: snapshot.videoReceiverReports,
39984
+ audioReceiverReports: snapshot.audioReceiverReports,
39985
+ videoLossVerdict: lossVerdict(snapshot.videoReceiverReports),
39986
+ audioLossVerdict: lossVerdict(snapshot.audioReceiverReports),
38602
39987
  mediaStarved: snapshot.videoPacketsForwarded === 0,
38603
39988
  drops: nonZeroDrops(snapshot.drops)
38604
39989
  };
@@ -38618,8 +40003,17 @@ var SRTP_SALT_LEN = 14;
38618
40003
  * make this one act.
38619
40004
  */
38620
40005
  var SESSION_HEARTBEAT_MS = 5e3;
38621
- var OPUS_BITRATE_KBPS = 24;
38622
- var OPUS_CHANNELS = 1;
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;
38623
40017
  function buildCameraStreamingDelegate(bctx, advertised) {
38624
40018
  const { ctx, numericDeviceId } = bctx;
38625
40019
  const log = ctx.logger.withTags({ deviceId: numericDeviceId });
@@ -38727,7 +40121,7 @@ async function prepareStream(request, sessions, bctx) {
38727
40121
  },
38728
40122
  profile: import_src.ProtectionProfileAes128CmHmacSha1_80
38729
40123
  });
38730
- const makeOutSrtcp = (key, salt) => new import_src.SrtcpSession({
40124
+ const makeSrtcp = (key, salt) => new import_src.SrtcpSession({
38731
40125
  keys: {
38732
40126
  localMasterKey: key,
38733
40127
  localMasterSalt: salt,
@@ -38738,13 +40132,17 @@ async function prepareStream(request, sessions, bctx) {
38738
40132
  });
38739
40133
  let videoOutSrtp;
38740
40134
  let videoOutSrtcp;
40135
+ let videoInSrtcp;
38741
40136
  let audioOutSrtp;
38742
40137
  let audioOutSrtcp;
40138
+ let audioInSrtcp;
38743
40139
  try {
38744
40140
  videoOutSrtp = makeOutSrtp(request.video.srtp_key, request.video.srtp_salt);
38745
- 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);
38746
40143
  audioOutSrtp = makeOutSrtp(request.audio.srtp_key, request.audio.srtp_salt);
38747
- 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);
38748
40146
  } catch (err) {
38749
40147
  closeSocket(videoUdp);
38750
40148
  closeSocket(audioUdp);
@@ -38786,6 +40184,10 @@ async function prepareStream(request, sessions, bctx) {
38786
40184
  audioRtcpReceived: 0,
38787
40185
  videoRtpReceived: 0,
38788
40186
  audioRtpReceived: 0,
40187
+ videoReceiverReports: emptyReceiverReportTally(),
40188
+ audioReceiverReports: emptyReceiverReportTally(),
40189
+ videoRrLoggedAt: 0,
40190
+ audioRrLoggedAt: 0,
38789
40191
  videoGate: null,
38790
40192
  audioGate: null,
38791
40193
  heartbeat: null,
@@ -38797,6 +40199,7 @@ async function prepareStream(request, sessions, bctx) {
38797
40199
  videoLoopUdp,
38798
40200
  videoOutSrtp,
38799
40201
  videoOutSrtcp,
40202
+ videoInSrtcp,
38800
40203
  videoOutPacketCount: 0,
38801
40204
  videoOutOctetCount: 0,
38802
40205
  videoOutLastRtpTimestamp: 0,
@@ -38811,6 +40214,7 @@ async function prepareStream(request, sessions, bctx) {
38811
40214
  audioLoopUdp,
38812
40215
  audioOutSrtp,
38813
40216
  audioOutSrtcp,
40217
+ audioInSrtcp,
38814
40218
  audioSendGate: null,
38815
40219
  ipVersion,
38816
40220
  ffmpeg: null,
@@ -38987,6 +40391,111 @@ function countInbound(session, leg, packet, log) {
38987
40391
  leg,
38988
40392
  bytes: packet.length
38989
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 });
38990
40499
  }
38991
40500
  /** Snapshot every counter into the summary meta. */
38992
40501
  function sessionSummaryMeta(session) {
@@ -39006,6 +40515,8 @@ function sessionSummaryMeta(session) {
39006
40515
  audioRtpReceived: session.audioRtpReceived,
39007
40516
  videoGate: session.videoGate,
39008
40517
  audioGate: session.audioGate,
40518
+ videoReceiverReports: session.videoReceiverReports,
40519
+ audioReceiverReports: session.audioReceiverReports,
39009
40520
  drops: session.drops,
39010
40521
  ffmpegExit: session.ffmpegExit,
39011
40522
  stopRequestedByController: session.stopRequestedByController
@@ -39041,7 +40552,15 @@ function stopHeartbeat(session) {
39041
40552
  * a HomeKit session died. It carries the negotiated parameters, the slot we
39042
40553
  * dialled and the rate we had promised for it, packet counts in both
39043
40554
  * directions on both legs, each leg's gate outcome, ffmpeg's exit, who asked
39044
- * for the teardown, and every named drop.
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".
39045
40564
  *
39046
40565
  * Emitted from the ffmpeg `exit` handler — the only place the exit code is
39047
40566
  * known — and directly from the teardown paths when there is no ffmpeg to wait
@@ -39354,11 +40873,24 @@ async function startFfmpegForSession(bctx, session, sessionId, video, advertised
39354
40873
  throw new Error(`export-hap: no profile RTSP entries for device ${numericDeviceId}`);
39355
40874
  }
39356
40875
  const pref = options.hapDeviceSettings.streamPreference;
39357
- const picked = pickPreferredRtspEntry(entries, pref, numericDeviceId, { targetResolution: {
39358
- width: video.width,
39359
- height: video.height
39360
- } });
39361
- if (!picked) {
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) {
39362
40894
  startLog.warn("export-hap: stream start DROPPED — no ENABLED profile RTSP entry", { meta: {
39363
40895
  sessionId,
39364
40896
  streamPreference: pref,
@@ -39367,14 +40899,18 @@ async function startFfmpegForSession(bctx, session, sessionId, video, advertised
39367
40899
  } });
39368
40900
  throw new Error(`export-hap: no enabled RTSP entries for device ${numericDeviceId}`);
39369
40901
  }
40902
+ const picked = fit.picked;
39370
40903
  const rtspUrl = picked.url;
39371
- const slot = (await proxy.cameraStreams?.getBrokerStreams({}) ?? []).find((s) => s.brokerId === picked.brokerId);
40904
+ const slot = brokerStreams.find((s) => s.profile === fit.profile);
39372
40905
  const codec = (picked.codec ?? slot?.codec ?? "").toLowerCase();
39373
- const needsTranscode = codec.includes("h265") || codec.includes("hevc");
40906
+ const needsTranscode = fit.kind === "transcode";
39374
40907
  const pickedProfile = toKnownProfile(picked.profileId);
39375
40908
  const resolvedFps = pickedProfile === null ? void 0 : advertised.fpsByProfile.get(pickedProfile);
39376
40909
  const advertisedFps = resolvedFps?.fps ?? video.fps;
39377
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);
39378
40914
  session.selectedSlot = {
39379
40915
  profile: pickedProfile,
39380
40916
  brokerId: picked.brokerId,
@@ -39382,14 +40918,33 @@ async function startFfmpegForSession(bctx, session, sessionId, video, advertised
39382
40918
  height: picked.resolution?.height ?? null,
39383
40919
  advertisedFps,
39384
40920
  advertisedFpsSource,
40921
+ deliveredFps,
39385
40922
  codec: codec.length > 0 ? codec : "unknown",
39386
- transcode: needsTranscode
40923
+ transcode: needsTranscode,
40924
+ fitReason: fit.reason,
40925
+ publishedKbps: slotEvidence?.publishedKbps ?? null,
40926
+ measuredKbps: slotEvidence?.measuredKbps ?? null,
40927
+ budgetKbps: fit.budgetKbps,
40928
+ fitNotes
39387
40929
  };
39388
- if (advertisedFps !== video.fps) startLog.warn("export-hap: negotiated fps does NOT match the slot we are about to dial", { meta: {
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: {
39389
40943
  sessionId,
39390
40944
  negotiatedFps: video.fps,
39391
40945
  slotFps: advertisedFps,
39392
40946
  slotFpsSource: advertisedFpsSource,
40947
+ deliveredFps,
39393
40948
  profile: pickedProfile,
39394
40949
  brokerId: picked.brokerId,
39395
40950
  transcode: needsTranscode
@@ -39398,101 +40953,91 @@ async function startFfmpegForSession(bctx, session, sessionId, video, advertised
39398
40953
  const audioLoopPort = session.audioLoopUdp.address().port;
39399
40954
  const videoTarget = `rtp://127.0.0.1:${videoLoopPort}?pkt_size=${video.mtu}`;
39400
40955
  const audioTarget = `rtp://127.0.0.1:${audioLoopPort}?pkt_size=${video.mtu}`;
39401
- const videoArgs = needsTranscode ? [
39402
- "-c:v",
39403
- "libx264",
39404
- "-preset",
39405
- "ultrafast",
39406
- "-tune",
39407
- "zerolatency",
39408
- "-pix_fmt",
39409
- "yuv420p",
39410
- "-r",
39411
- String(video.fps),
39412
- "-s",
39413
- `${video.width}x${video.height}`,
39414
- "-b:v",
39415
- `${video.max_bit_rate}k`,
39416
- "-bufsize",
39417
- `${video.max_bit_rate * 2}k`,
39418
- "-maxrate",
39419
- `${video.max_bit_rate}k`,
39420
- "-profile:v",
39421
- "baseline",
39422
- "-level",
39423
- "3.1"
39424
- ] : [
39425
- "-c:v",
39426
- "copy",
39427
- "-bsf:v",
39428
- "dump_extra"
39429
- ];
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);
39430
40972
  const videoSsrcSigned = session.videoSsrc | 0;
39431
40973
  const audioSsrcSigned = video.audio_ssrc | 0;
39432
- const args = [
39433
- "-hide_banner",
39434
- "-loglevel",
39435
- "warning",
39436
- "-rtsp_transport",
39437
- "tcp",
39438
- "-i",
40974
+ const buildArgs = (decodeArgs) => buildSessionFfmpegArgs({
40975
+ decodeArgs,
39439
40976
  rtspUrl,
39440
- "-an",
39441
- "-map",
39442
- "0:v:0",
39443
- ...videoArgs,
39444
- "-payload_type",
39445
- String(video.pt),
39446
- "-ssrc",
39447
- String(videoSsrcSigned),
39448
- "-f",
39449
- "rtp",
40977
+ videoArgs,
39450
40978
  videoTarget,
39451
- "-vn",
39452
- "-map",
39453
- "0:a:0?",
39454
- "-af",
39455
- "aresample=async=1000:first_pts=0",
39456
- "-c:a",
39457
- "libopus",
39458
- "-application",
39459
- "lowdelay",
39460
- "-frame_duration",
39461
- String(video.packet_time ?? 20),
39462
- "-flags",
39463
- "+global_header",
39464
- "-ar",
39465
- String((video.sample_rate ?? 16) * 1e3),
39466
- "-b:a",
39467
- `${OPUS_BITRATE_KBPS}k`,
39468
- "-bufsize",
39469
- `${OPUS_BITRATE_KBPS * 4}k`,
39470
- "-ac",
39471
- String(OPUS_CHANNELS),
39472
- "-payload_type",
39473
- String(video.audio_pt),
39474
- "-ssrc",
39475
- String(audioSsrcSigned),
39476
- "-f",
39477
- "rtp",
39478
- audioTarget
39479
- ];
39480
- const log = ctx.logger.withTags({ deviceId: numericDeviceId });
39481
- const proc = (0, node_child_process.spawn)("ffmpeg", args, { stdio: [
39482
- "ignore",
39483
- "ignore",
39484
- "pipe"
39485
- ] });
39486
- session.ffmpeg = proc;
39487
- proc.stderr?.on("data", (chunk) => {
39488
- const line = chunk.toString("utf8").trim();
39489
- if (!line) return;
39490
- log.info("export-hap: ffmpeg", { meta: {
39491
- sessionId,
39492
- line
39493
- } });
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
39494
40986
  });
39495
- 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) => {
39496
41041
  session.ffmpegExit = {
39497
41042
  code,
39498
41043
  signal
@@ -39509,13 +41054,8 @@ async function startFfmpegForSession(bctx, session, sessionId, video, advertised
39509
41054
  else log.warn("export-hap: ffmpeg exited without a controller stop", { meta });
39510
41055
  if (session.ffmpeg === proc) session.ffmpeg = null;
39511
41056
  logSessionSummary(session, log, session.teardownTrigger ?? "ffmpeg-exit");
39512
- });
39513
- proc.once("error", (err) => {
39514
- log.warn("export-hap: ffmpeg spawn failed", { meta: {
39515
- sessionId,
39516
- error: err.message
39517
- } });
39518
- });
41057
+ };
41058
+ spawnFfmpeg(hwDecode);
39519
41059
  log.info("export-hap: stream started", { meta: {
39520
41060
  sessionId,
39521
41061
  transcode: needsTranscode,
@@ -39526,8 +41066,38 @@ async function startFfmpegForSession(bctx, session, sessionId, video, advertised
39526
41066
  negotiated: `${video.width}x${video.height}@${video.fps} ${video.max_bit_rate}kbps`,
39527
41067
  slotFps: advertisedFps,
39528
41068
  slotFpsSource: advertisedFpsSource,
41069
+ deliveredFps,
41070
+ fitReason: fit.reason,
41071
+ encodeBudgetKbps: fit.budgetKbps,
39529
41072
  audioCodec: "opus",
39530
- 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
39531
41101
  } });
39532
41102
  }
39533
41103
  /**
@@ -39752,7 +41322,7 @@ async function buildIntercom(input) {
39752
41322
  var RESET_DEBOUNCE_MS = 5e3;
39753
41323
  async function buildMotionSensor(bctx) {
39754
41324
  const { ctx, accessory, proxy, numericDeviceId, displayName } = bctx;
39755
- const motionService = accessory.addService(_homebridge_hap_nodejs.Service.MotionSensor, displayName);
41325
+ const motionService = accessory.addService(_homebridge_hap_nodejs.Service.MotionSensor, hapServiceName([displayName], `Camera ${numericDeviceId}`));
39756
41326
  motionService.setCharacteristic(_homebridge_hap_nodejs.Characteristic.MotionDetected, false);
39757
41327
  try {
39758
41328
  const detected = await proxy.motion?.isDetected({});
@@ -39807,12 +41377,12 @@ function errMsg$6(err) {
39807
41377
  * camera-enabled switch — distinct from privacy-mask).
39808
41378
  */
39809
41379
  async function buildPrivacySwitch(bctx) {
39810
- const { ctx, accessory, proxy, numericDeviceId } = bctx;
41380
+ const { ctx, accessory, proxy, numericDeviceId, displayName } = bctx;
39811
41381
  const log = ctx.logger.withTags({ deviceId: numericDeviceId });
39812
41382
  const subtype = "privacy-mask";
39813
- const configuredName = "Privacy";
39814
- const service = accessory.addService(_homebridge_hap_nodejs.Service.Switch, configuredName, subtype);
39815
- 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);
39816
41386
  try {
39817
41387
  const status = await proxy.privacyMask?.getStatus({});
39818
41388
  if (status && typeof status.enabled === "boolean") service.updateCharacteristic(_homebridge_hap_nodejs.Characteristic.On, status.enabled);
@@ -39902,18 +41472,23 @@ function ptzPresetLabel(presetName) {
39902
41472
  * `proxy.ptzAutotrack.setEnabled({enabled})`. Initial value is
39903
41473
  * hydrated from `getStatus({})`.
39904
41474
  *
39905
- * Naming: each switch uses a BARE per-action label ("Preset stanza",
39906
- * "Pan Left", "Autotrack") set as BOTH the service name AND its
39907
- * `ConfiguredName`, mirroring `child-switch.ts` / `privacy-switch.ts`.
39908
- * iOS Home renders sibling services on an accessory by their
39909
- * `ConfiguredName`. The old `${displayName} — <action>` form (em-dash
39910
- * U+2014 + redundant camera prefix) was rejected by HAP-NodeJS as an
39911
- * invalid `Name` characteristic, so iOS discarded it and showed generic
39912
- * "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.
39913
41488
  */
39914
41489
  var MOMENTARY_RESET_MS = 1e3;
39915
41490
  async function buildPtz(bctx) {
39916
- const { ctx, accessory, proxy, numericDeviceId, options } = bctx;
41491
+ const { ctx, accessory, proxy, numericDeviceId, displayName, options } = bctx;
39917
41492
  const log = ctx.logger.withTags({ deviceId: numericDeviceId });
39918
41493
  const timers = /* @__PURE__ */ new Set();
39919
41494
  const armReset = (cb, delay) => {
@@ -39925,10 +41500,10 @@ async function buildPtz(bctx) {
39925
41500
  };
39926
41501
  const presets = await readPresets(bctx);
39927
41502
  for (const preset of presets) {
39928
- const label = ptzPresetLabel(preset.name);
41503
+ const label = ptzServiceName(displayName, ptzPresetLabel(preset.name));
39929
41504
  const subtype = `ptz-preset-${preset.id}`;
39930
41505
  const service = accessory.addService(_homebridge_hap_nodejs.Service.Switch, label, subtype);
39931
- service.setCharacteristic(_homebridge_hap_nodejs.Characteristic.ConfiguredName, label);
41506
+ service.setCharacteristic(_homebridge_hap_nodejs.Characteristic.Name, label);
39932
41507
  service.getCharacteristic(_homebridge_hap_nodejs.Characteristic.On).onSet(async (value) => {
39933
41508
  if (value !== true) return;
39934
41509
  try {
@@ -39943,9 +41518,9 @@ async function buildPtz(bctx) {
39943
41518
  });
39944
41519
  }
39945
41520
  if (proxy.ptz) for (const dir of PTZ_DIRECTIONS) {
39946
- const label = dir.label;
41521
+ const label = ptzServiceName(displayName, dir.label);
39947
41522
  const service = accessory.addService(_homebridge_hap_nodejs.Service.Switch, label, dir.subtype);
39948
- service.setCharacteristic(_homebridge_hap_nodejs.Characteristic.ConfiguredName, label);
41523
+ service.setCharacteristic(_homebridge_hap_nodejs.Characteristic.Name, label);
39949
41524
  service.getCharacteristic(_homebridge_hap_nodejs.Characteristic.On).onSet(async (value) => {
39950
41525
  if (value !== true) return;
39951
41526
  try {
@@ -39989,12 +41564,12 @@ async function readPresets(bctx) {
39989
41564
  }
39990
41565
  }
39991
41566
  async function tryBuildAutotrack(bctx) {
39992
- const { ctx, accessory, proxy, numericDeviceId } = bctx;
41567
+ const { ctx, accessory, proxy, numericDeviceId, displayName } = bctx;
39993
41568
  if (!proxy.ptzAutotrack) return { async dispose() {} };
39994
41569
  const log = ctx.logger.withTags({ deviceId: numericDeviceId });
39995
- const label = PTZ_AUTOTRACK_LABEL;
41570
+ const label = ptzServiceName(displayName, PTZ_AUTOTRACK_LABEL);
39996
41571
  const service = accessory.addService(_homebridge_hap_nodejs.Service.Switch, label, "ptz-autotrack");
39997
- service.setCharacteristic(_homebridge_hap_nodejs.Characteristic.ConfiguredName, label);
41572
+ service.setCharacteristic(_homebridge_hap_nodejs.Characteristic.Name, label);
39998
41573
  try {
39999
41574
  const status = await proxy.ptzAutotrack.getStatus({});
40000
41575
  if (status && typeof status.enabled === "boolean") service.updateCharacteristic(_homebridge_hap_nodejs.Characteristic.On, status.enabled);
@@ -40121,7 +41696,7 @@ async function buildChildSwitch(bctx, subtype, deviceType) {
40121
41696
  const isLightingDevice = deviceType === DeviceType.Light || deviceType === DeviceType.Generic;
40122
41697
  const useLightbulb = hasBrightness && isLightingDevice;
40123
41698
  const service = useLightbulb ? accessory.addService(_homebridge_hap_nodejs.Service.Lightbulb, displayName, subtype) : accessory.addService(_homebridge_hap_nodejs.Service.Switch, displayName, subtype);
40124
- service.setCharacteristic(_homebridge_hap_nodejs.Characteristic.ConfiguredName, displayName);
41699
+ service.setCharacteristic(_homebridge_hap_nodejs.Characteristic.Name, displayName);
40125
41700
  try {
40126
41701
  const switchStatus = await proxy.switch?.getStatus({});
40127
41702
  if (switchStatus && typeof switchStatus.on === "boolean") service.updateCharacteristic(_homebridge_hap_nodejs.Characteristic.On, switchStatus.on);
@@ -40193,7 +41768,7 @@ async function buildChildServicesFor(input) {
40193
41768
  accessory: parentCtx.accessory,
40194
41769
  proxy: childProxy,
40195
41770
  numericDeviceId: child.id,
40196
- displayName: formatChildServiceName(parentDisplayName, child),
41771
+ displayName: childServiceName(parentDisplayName, child),
40197
41772
  options
40198
41773
  };
40199
41774
  const subtype = `child-${child.id}`;
@@ -40224,21 +41799,6 @@ async function listChildren(ctx, parentNumericId) {
40224
41799
  }
40225
41800
  }
40226
41801
  /**
40227
- * Service name displayed inside the camera tile detail in iOS Home.
40228
- * Prefer the child's own role ("Siren", "Floodlight") when meaningful
40229
- * — the camera name is already implied by the surrounding Accessory.
40230
- * Falls back to the child's stored device name when role is empty.
40231
- */
40232
- function formatChildServiceName(parentName, child) {
40233
- const role = child.role && child.role.length > 0 ? toTitleCase(child.role) : null;
40234
- if (role) return role;
40235
- if (child.name.toLowerCase().includes(parentName.toLowerCase())) {
40236
- const stripped = child.name.replace(new RegExp(`\\b${escapeRegex(parentName)}\\b`, "i"), "").replace(/\s+[—-]\s+/, " ").trim();
40237
- if (stripped.length > 0) return stripped;
40238
- }
40239
- return child.name;
40240
- }
40241
- /**
40242
41802
  * Coerce the raw `child.type` string (from `deviceManager.getChildren`)
40243
41803
  * to a `DeviceType` enum value. Unknown / mis-cased values fall back to
40244
41804
  * `Generic` so an unrecognised driver behaves like the safest existing
@@ -40250,12 +41810,6 @@ function asDeviceType(raw) {
40250
41810
  for (const value of Object.values(DeviceType)) if (value === lower) return value;
40251
41811
  return DeviceType.Generic;
40252
41812
  }
40253
- function escapeRegex(s) {
40254
- return s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
40255
- }
40256
- function toTitleCase(raw) {
40257
- return raw.split("-").filter((part) => part.length > 0).map((part) => part.charAt(0).toUpperCase() + part.slice(1)).join(" ");
40258
- }
40259
41813
  function errMsg$2(err) {
40260
41814
  return err instanceof Error ? err.message : String(err);
40261
41815
  }