@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.
@@ -774,7 +774,7 @@ var propertyKeyTypes = /* @__PURE__*/ new Set([
774
774
  "number",
775
775
  "symbol"
776
776
  ]);
777
- function escapeRegex$1(str) {
777
+ function escapeRegex(str) {
778
778
  return str.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
779
779
  }
780
780
  function clone(inst, def, params) {
@@ -1504,7 +1504,7 @@ var $ZodCheckUpperCase = /*@__PURE__*/ $constructor("$ZodCheckUpperCase", (inst,
1504
1504
  });
1505
1505
  var $ZodCheckIncludes = /*@__PURE__*/ $constructor("$ZodCheckIncludes", (inst, def) => {
1506
1506
  $ZodCheck.init(inst, def);
1507
- const escapedRegex = escapeRegex$1(def.includes);
1507
+ const escapedRegex = escapeRegex(def.includes);
1508
1508
  const pattern = new RegExp(typeof def.position === "number" ? `^.{${def.position}}${escapedRegex}` : escapedRegex);
1509
1509
  def.pattern = pattern;
1510
1510
  inst._zod.onattach.push((inst) => {
@@ -1527,7 +1527,7 @@ var $ZodCheckIncludes = /*@__PURE__*/ $constructor("$ZodCheckIncludes", (inst, d
1527
1527
  });
1528
1528
  var $ZodCheckStartsWith = /*@__PURE__*/ $constructor("$ZodCheckStartsWith", (inst, def) => {
1529
1529
  $ZodCheck.init(inst, def);
1530
- const pattern = new RegExp(`^${escapeRegex$1(def.prefix)}.*`);
1530
+ const pattern = new RegExp(`^${escapeRegex(def.prefix)}.*`);
1531
1531
  def.pattern ?? (def.pattern = pattern);
1532
1532
  inst._zod.onattach.push((inst) => {
1533
1533
  const bag = inst._zod.bag;
@@ -1549,7 +1549,7 @@ var $ZodCheckStartsWith = /*@__PURE__*/ $constructor("$ZodCheckStartsWith", (ins
1549
1549
  });
1550
1550
  var $ZodCheckEndsWith = /*@__PURE__*/ $constructor("$ZodCheckEndsWith", (inst, def) => {
1551
1551
  $ZodCheck.init(inst, def);
1552
- const pattern = new RegExp(`.*${escapeRegex$1(def.suffix)}$`);
1552
+ const pattern = new RegExp(`.*${escapeRegex(def.suffix)}$`);
1553
1553
  def.pattern ?? (def.pattern = pattern);
1554
1554
  inst._zod.onattach.push((inst) => {
1555
1555
  const bag = inst._zod.bag;
@@ -2785,7 +2785,7 @@ var $ZodEnum = /*@__PURE__*/ $constructor("$ZodEnum", (inst, def) => {
2785
2785
  const values = getEnumValues(def.entries);
2786
2786
  const valuesSet = new Set(values);
2787
2787
  inst._zod.values = valuesSet;
2788
- inst._zod.pattern = new RegExp(`^(${values.filter((k) => propertyKeyTypes.has(typeof k)).map((o) => typeof o === "string" ? escapeRegex$1(o) : o.toString()).join("|")})$`);
2788
+ inst._zod.pattern = new RegExp(`^(${values.filter((k) => propertyKeyTypes.has(typeof k)).map((o) => typeof o === "string" ? escapeRegex(o) : o.toString()).join("|")})$`);
2789
2789
  inst._zod.parse = (payload, _ctx) => {
2790
2790
  const input = payload.value;
2791
2791
  if (valuesSet.has(input)) return payload;
@@ -2803,7 +2803,7 @@ var $ZodLiteral = /*@__PURE__*/ $constructor("$ZodLiteral", (inst, def) => {
2803
2803
  if (def.values.length === 0) throw new Error("Cannot create literal schema with no valid values");
2804
2804
  const values = new Set(def.values);
2805
2805
  inst._zod.values = values;
2806
- inst._zod.pattern = new RegExp(`^(${def.values.map((o) => typeof o === "string" ? escapeRegex$1(o) : o ? escapeRegex$1(o.toString()) : String(o)).join("|")})$`);
2806
+ inst._zod.pattern = new RegExp(`^(${def.values.map((o) => typeof o === "string" ? escapeRegex(o) : o ? escapeRegex(o.toString()) : String(o)).join("|")})$`);
2807
2807
  inst._zod.parse = (payload, _ctx) => {
2808
2808
  const input = payload.value;
2809
2809
  if (values.has(input)) return payload;
@@ -6496,6 +6496,36 @@ var ProfileRtspEntrySchema = object({
6496
6496
  resolution: CamStreamResolutionSchema.optional()
6497
6497
  });
6498
6498
  /**
6499
+ * Per-call node pinning for `ctx.api` capability calls.
6500
+ *
6501
+ * A capability call normally resolves to its DEFAULT provider — a `singleton`
6502
+ * cap resolves to the hub, a device-scoped cap to the device's owning node. To
6503
+ * query a SPECIFIC node's provider instead (e.g. a remote agent's own
6504
+ * in-process `platform-probe` hardware, which the hub cannot probe), pin the
6505
+ * call to that node.
6506
+ *
6507
+ * The nodeId rides OUT-OF-BAND in the tRPC call context (NOT in the validated
6508
+ * method args), so capability method signatures stay `nodeId`-free — node
6509
+ * targeting is a property of the CALL, not of the method. The transport lifts
6510
+ * it from `op.context` onto the `CapCallInput.nodeId` field (`ipcParentLink`),
6511
+ * and the hub parent's `onUnownedCall` passes it to the `CapRouteResolver`,
6512
+ * which classifies a pinned agent node as `agent-child-forward`
6513
+ * (`$agent-cap-fwd.forward` → the agent's in-process provider).
6514
+ *
6515
+ * Usage at a call site:
6516
+ *
6517
+ * await api.platformProbe.getCapabilities.query(undefined, nodePin(nodeId))
6518
+ */
6519
+ /** tRPC `op.context` key carrying a per-call node pin. */
6520
+ var CAP_NODE_PIN_CONTEXT_KEY = "__camstackNodePin";
6521
+ /**
6522
+ * Build the tRPC request options that pin a single capability call to `nodeId`.
6523
+ * Pass as the second argument to `.query(input, …)` / `.mutate(input, …)`.
6524
+ */
6525
+ function nodePin(nodeId) {
6526
+ return { context: { [CAP_NODE_PIN_CONTEXT_KEY]: nodeId } };
6527
+ }
6528
+ /**
6499
6529
  * Output schema shared by the contribution + live methods.
6500
6530
  *
6501
6531
  * Mirrors the `ConfigUISchemaWithValues` shape (sections[] + optional
@@ -6943,6 +6973,39 @@ method(object({ deviceId: number() }), array(StreamSourceEntrySchema)), method(o
6943
6973
  action: string().min(1),
6944
6974
  input: unknown()
6945
6975
  }), unknown(), { kind: "mutation" }), method(object({ deviceId: number() }), _void(), { kind: "mutation" }), method(object({ deviceId: number() }), unknown().nullable()), method(object({ deviceId: number() }), RawStateResultSchema.nullable(), { auth: "protected" });
6976
+ //#endregion
6977
+ //#region ../types/dist/canonical-hash-7nfBbEqR.mjs
6978
+ /**
6979
+ * Deterministic SHA-256 hash of an arbitrary serialisable value. The
6980
+ * canonical form sorts object keys alphabetically at every depth so two
6981
+ * structurally-equal inputs with different key insertion orders produce
6982
+ * the same hash. Returns a 64-char lowercase hex digest.
6983
+ *
6984
+ * Used by export adapters (Alexa, HAP) to short-circuit re-discovery /
6985
+ * accessory-rebuild work when the upstream shape is byte-identical to
6986
+ * the last applied state — preventing user-visible "re-discovery"
6987
+ * notifications on every addon-runner respawn. Each respawn re-fires
6988
+ * `DeviceBindingsChanged` for every cap registration, which without
6989
+ * this guard would propagate redundant pushes.
6990
+ *
6991
+ * Note: this is a SYMPTOMATIC fix layered on top of the binding-change
6992
+ * subscription. The proper fix is a single "device ready" lifecycle
6993
+ * barrier so exports react only when the full cap set has landed —
6994
+ * tracked separately for post-HA-integration work.
6995
+ */
6996
+ function canonicalHash(value) {
6997
+ const canonical = JSON.stringify(value, replaceWithSortedKeys);
6998
+ return createHash("sha256").update(canonical ?? "").digest("hex");
6999
+ }
7000
+ function replaceWithSortedKeys(_key, value) {
7001
+ if (value && typeof value === "object" && !Array.isArray(value)) {
7002
+ const obj = value;
7003
+ const out = {};
7004
+ for (const k of Object.keys(obj).toSorted()) out[k] = obj[k];
7005
+ return out;
7006
+ }
7007
+ return value;
7008
+ }
6946
7009
  var EncodeProfileSchema = object({
6947
7010
  video: object({
6948
7011
  codec: _enum([
@@ -6955,6 +7018,14 @@ var EncodeProfileSchema = object({
6955
7018
  "main",
6956
7019
  "high"
6957
7020
  ]).optional(),
7021
+ /**
7022
+ * `-level`, e.g. `'3.1'`. A consumer that ADVERTISES a level in its SDP
7023
+ * (`profile-level-id=42e01f` is Baseline 3.1) must constrain the encoder to
7024
+ * it, or it ships a stream that does not match its own advertisement — the
7025
+ * defect class that kept HomeKit black for a year and that Alexa carried
7026
+ * silently. Optional because a browser negotiates the level itself.
7027
+ */
7028
+ level: string().optional(),
6958
7029
  width: number().int().positive().optional(),
6959
7030
  height: number().int().positive().optional(),
6960
7031
  fps: number().positive().optional(),
@@ -7002,6 +7073,29 @@ var EncodeProfileSchema = object({
7002
7073
  outputArgs: array(string()).optional()
7003
7074
  });
7004
7075
  /**
7076
+ * The shape every live egress starts from: H.264 Baseline 3.1 at 720p25.
7077
+ * Baseline because it is the one profile every consumer in this repo decodes
7078
+ * (Echo, iOS, an old browser); 3.1 because that is what the SDPs advertise.
7079
+ */
7080
+ var BASE_LIVE_EGRESS_PROFILE = {
7081
+ video: {
7082
+ codec: "h264",
7083
+ profile: "baseline",
7084
+ level: "3.1",
7085
+ width: 1280,
7086
+ height: 720,
7087
+ fps: 25,
7088
+ bitrateKbps: 2500,
7089
+ gopFrames: 25,
7090
+ bf: 0,
7091
+ preset: "veryfast",
7092
+ tune: "zerolatency"
7093
+ },
7094
+ audio: "passthrough"
7095
+ };
7096
+ ({ ...BASE_LIVE_EGRESS_PROFILE }), { ...BASE_LIVE_EGRESS_PROFILE.video };
7097
+ ({ ...BASE_LIVE_EGRESS_PROFILE });
7098
+ /**
7005
7099
  * Deep wiring healthcheck — snapshot of active reachability probes across
7006
7100
  * every declared capability + widget of every installed plugin, on every
7007
7101
  * node. Produced by the backend `WiringHealthService` and surfaced via
@@ -7051,6 +7145,105 @@ object({
7051
7145
  })
7052
7146
  });
7053
7147
  /**
7148
+ * Per-camera FUNCTION SWITCHES — the one coherent on/off surface over the
7149
+ * pipeline functions an operator thinks in terms of.
7150
+ *
7151
+ * ## This file adds no state
7152
+ *
7153
+ * Every switch here is a VIEW onto an authority that already existed
7154
+ * ([D61](../../../../docs/decisions/adr-0062.md)). The whole point of the
7155
+ * group is that there is exactly one place each function is turned off, and
7156
+ * the group routes to it:
7157
+ *
7158
+ * | Switch | Authority | Proven "off stops the work" gate |
7159
+ * | --- | --- | --- |
7160
+ * | `stream-broker` | `deviceManager.setDisabled` | `StreamBrokerManager.reconcileAllCatalogs` releases the brokers; `ensureBroker` refuses re-creation |
7161
+ * | `object-detection` | `deviceManager.setWrapperActive('detection-pipeline')` | `PipelineSettingsStore.resolvePipelineForDevice` returns `{ steps: [], audio: null }` |
7162
+ * | `audio-analysis` | `deviceManager.setWrapperActive('audio-analysis')` | `AudioSubscriptionController.subscribeAudioStream` returns `null` before opening the stream |
7163
+ * | `recording` | `recording.setDeviceConfig` → `RecordingConfig.enabled` | `band-decision.shouldRecord` returns false; the controller detaches the device |
7164
+ * | `notifications` | `notificationRules.setDeviceMuted` | `NotificationCenter.evaluateAndEnqueue` returns before any rule is evaluated |
7165
+ *
7166
+ * The wrapper-binding pair is not a new idea: `legacy-migrations.ts` already
7167
+ * migrated the legacy `audioEnabled` / `pipelineEnabled` /
7168
+ * `motionDetectionEnabled` booleans ONTO `setWrapperActive`. The group is the
7169
+ * surface that decision never got.
7170
+ *
7171
+ * ## Two rules that are load-bearing
7172
+ *
7173
+ * - **Recording's switch is `enabled`, never the bands.** `bands` is the only
7174
+ * authored intent and `mode` is derived from it (`deriveRecordingMode`).
7175
+ * Expressing "off" by clearing bands destroys the operator's schedule and
7176
+ * turning the camera back on would then silently record nothing.
7177
+ * - **A switch that is off must be reported as off**, not merely produce
7178
+ * nothing. {@link CameraSwitch.enabled} is what a status surface renders as
7179
+ * "disabled by an operator" instead of "broken" — see
7180
+ * `CameraStatus.switchedOff`.
7181
+ */
7182
+ /**
7183
+ * The five functions the operator named (2026-08-05). Deliberately NOT one id
7184
+ * per pipeline step: face recognition and plate/LPR are per-step toggles on
7185
+ * `pipelineOrchestrator.setCameraStepToggle` and belong in the pipeline
7186
+ * editor, not in a five-button safety group.
7187
+ */
7188
+ var CameraSwitchIdSchema = _enum([
7189
+ "stream-broker",
7190
+ "object-detection",
7191
+ "audio-analysis",
7192
+ "recording",
7193
+ "notifications"
7194
+ ]);
7195
+ /**
7196
+ * WHERE the switch's state actually lives. A discriminated union rather than a
7197
+ * string so both the writer (the orchestrator's `setCameraSwitch`) and any
7198
+ * reader can exhaustively narrow — and so "the group added a parallel map" is
7199
+ * a compile error rather than a review comment.
7200
+ */
7201
+ var CameraSwitchAuthoritySchema = discriminatedUnion("kind", [
7202
+ object({ kind: literal("device-disabled") }),
7203
+ object({
7204
+ kind: literal("wrapper-binding"),
7205
+ capName: string()
7206
+ }),
7207
+ object({ kind: literal("recording-config") }),
7208
+ object({ kind: literal("notification-mute") })
7209
+ ]);
7210
+ /**
7211
+ * Why a switch is not offered for this camera. Rendered instead of the
7212
+ * control, never as a dead control — an absent function and a broken one must
7213
+ * not look the same.
7214
+ */
7215
+ var CameraSwitchUnavailableReasonSchema = _enum(["no-provider", "source-unreachable"]);
7216
+ /**
7217
+ * One switch, resolved for one camera.
7218
+ *
7219
+ * `label` and `costWhenOff` travel ON THE WIRE rather than being looked up
7220
+ * client-side: the viewer is a separate repository that does not import
7221
+ * `@camstack/types`, and a cost line duplicated in two clients is a cost line
7222
+ * that will disagree with itself. Five rows per camera is nothing.
7223
+ */
7224
+ var CameraSwitchSchema = object({
7225
+ id: CameraSwitchIdSchema,
7226
+ label: string(),
7227
+ /**
7228
+ * What the operator LOSES while this is off, in one sentence. Required, not
7229
+ * optional: a switch that cannot say what it costs should not ship.
7230
+ */
7231
+ costWhenOff: string(),
7232
+ /** False = do not render a control. `unavailableReason` says why. */
7233
+ available: boolean(),
7234
+ unavailableReason: CameraSwitchUnavailableReasonSchema.optional(),
7235
+ /** Current state. Meaningless when `available` is false — read it as `true`. */
7236
+ enabled: boolean(),
7237
+ authority: CameraSwitchAuthoritySchema
7238
+ });
7239
+ /** The whole group for one camera. */
7240
+ var CameraSwitchGroupSchema = object({
7241
+ deviceId: number().int(),
7242
+ switches: array(CameraSwitchSchema).readonly(),
7243
+ /** Unix ms when the group was composed server-side. */
7244
+ fetchedAt: number()
7245
+ });
7246
+ /**
7054
7247
  * Ops-log — the durable, append-only operations audit shared by the
7055
7248
  * recordings and events management surfaces.
7056
7249
  *
@@ -7069,14 +7262,16 @@ var OpsLogOpSchema = _enum([
7069
7262
  "manual-delete",
7070
7263
  "rescan",
7071
7264
  "retention-run",
7072
- "relocate"
7265
+ "relocate",
7266
+ "orphan-audit"
7073
7267
  ]);
7074
7268
  /** Why the operation ran. */
7075
7269
  var OpsLogReasonSchema = _enum([
7076
7270
  "retention",
7077
7271
  "quota",
7078
7272
  "manual",
7079
- "operator"
7273
+ "operator",
7274
+ "maintenance"
7080
7275
  ]);
7081
7276
  /** One audit row, shared verbatim by both domains. */
7082
7277
  var OpsLogEntrySchema = object({
@@ -8991,6 +9186,100 @@ var RtpSourceSchema = object({
8991
9186
  encoder: string(),
8992
9187
  pipelineKey: string()
8993
9188
  });
9189
+ /**
9190
+ * The encode request — **structured and serialisable, with NO raw-flag escape
9191
+ * hatch.** This is deliberate and it is the one lesson taken from
9192
+ * `getStreamWithCodec`: that method's `outputArgs: string[]` is simultaneously
9193
+ * its extensibility mechanism AND part of `pipelineKeyFor`'s sharing key, so
9194
+ * adding a flag silently forks the shared child, and two consumers that mean
9195
+ * the same thing but spell it differently never share. Here every knob is a
9196
+ * NAMED field: a new requirement becomes a schema field (and a codegen run),
9197
+ * never an opaque array.
9198
+ *
9199
+ * `inputArgs` / `outputArgs` are omitted from the profile for the same reason.
9200
+ * The operator-facing derived-stream transform editor still has them — that is
9201
+ * a different surface (`publishCameraStream({ kind: 'derived' })`) with a
9202
+ * different purpose (reshaping a badly-behaved SOURCE), and it is unchanged.
9203
+ */
9204
+ var EgressEncodeSchema = EncodeProfileSchema.omit({
9205
+ inputArgs: true,
9206
+ outputArgs: true
9207
+ });
9208
+ /**
9209
+ * How the encoder is bounded. `'tight'` is a one-second VBV window for a
9210
+ * consumer whose budget is enforced per second (HomeKit); `'relaxed'` is two
9211
+ * seconds, letting a keyframe spike borrow from the next second (a browser,
9212
+ * an Echo). Named rather than numeric so the INTENT survives.
9213
+ */
9214
+ var EgressRateControlSchema = _enum(["tight", "relaxed"]);
9215
+ var EgressTranscodeRequestSchema = object({
9216
+ deviceId: number().int().nonnegative(),
9217
+ /** Which published stream to read. */
9218
+ source: discriminatedUnion("kind", [object({
9219
+ kind: literal("profile"),
9220
+ profile: CamProfileSchema
9221
+ }), object({
9222
+ kind: literal("cam-stream"),
9223
+ camStreamId: string().min(1)
9224
+ })]),
9225
+ encode: EgressEncodeSchema,
9226
+ rateControl: EgressRateControlSchema.optional(),
9227
+ /**
9228
+ * `-bsf:v`. A consumer that negotiates its OWN SDP (HomeKit) cannot carry
9229
+ * out-of-band extradata and needs `dump_extra` on both the copy and encode
9230
+ * branches. Enumerated, not free text.
9231
+ */
9232
+ bitstreamFilter: _enum([
9233
+ "dump_extra",
9234
+ "h264_mp4toannexb",
9235
+ "hevc_mp4toannexb"
9236
+ ]).optional(),
9237
+ pixelFormat: _enum(["yuv420p", "nv12"]).optional(),
9238
+ /**
9239
+ * Operator/consumer override for decode hardware. ABSENT is the normal case
9240
+ * and the one that matters: the broker then resolves the backend from the
9241
+ * DECODER ADDON's per-node `probedBestHwaccel` (see
9242
+ * `@camstack/types` `ffmpeg/hwaccel.ts`), which is the ranking known to work
9243
+ * on this hardware — never the raw kernel resolver's qsv-first order.
9244
+ */
9245
+ decodeHwAccel: _enum([
9246
+ "auto",
9247
+ "none",
9248
+ "videotoolbox",
9249
+ "vaapi",
9250
+ "qsv",
9251
+ "cuda"
9252
+ ]).optional(),
9253
+ /**
9254
+ * Host to embed in the returned restream `url`. The broker mints hub-local
9255
+ * `127.0.0.1` URLs; a consumer on another node passes a cluster-resolvable
9256
+ * host (`NodeTopologyService.reachableHostByNode`) so the returned URL is
9257
+ * dialable from there. Same contract as `getStreamWithCodec.hostname` —
9258
+ * `substituteRtspHost` rewrites only the dial address, never the restreamer.
9259
+ */
9260
+ hostname: string().optional(),
9261
+ /** Attribution for the broker panel. Never part of the sharing key. */
9262
+ tag: string().optional()
9263
+ });
9264
+ var EgressTranscodeSchema = object({
9265
+ /** Dial-able RTSP url (host-substituted when `hostname` was supplied). */
9266
+ url: string(),
9267
+ /** Release handle. Refcounted — the child dies when the last holder releases. */
9268
+ pipelineKey: string(),
9269
+ videoCodec: _enum(["H264", "H265"]),
9270
+ resolution: object({
9271
+ width: number().int().positive(),
9272
+ height: number().int().positive()
9273
+ }),
9274
+ transcoded: boolean(),
9275
+ encoder: string(),
9276
+ /**
9277
+ * The decode backend the child ACTUALLY ran with — `null` for software.
9278
+ * Returned rather than assumed: a consumer that asked for hardware and got
9279
+ * software needs to be able to see that without reading the broker's logs.
9280
+ */
9281
+ decodeHwAccel: string().nullable()
9282
+ });
8994
9283
  method(object({
8995
9284
  deviceId: number().int().nonnegative(),
8996
9285
  camStreamId: string().min(1),
@@ -9100,6 +9389,15 @@ method(object({
9100
9389
  }), {
9101
9390
  kind: "mutation",
9102
9391
  auth: "admin"
9392
+ }), method(EgressTranscodeRequestSchema, EgressTranscodeSchema, {
9393
+ kind: "mutation",
9394
+ auth: "admin"
9395
+ }), method(object({ pipelineKey: string() }), object({
9396
+ released: boolean(),
9397
+ refcount: number().int().nonnegative()
9398
+ }), {
9399
+ kind: "mutation",
9400
+ auth: "admin"
9103
9401
  }), method(SubscribeAudioChunksInputSchema, SubscribeAudioChunksResultSchema, { kind: "mutation" }), method(object({
9104
9402
  subscriptionId: string(),
9105
9403
  maxCount: number().int().positive().default(8)
@@ -9554,6 +9852,62 @@ method(_void(), EngineInfoSchema), method(object({
9554
9852
  indexes: array(CollectionIndexSchema).readonly().optional()
9555
9853
  }), _void(), { kind: "mutation" });
9556
9854
  /**
9855
+ * Stable UI option list for the `hwaccel` setting. Decoder addons
9856
+ * reuse this for `globalSettingsSchema()` so the dropdown is
9857
+ * identical everywhere. Order: auto → off → common backends by
9858
+ * platform affinity (macOS, NVIDIA, Intel/AMD, Windows, Linux).
9859
+ */
9860
+ var HWACCEL_OPTIONS = [
9861
+ {
9862
+ value: "auto",
9863
+ label: "Auto (defer to probed best)"
9864
+ },
9865
+ {
9866
+ value: "none",
9867
+ label: "Off (software)"
9868
+ },
9869
+ {
9870
+ value: "videotoolbox",
9871
+ label: "VideoToolbox (macOS)"
9872
+ },
9873
+ {
9874
+ value: "cuda",
9875
+ label: "CUDA (NVIDIA)"
9876
+ },
9877
+ {
9878
+ value: "nvdec",
9879
+ label: "NVDEC (NVIDIA legacy)"
9880
+ },
9881
+ {
9882
+ value: "vaapi",
9883
+ label: "VAAPI (Linux Intel/AMD)"
9884
+ },
9885
+ {
9886
+ value: "qsv",
9887
+ label: "QuickSync (Intel)"
9888
+ },
9889
+ {
9890
+ value: "d3d11va",
9891
+ label: "D3D11VA (Windows)"
9892
+ },
9893
+ {
9894
+ value: "dxva2",
9895
+ label: "DXVA2 (Windows legacy)"
9896
+ },
9897
+ {
9898
+ value: "amf",
9899
+ label: "AMF (AMD)"
9900
+ },
9901
+ {
9902
+ value: "vdpau",
9903
+ label: "VDPAU (Linux NVIDIA legacy)"
9904
+ },
9905
+ {
9906
+ value: "drm",
9907
+ label: "DRM (Linux generic)"
9908
+ }
9909
+ ];
9910
+ /**
9557
9911
  * shm ring usage stats for a `frameSink: 'shm'` decoder session —
9558
9912
  * exposed via `decoder.getShmStats` so downstream consumers can
9559
9913
  * observe ring pressure (slot count, byte budget, hit/miss ratio).
@@ -12903,7 +13257,7 @@ var DETECTION_SUB_COLORS = {
12903
13257
  zebra: "#404040",
12904
13258
  giraffe: "#d4a373"
12905
13259
  };
12906
- function titleCase(id) {
13260
+ function titleCase$1(id) {
12907
13261
  return id.split(/[-_ ]/).filter((p) => p.length > 0).map((p) => p.charAt(0).toUpperCase() + p.slice(1)).join(" ");
12908
13262
  }
12909
13263
  var entries = /* @__PURE__ */ new Map();
@@ -12942,7 +13296,7 @@ macro("control", "control", TAXONOMY_COLORS.control, "control", "Control");
12942
13296
  for (const [cocoClass, macroClass] of Object.entries(COCO_TO_MACRO.mapping)) {
12943
13297
  if (macroClass !== "vehicle" && macroClass !== "animal") continue;
12944
13298
  if (entries.has(cocoClass)) continue;
12945
- sub(cocoClass, macroClass, "detection", DETECTION_SUB_COLORS[cocoClass] ?? TAXONOMY_COLORS.genericDetection, cocoClass, titleCase(cocoClass));
13299
+ sub(cocoClass, macroClass, "detection", DETECTION_SUB_COLORS[cocoClass] ?? TAXONOMY_COLORS.genericDetection, cocoClass, titleCase$1(cocoClass));
12946
13300
  }
12947
13301
  sub("package-delivered", "package", "package", TAXONOMY_COLORS.package, "package", "Package delivered");
12948
13302
  sub("package-picked-up", "package", "package", TAXONOMY_COLORS.package, "package", "Package picked up");
@@ -13451,12 +13805,13 @@ var NcConditionsSchema = object({
13451
13805
  * source; otherwise the subject's source must equal it. Legacy records
13452
13806
  * with no stamped source are treated as `pipeline`. The union spans both
13453
13807
  * record kinds — object events carry `pipeline` | `onboard`, synthetic
13454
- * tracks carry `sensor`.
13808
+ * tracks carry `sensor` (a linked device) or `audio` (a D62 audio marker).
13455
13809
  */
13456
13810
  source: _enum([
13457
13811
  "pipeline",
13458
13812
  "onboard",
13459
13813
  "sensor",
13814
+ "audio",
13460
13815
  "any"
13461
13816
  ]).optional(),
13462
13817
  /**
@@ -14032,6 +14387,12 @@ method(object({}), object({ rules: array(NcRuleSchema) }), { auth: "admin" }), m
14032
14387
  }), object({ success: literal(true) }), {
14033
14388
  kind: "mutation",
14034
14389
  auth: "admin"
14390
+ }), method(object({}), object({ mutedDeviceIds: array(number().int()).readonly() }), { auth: "admin" }), method(object({
14391
+ deviceId: number().int(),
14392
+ muted: boolean()
14393
+ }), object({ success: literal(true) }), {
14394
+ kind: "mutation",
14395
+ auth: "admin"
14035
14396
  }), method(object({
14036
14397
  rule: NcRuleInputSchema,
14037
14398
  lookbackMinutes: number().int().min(1).max(1440).default(60)
@@ -14370,12 +14731,60 @@ var TrackAudioLabelSchema = object({
14370
14731
  });
14371
14732
  /**
14372
14733
  * How a track was produced. `pipeline` (default / absent) = the spatial
14373
- * detection+tracking pipeline. `sensor` = a SYNTHETIC track projected from a
14374
- * linked sensor/control state change (no positions; carries a snapshot). The
14375
- * spatial subsystems (tracker association, occupancy count, re-id/embedding,
14376
- * resurrection) MUST skip `sensor` tracks they have no bbox trajectory.
14734
+ * detection+tracking pipeline. Every OTHER value is a SYNTHETIC projection
14735
+ * no positions, a single snapshot, and no bbox trajectory at all:
14736
+ *
14737
+ * - `sensor` — a linked sensor/control device state change.
14738
+ * - `audio` — an audio event on the camera itself that was anomalous for
14739
+ * THAT camera, loud, and heard while nothing visual was happening (D62).
14740
+ *
14741
+ * The spatial subsystems (tracker association, occupancy count, re-id /
14742
+ * embedding, resurrection) MUST skip every synthetic source. Test for that
14743
+ * with `isSpatialTrack`, which allow-lists `pipeline` — a `!== 'sensor'`
14744
+ * check silently readmits every source added after it was written.
14745
+ */
14746
+ var TrackSourceSchema = _enum([
14747
+ "pipeline",
14748
+ "sensor",
14749
+ "audio"
14750
+ ]);
14751
+ /**
14752
+ * Per-track OPERATOR flags — set by hand from the admin UI or the viewer, never
14753
+ * by the pipeline. Spread into `TrackSchema` and `KeyEventSchema` from one place
14754
+ * so the two surfaces cannot drift.
14755
+ *
14756
+ * **Absent ≠ false.** A track that has never been touched omits the field; an
14757
+ * explicitly un-flagged track carries `false`. Legacy rows written before the
14758
+ * columns existed read as absent, and a consumer that needs a boolean should say
14759
+ * `flag === true`, not `flag !== false`.
14760
+ *
14761
+ * What the flags DO is deliberately UNDEFINED at the time of writing: they are
14762
+ * operator curation, and the behaviour they drive will be specified separately.
14763
+ * In particular a `markForTrain` track is NOT pinned against retention — see
14764
+ * `docs/decisions/adr-0059.md` for why that is a store-level change, not a flag.
14765
+ */
14766
+ var TrackFlagFields = {
14767
+ /** Operator marked this track as training material. */
14768
+ markForTrain: boolean().optional(),
14769
+ /** Operator marked this track for diagnostic attention. */
14770
+ debug: boolean().optional()
14771
+ };
14772
+ /**
14773
+ * The write half: a PARTIAL patch. An omitted key is left untouched, so setting
14774
+ * one flag can never clear the other — the toggles are independent and are
14775
+ * driven from three surfaces that do not know about each other.
14377
14776
  */
14378
- var TrackSourceSchema = _enum(["pipeline", "sensor"]);
14777
+ var TrackFlagsPatchSchema = object(TrackFlagFields);
14778
+ /**
14779
+ * The resolved flag state after a write. Both fields are REQUIRED here (absent
14780
+ * collapses to `false`) so a caller can drive a toggle's checked state off the
14781
+ * mutation result without a re-fetch.
14782
+ */
14783
+ var TrackFlagsSchema = object({
14784
+ trackId: string(),
14785
+ markForTrain: boolean(),
14786
+ debug: boolean()
14787
+ });
14379
14788
  var TrackSchema = object({
14380
14789
  trackId: string(),
14381
14790
  deviceId: number(),
@@ -14418,7 +14827,8 @@ var TrackSchema = object({
14418
14827
  /** Normalized 0..1 trajectory envelope (see {@link TrackEnvelopeSchema}).
14419
14828
  * Populated from the persisted envelope columns on historical reads;
14420
14829
  * absent on legacy rows, dims-less tracks and active (in-RAM) tracks. */
14421
- envelope: TrackEnvelopeSchema.optional()
14830
+ envelope: TrackEnvelopeSchema.optional(),
14831
+ ...TrackFlagFields
14422
14832
  });
14423
14833
  var BaseEventFields = {
14424
14834
  id: string(),
@@ -14631,7 +15041,8 @@ var KeyEventSchema = object({
14631
15041
  /** Highest-confidence ObjectEvent id for the track (empty when none). */
14632
15042
  bestEventId: string(),
14633
15043
  /** Track lifetime in ms (lastSeen - firstSeen). */
14634
- windowMs: number().optional()
15044
+ windowMs: number().optional(),
15045
+ ...TrackFlagFields
14635
15046
  });
14636
15047
  object({
14637
15048
  trackId: string(),
@@ -14717,7 +15128,31 @@ var RebuildObjectEmbeddingsInput = object({
14717
15128
  since: number().optional(),
14718
15129
  until: number().optional(),
14719
15130
  /** Stop after this many tracks; the result reports whether more remain. */
14720
- maxTracks: number().int().positive().optional()
15131
+ maxTracks: number().int().positive().optional(),
15132
+ /**
15133
+ * Run every embedding on THIS node instead of round-robining the fleet.
15134
+ *
15135
+ * Named `executeOnNodeId` and not `nodeId` on purpose: an inline `nodeId`
15136
+ * field in cap args is read by `parent-unowned-call.ts` as a ROUTING PIN, so
15137
+ * calling it that would pin the rebuild REQUEST itself to that node — the
15138
+ * rebuild orchestration lives on the hub, and only the per-track step runs
15139
+ * remotely. This field is data; the per-track pin is applied inside.
15140
+ *
15141
+ * Absent ⇒ round-robin over every online node whose runner can serve the
15142
+ * pinned model.
15143
+ */
15144
+ executeOnNodeId: string().optional(),
15145
+ /**
15146
+ * Milliseconds to wait between tracks; omit for the built-in default, `0` to
15147
+ * run flat out.
15148
+ *
15149
+ * A rebuild is bulk maintenance on hub-main's single thread. Measured
15150
+ * 2026-08-06, an unpaced pass held that thread busy 82.2 s out of 120 and
15151
+ * pushed `nodes.topology` from 0.25 s to 26 s for 43 minutes. The value in
15152
+ * force is logged at start and finish so a deliberately slow pass reads
15153
+ * differently from a stalled one.
15154
+ */
15155
+ pacingMs: number().int().nonnegative().optional()
14721
15156
  });
14722
15157
  /**
14723
15158
  * Result of emptying the CLIP index.
@@ -14751,13 +15186,23 @@ var RebuildStatusSchema = object({
14751
15186
  /** Tracks with no usable detection box. */
14752
15187
  missingBbox: number(),
14753
15188
  /**
14754
- * Tracks the pipeline REFUSED rather than broke on: the camera is not
14755
- * attached, or `clip-embedding` is not enabled in its step tree. Separate
14756
- * from `failed` because the remedy is a configuration change, not an engine
14757
- * investigation and because a pass over decommissioned cameras would
14758
- * otherwise read as a total engine outage.
15189
+ * Tracks an executing node REFUSED rather than broke on an unreadable key
15190
+ * frame, a step that threw. Separate from `failed` because the remedy is
15191
+ * different, and because a whole camera silently contributing zero vectors
15192
+ * is the shape of failure a rebuild must never hide.
14759
15193
  */
14760
15194
  notRunnable: number(),
15195
+ /**
15196
+ * The pass stopped because NO node could serve the pinned model.
15197
+ *
15198
+ * Distinct from `notRunnable` on purpose: that one says "this track was
15199
+ * refused", this one says "the cluster cannot do this work at all" — every
15200
+ * candidate node either lacks the `clip-embedding` step, lacks a build of the
15201
+ * pinned model for its engine format, or dropped out. The remedy is a model /
15202
+ * engine change, not a per-camera one. Non-zero here always comes with
15203
+ * `complete: false`.
15204
+ */
15205
+ noCapableNode: number(),
14761
15206
  failed: number(),
14762
15207
  /** Set once a pass ends: true only when EVERYTHING was covered. */
14763
15208
  complete: boolean().nullable(),
@@ -14829,7 +15274,12 @@ DeviceType.Camera, method(object({ deviceId: number() }), array(TrackSchema).rea
14829
15274
  }), {
14830
15275
  kind: "mutation",
14831
15276
  auth: "admin"
14832
- }), method(object({}), EventStoreFootprintSchema, {
15277
+ }), method(object({
15278
+ /** Log/audit scope only — the trackId is globally unique on its own. */
15279
+ deviceId: number(),
15280
+ trackId: string(),
15281
+ flags: TrackFlagsPatchSchema
15282
+ }), TrackFlagsSchema, { kind: "mutation" }), method(object({}), EventStoreFootprintSchema, {
14833
15283
  kind: "query",
14834
15284
  auth: "admin"
14835
15285
  }), method(object({
@@ -15439,6 +15889,53 @@ var DetailResultSchema = object({
15439
15889
  nativeFaceShortSidePx: number().optional()
15440
15890
  });
15441
15891
  /**
15892
+ * Why an executing node REFUSED a stateless step run (`runStatelessStep`).
15893
+ *
15894
+ * A refusal is a first-class answer, not an error, because the caller's next
15895
+ * move depends on WHICH one it is — and because "the pass produced nothing"
15896
+ * must never be reachable without a named, counted cause. The two tiers:
15897
+ *
15898
+ * - **node-level** (`unknown-step`, `model-not-servable`) — this node can
15899
+ * never serve this (step, model) pair. The caller drops it from its rotation
15900
+ * and retries the same work elsewhere; nothing about the work changes.
15901
+ * - **work-level** (`unreadable-frame`, `execution-failed`) — this node is
15902
+ * fine, this one request is not. Retrying it on another node would only
15903
+ * spread the same failure.
15904
+ */
15905
+ var StatelessStepRefusalSchema = _enum([
15906
+ "unknown-step",
15907
+ "model-not-servable",
15908
+ "unreadable-frame",
15909
+ "execution-failed"
15910
+ ]);
15911
+ /**
15912
+ * Answer to `runStatelessStep` — a discriminated union rather than a nullable
15913
+ * result, because `null` is exactly what made the camera-bound detail path
15914
+ * unable to tell "refused" from "never asked".
15915
+ */
15916
+ var RunStatelessStepResultSchema = discriminatedUnion("kind", [object({
15917
+ kind: literal("ran"),
15918
+ /** The node that actually executed it — the pin, echoed back for the log. */
15919
+ nodeId: string(),
15920
+ /**
15921
+ * The model the step ran with.
15922
+ *
15923
+ * The node verified this exact id has a build for the format it dispatched
15924
+ * on BEFORE running, so the executor's format resolution returns it
15925
+ * unchanged. A caller that pinned a model must compare this field and
15926
+ * treat a mismatch as a refusal — the whole point of the pin is that a
15927
+ * pass writes one feature space.
15928
+ */
15929
+ modelId: string(),
15930
+ details: array(DetailResultSchema)
15931
+ }), object({
15932
+ kind: literal("refused"),
15933
+ nodeId: string(),
15934
+ reason: StatelessStepRefusalSchema,
15935
+ /** Human-readable specifics — the format tried, the formats shipped, etc. */
15936
+ detail: string()
15937
+ })]);
15938
+ /**
15442
15939
  * Per-camera tunable ranges + defaults. Single source of truth used
15443
15940
  * by both the Zod data schema (validation + default fallback) and
15444
15941
  * the device settings UI (slider min/max/step). Touch one place and
@@ -15788,7 +16285,32 @@ method(RunnerCameraConfigSchema, object({ success: literal(true) }), { kind: "mu
15788
16285
  cropJpeg: string().optional(),
15789
16286
  parent: DetailParentSchema,
15790
16287
  steps: array(string()).optional()
15791
- }), object({ details: array(DetailResultSchema) }).nullable(), { kind: "mutation" });
16288
+ }), object({ details: array(DetailResultSchema) }).nullable(), { kind: "mutation" }), method(object({
16289
+ /** Catalog step id, e.g. `clip-embedding`. */
16290
+ stepId: string(),
16291
+ /**
16292
+ * REQUIRED model pin. The node runs this exact model or refuses with
16293
+ * `model-not-servable` — it never substitutes a format default, because
16294
+ * a fleet pass that round-robins across nodes would then fill one index
16295
+ * from several encoders.
16296
+ */
16297
+ modelId: string(),
16298
+ /** FULL FRAME, base64 JPEG. The runner cuts — do NOT pre-crop. */
16299
+ frameJpeg: string(),
16300
+ /**
16301
+ * The subject box, NORMALISED [0,1] against `frameJpeg`. Normalised on
16302
+ * purpose: the caller stores boxes against a downscaled analysis frame
16303
+ * while the stored key frame is native-resolution, and the only side
16304
+ * that reliably knows the image's pixel dimensions is the side that
16305
+ * decodes it. Denormalising here removes a second reader of the
16306
+ * dimensions and the class of mismatch that comes with it.
16307
+ */
16308
+ bbox: NativeCropBboxSchema,
16309
+ /** Parent class of the subject (`person`, `vehicle`, …) — carried into the result. */
16310
+ className: string(),
16311
+ /** Camera the pixels came from. Diagnostics + log tags ONLY — never routing. */
16312
+ sourceDeviceId: number()
16313
+ }), RunStatelessStepResultSchema, { kind: "mutation" });
15792
16314
  var CameraPipelineConfigSchema = object({
15793
16315
  engine: PipelineEngineChoiceSchema.optional(),
15794
16316
  steps: array(PipelineStepInputSchema).readonly(),
@@ -16086,6 +16608,20 @@ var CameraStatusSchema = object({
16086
16608
  detection: CameraDetectionStatusSchema.nullable(),
16087
16609
  audio: CameraAudioStatusSchema.nullable(),
16088
16610
  recording: CameraRecordingStatusSchema.nullable(),
16611
+ /**
16612
+ * Per-camera function switches an OPERATOR has turned off
16613
+ * ([D61](../../../../docs/decisions/adr-0067.md)).
16614
+ *
16615
+ * This is the difference between DISABLED and BROKEN. A camera whose
16616
+ * `detection` block reports zero fps and whose `switchedOff` contains
16617
+ * `'object-detection'` was switched off by a person; the same camera with an
16618
+ * empty list is failing. Every status surface must render the two
16619
+ * differently — a quiet camera that looks identical to a dead one is the
16620
+ * silence-reads-as-never-happened trap this repo keeps paying for.
16621
+ *
16622
+ * Empty when nothing is off. Never contains a switch no provider offers.
16623
+ */
16624
+ switchedOff: array(CameraSwitchIdSchema).readonly(),
16089
16625
  /** Unix timestamp (ms) when this snapshot was composed server-side. */
16090
16626
  fetchedAt: number()
16091
16627
  });
@@ -16254,7 +16790,14 @@ method(object({
16254
16790
  }), method(object({
16255
16791
  deviceId: number(),
16256
16792
  agentNodeId: string().optional()
16257
- }), CameraPipelineConfigSchema), method(object({ deviceId: number() }), CameraStatusSchema), method(object({ deviceIds: array(number()).optional() }), array(CameraStatusSchema).readonly()), method(_void(), array(PipelineTemplateSchema).readonly()), method(object({
16793
+ }), CameraPipelineConfigSchema), method(object({ deviceId: number() }), CameraSwitchGroupSchema), method(object({
16794
+ deviceId: number(),
16795
+ switchId: CameraSwitchIdSchema,
16796
+ enabled: boolean()
16797
+ }), CameraSwitchGroupSchema, {
16798
+ kind: "mutation",
16799
+ auth: "admin"
16800
+ }), method(object({ deviceId: number() }), CameraStatusSchema), method(object({ deviceIds: array(number()).optional() }), array(CameraStatusSchema).readonly()), method(_void(), array(PipelineTemplateSchema).readonly()), method(object({
16258
16801
  name: string(),
16259
16802
  description: string().optional(),
16260
16803
  config: CameraPipelineConfigSchema
@@ -25211,6 +25754,12 @@ Object.freeze({
25211
25754
  addonId: null,
25212
25755
  access: "view"
25213
25756
  },
25757
+ "notificationRules.listDeviceMutes": {
25758
+ capName: "notification-rules",
25759
+ capScope: "system",
25760
+ addonId: null,
25761
+ access: "view"
25762
+ },
25214
25763
  "notificationRules.listRules": {
25215
25764
  capName: "notification-rules",
25216
25765
  capScope: "system",
@@ -25229,6 +25778,12 @@ Object.freeze({
25229
25778
  addonId: null,
25230
25779
  access: "create"
25231
25780
  },
25781
+ "notificationRules.setDeviceMuted": {
25782
+ capName: "notification-rules",
25783
+ capScope: "system",
25784
+ addonId: null,
25785
+ access: "create"
25786
+ },
25232
25787
  "notificationRules.setRuleEnabled": {
25233
25788
  capName: "notification-rules",
25234
25789
  capScope: "system",
@@ -25505,6 +26060,12 @@ Object.freeze({
25505
26060
  addonId: null,
25506
26061
  access: "view"
25507
26062
  },
26063
+ "pipelineAnalytics.setTrackFlags": {
26064
+ capName: "pipeline-analytics",
26065
+ capScope: "device",
26066
+ addonId: null,
26067
+ access: "create"
26068
+ },
25508
26069
  "pipelineAnalytics.wipeAllAnalytics": {
25509
26070
  capName: "pipeline-analytics",
25510
26071
  capScope: "device",
@@ -25811,6 +26372,12 @@ Object.freeze({
25811
26372
  addonId: null,
25812
26373
  access: "view"
25813
26374
  },
26375
+ "pipelineOrchestrator.getCameraSwitches": {
26376
+ capName: "pipeline-orchestrator",
26377
+ capScope: "system",
26378
+ addonId: null,
26379
+ access: "view"
26380
+ },
25814
26381
  "pipelineOrchestrator.getCapabilityBindings": {
25815
26382
  capName: "pipeline-orchestrator",
25816
26383
  capScope: "system",
@@ -25943,6 +26510,12 @@ Object.freeze({
25943
26510
  addonId: null,
25944
26511
  access: "create"
25945
26512
  },
26513
+ "pipelineOrchestrator.setCameraSwitch": {
26514
+ capName: "pipeline-orchestrator",
26515
+ capScope: "system",
26516
+ addonId: null,
26517
+ access: "create"
26518
+ },
25946
26519
  "pipelineOrchestrator.setCapabilityBinding": {
25947
26520
  capName: "pipeline-orchestrator",
25948
26521
  capScope: "system",
@@ -26033,6 +26606,12 @@ Object.freeze({
26033
26606
  addonId: null,
26034
26607
  access: "create"
26035
26608
  },
26609
+ "pipelineRunner.runStatelessStep": {
26610
+ capName: "pipeline-runner",
26611
+ capScope: "system",
26612
+ addonId: null,
26613
+ access: "create"
26614
+ },
26036
26615
  "plateGallery.assignPlate": {
26037
26616
  capName: "plate-gallery",
26038
26617
  capScope: "system",
@@ -26849,6 +27428,12 @@ Object.freeze({
26849
27428
  addonId: null,
26850
27429
  access: "create"
26851
27430
  },
27431
+ "streamBroker.acquireEgressTranscode": {
27432
+ capName: "stream-broker",
27433
+ capScope: "system",
27434
+ addonId: null,
27435
+ access: "create"
27436
+ },
26852
27437
  "streamBroker.assignProfile": {
26853
27438
  capName: "stream-broker",
26854
27439
  capScope: "system",
@@ -26957,6 +27542,12 @@ Object.freeze({
26957
27542
  addonId: null,
26958
27543
  access: "create"
26959
27544
  },
27545
+ "streamBroker.releaseEgressTranscode": {
27546
+ capName: "stream-broker",
27547
+ capScope: "system",
27548
+ addonId: null,
27549
+ access: "create"
27550
+ },
26960
27551
  "streamBroker.releaseStreamWithCodec": {
26961
27552
  capName: "stream-broker",
26962
27553
  capScope: "system",
@@ -27742,37 +28333,6 @@ object({
27742
28333
  square: false
27743
28334
  }).paddingRatio;
27744
28335
  /**
27745
- * Deterministic SHA-256 hash of an arbitrary serialisable value. The
27746
- * canonical form sorts object keys alphabetically at every depth so two
27747
- * structurally-equal inputs with different key insertion orders produce
27748
- * the same hash. Returns a 64-char lowercase hex digest.
27749
- *
27750
- * Used by export adapters (Alexa, HAP) to short-circuit re-discovery /
27751
- * accessory-rebuild work when the upstream shape is byte-identical to
27752
- * the last applied state — preventing user-visible "re-discovery"
27753
- * notifications on every addon-runner respawn. Each respawn re-fires
27754
- * `DeviceBindingsChanged` for every cap registration, which without
27755
- * this guard would propagate redundant pushes.
27756
- *
27757
- * Note: this is a SYMPTOMATIC fix layered on top of the binding-change
27758
- * subscription. The proper fix is a single "device ready" lifecycle
27759
- * barrier so exports react only when the full cap set has landed —
27760
- * tracked separately for post-HA-integration work.
27761
- */
27762
- function canonicalHash(value) {
27763
- const canonical = JSON.stringify(value, replaceWithSortedKeys);
27764
- return createHash("sha256").update(canonical ?? "").digest("hex");
27765
- }
27766
- function replaceWithSortedKeys(_key, value) {
27767
- if (value && typeof value === "object" && !Array.isArray(value)) {
27768
- const obj = value;
27769
- const out = {};
27770
- for (const k of Object.keys(obj).toSorted()) out[k] = obj[k];
27771
- return out;
27772
- }
27773
- return value;
27774
- }
27775
- /**
27776
28336
  * Compute the stable 64-char lowercase-hex fingerprint of a device's
27777
28337
  * export-relevant shape. Two structurally-equal shapes (any feature order,
27778
28338
  * any duplicates, any deviceId) hash identically.
@@ -27943,6 +28503,110 @@ function firstExposedAccessorySetupUri(exposed, logger) {
27943
28503
  }
27944
28504
  }
27945
28505
  }
28506
+ /**
28507
+ * hap-nodejs' `checkName` regex, verbatim.
28508
+ *
28509
+ * Duplicated rather than imported because it is `@private Private API` in that
28510
+ * package and not exported. Duplicating a private regex is a liability, so the
28511
+ * guard against drift is behavioural, not textual: `service-naming.spec.ts`
28512
+ * builds real services and asserts hap-nodejs emits ZERO characteristic
28513
+ * warnings — if this expression ever diverges from theirs, that test fails.
28514
+ */
28515
+ var HAP_NAME_PATTERN = /^[\p{L}\p{N}][\p{L}\p{N}\p{Zs}’'&!._:;()/,-]*[\p{L}\p{N}]$/u;
28516
+ /** Characters HAP tolerates INSIDE a name. Anything else becomes a space. */
28517
+ var HAP_NAME_INNER = /[^\p{L}\p{N}\p{Zs}’'&!._:;()/,-]/gu;
28518
+ /** Would hap-nodejs accept this as a `Name` characteristic value? */
28519
+ function isHapServiceName(value) {
28520
+ return HAP_NAME_PATTERN.test(value);
28521
+ }
28522
+ /**
28523
+ * Build one HAP-valid service name from device-derived parts.
28524
+ *
28525
+ * Empty and absent parts are dropped rather than joined, so a missing role
28526
+ * never produces a double space. `fallback` is used ONLY when nothing
28527
+ * device-derived survives sanitisation — it is the last resort, not the
28528
+ * default, because a name that says nothing about the device is the defect
28529
+ * this module exists to end.
28530
+ */
28531
+ function hapServiceName(parts, fallback) {
28532
+ const trimmed = trimToHapName(parts.map((part) => typeof part === "string" ? part : "").map((part) => part.replace(HAP_NAME_INNER, " ")).join(" ").replace(/\s+/gu, " ").trim());
28533
+ if (trimmed !== null) return trimmed;
28534
+ return trimToHapName(fallback.replace(HAP_NAME_INNER, " ").replace(/\s+/gu, " ").trim()) ?? "";
28535
+ }
28536
+ /**
28537
+ * The privacy-mask switch.
28538
+ *
28539
+ * The camera's own name carries the meaning; "Privacy" only says which of the
28540
+ * camera's switches this is. It used to be the WHOLE name, which is why the
28541
+ * operator saw a switch that named neither its camera nor, with two cameras
28542
+ * exposed, which camera it belonged to.
28543
+ */
28544
+ function privacyServiceName(deviceName) {
28545
+ return hapServiceName([deviceName, PRIVACY_SUFFIX], PRIVACY_SUFFIX);
28546
+ }
28547
+ /**
28548
+ * Deliberately not localised, and deliberately not a translation table.
28549
+ *
28550
+ * The device half of the name is the operator's own text and arrives in the
28551
+ * operator's language. This half names a camstack capability (`privacy-mask`)
28552
+ * and there is no locale in an addon's context to resolve it against; the word
28553
+ * is also identical in the operator's language. A translation layer for one
28554
+ * word would be the kind of leftover that reads as verification.
28555
+ */
28556
+ var PRIVACY_SUFFIX = "Privacy";
28557
+ /**
28558
+ * An accessory child (siren, floodlight, spotlight) rendered as a service on
28559
+ * the parent camera.
28560
+ *
28561
+ * The child's OWN stored name wins. It is the string the operator typed, in
28562
+ * the operator's language, and the previous rule threw it away: `role` was
28563
+ * consulted first and title-cased, so every siren on the fleet published as
28564
+ * the English word "Siren" no matter what the operator had called it.
28565
+ *
28566
+ * The parent name is prefixed only when the child's name does not already
28567
+ * carry it — providers name children both ways ("Sirena" and "Videocamera
28568
+ * cucina Sirena") and the result must be one form, not two.
28569
+ */
28570
+ function childServiceName(parentName, child) {
28571
+ const own = child.name.trim();
28572
+ if (own.length > 0) return mentions(own, parentName) ? hapServiceName([own], parentName) : hapServiceName([parentName, own], own);
28573
+ return hapServiceName([parentName, typeof child.role === "string" ? titleCase(child.role) : ""], parentName);
28574
+ }
28575
+ /**
28576
+ * A PTZ action switch.
28577
+ *
28578
+ * The action labels themselves stay in `ptz-labels.ts` — they name a HomeKit
28579
+ * control, not a device — but the service is still qualified by the camera, so
28580
+ * a home with two PTZ cameras does not publish two switches called "Preset
28581
+ * ingresso".
28582
+ */
28583
+ function ptzServiceName(deviceName, actionLabel) {
28584
+ return hapServiceName([deviceName, actionLabel], actionLabel);
28585
+ }
28586
+ /** Does `name` already contain `parentName` as a whole word? */
28587
+ function mentions(name, parentName) {
28588
+ const needle = parentName.trim().toLowerCase();
28589
+ if (needle.length === 0) return true;
28590
+ return name.toLowerCase().includes(needle);
28591
+ }
28592
+ /**
28593
+ * Truncate to the HAP ceiling and shave any leading/trailing character the
28594
+ * pattern forbids. Returns `null` when nothing usable is left — the caller
28595
+ * decides what to do with that, because "fall back" and "drop the part" are
28596
+ * different answers.
28597
+ */
28598
+ function trimToHapName(value) {
28599
+ let out = value.length > 64 ? value.slice(0, 64) : value;
28600
+ while (out.length > 0 && !isAlphanumeric(out[out.length - 1])) out = out.slice(0, -1);
28601
+ while (out.length > 0 && !isAlphanumeric(out[0])) out = out.slice(1);
28602
+ return isHapServiceName(out) ? out : null;
28603
+ }
28604
+ function isAlphanumeric(ch) {
28605
+ return ch !== void 0 && /[\p{L}\p{N}]/u.test(ch);
28606
+ }
28607
+ function titleCase(raw) {
28608
+ return raw.split(/[-_\s]+/u).filter((part) => part.length > 0).map((part) => part.charAt(0).toUpperCase() + part.slice(1)).join(" ");
28609
+ }
27946
28610
  //#endregion
27947
28611
  //#region src/mappers/builders/battery.ts
27948
28612
  /**
@@ -27967,7 +28631,7 @@ var LOW_BATTERY_THRESHOLD_PCT = 20;
27967
28631
  async function buildBattery(bctx) {
27968
28632
  const { ctx, accessory, proxy, numericDeviceId, displayName } = bctx;
27969
28633
  const log = ctx.logger.withTags({ deviceId: numericDeviceId });
27970
- const service = accessory.addService(Service.Battery, displayName);
28634
+ const service = accessory.addService(Service.Battery, hapServiceName([displayName], `Camera ${numericDeviceId}`));
27971
28635
  try {
27972
28636
  const status = await proxy.battery?.getStatus({});
27973
28637
  if (status) applyToService(service, status);
@@ -38329,6 +38993,573 @@ function makeRtcpGate(socket, timeoutMs) {
38329
38993
  socket.on("message", onMessage);
38330
38994
  });
38331
38995
  }
38996
+ /** V/P/RC byte + PT byte + 16-bit length. */
38997
+ var RTCP_HEADER_BYTES = 4;
38998
+ /** SSRC of the packet sender, first field of both SR and RR bodies. */
38999
+ var RTCP_SENDER_SSRC_BYTES = 4;
39000
+ /** NTP + RTP timestamp, packet count, octet count — present only on an SR. */
39001
+ var RTCP_SENDER_INFO_BYTES = 20;
39002
+ /** One report block: SSRC_n, loss, highest seq, jitter, LSR, DLSR. */
39003
+ var RTCP_REPORT_BLOCK_BYTES = 24;
39004
+ /** `fraction lost` is an 8-bit fixed-point fraction of 256. */
39005
+ var FRACTION_LOST_DENOMINATOR = 256;
39006
+ /** `delay since last SR` counts 1/65536 of a second. */
39007
+ var DLSR_UNITS_PER_SECOND = 65536;
39008
+ /** `cumulative number of packets lost` is a SIGNED 24-bit field. */
39009
+ var SIGNED_24_SIGN_BIT = 8388608;
39010
+ var SIGNED_24_MODULUS = 16777216;
39011
+ function readSigned24(packet, at) {
39012
+ const raw = packet[at] << 16 | packet[at + 1] << 8 | packet[at + 2];
39013
+ return raw >= SIGNED_24_SIGN_BIT ? raw - SIGNED_24_MODULUS : raw;
39014
+ }
39015
+ function readReportBlock(packet, at) {
39016
+ const fractionLostRaw = packet[at + 4];
39017
+ const dlsr = packet.readUInt32BE(at + 20);
39018
+ return {
39019
+ aboutSsrc: packet.readUInt32BE(at),
39020
+ fractionLostRaw,
39021
+ fractionLostPct: Math.round(fractionLostRaw / FRACTION_LOST_DENOMINATOR * 1e3) / 10,
39022
+ cumulativePacketsLost: readSigned24(packet, at + 5),
39023
+ extendedHighestSequence: packet.readUInt32BE(at + 8),
39024
+ jitter: packet.readUInt32BE(at + 12),
39025
+ lastSrTimestamp: packet.readUInt32BE(at + 16),
39026
+ delaySinceLastSrMs: Math.round(dlsr / DLSR_UNITS_PER_SECOND * 1e3)
39027
+ };
39028
+ }
39029
+ /**
39030
+ * Walk a (possibly compound) RTCP datagram and extract every report block.
39031
+ *
39032
+ * An RR is routinely bundled behind an SDES, and a controller with nothing to
39033
+ * report yet sends an RR with a reception-report count of zero. Both are
39034
+ * normal; neither is a failure. What IS a failure is a packet whose declared
39035
+ * length runs past the buffer or whose report count exceeds its own body —
39036
+ * those get named so a decrypt fault cannot masquerade as silence.
39037
+ */
39038
+ function parseCompoundRtcp(packet) {
39039
+ if (packet.length === 0) return {
39040
+ ok: false,
39041
+ failure: "empty",
39042
+ atOffset: 0
39043
+ };
39044
+ const packetTypes = [];
39045
+ const reports = [];
39046
+ let offset = 0;
39047
+ while (offset < packet.length) {
39048
+ if (packet.length - offset < RTCP_HEADER_BYTES) return {
39049
+ ok: false,
39050
+ failure: "short-header",
39051
+ atOffset: offset
39052
+ };
39053
+ const firstByte = packet[offset];
39054
+ if ((firstByte >> 6 & 3) !== 2) return {
39055
+ ok: false,
39056
+ failure: "bad-version",
39057
+ atOffset: offset
39058
+ };
39059
+ const reportCount = firstByte & 31;
39060
+ const packetType = packet[offset + 1];
39061
+ const totalBytes = (packet.readUInt16BE(offset + 2) + 1) * 4;
39062
+ if (offset + totalBytes > packet.length) return {
39063
+ ok: false,
39064
+ failure: "length-overrun",
39065
+ atOffset: offset
39066
+ };
39067
+ packetTypes.push(packetType);
39068
+ if (packetType === 201 || packetType === 200) {
39069
+ const senderSsrcAt = offset + RTCP_HEADER_BYTES;
39070
+ const blocksAt = senderSsrcAt + RTCP_SENDER_SSRC_BYTES + (packetType === 200 ? RTCP_SENDER_INFO_BYTES : 0);
39071
+ if (blocksAt + reportCount * RTCP_REPORT_BLOCK_BYTES > offset + totalBytes) return {
39072
+ ok: false,
39073
+ failure: "truncated-body",
39074
+ atOffset: offset
39075
+ };
39076
+ const blocks = [];
39077
+ for (let index = 0; index < reportCount; index += 1) blocks.push(readReportBlock(packet, blocksAt + index * RTCP_REPORT_BLOCK_BYTES));
39078
+ reports.push({
39079
+ packetType,
39080
+ reporterSsrc: packet.readUInt32BE(senderSsrcAt),
39081
+ blocks
39082
+ });
39083
+ }
39084
+ offset += totalBytes;
39085
+ }
39086
+ return {
39087
+ ok: true,
39088
+ packetTypes,
39089
+ reports
39090
+ };
39091
+ }
39092
+ function emptyReceiverReportTally() {
39093
+ return {
39094
+ reportsParsed: 0,
39095
+ blocksParsed: 0,
39096
+ unreadable: 0,
39097
+ lastFractionLostPct: null,
39098
+ worstFractionLostPct: null,
39099
+ lastCumulativePacketsLost: null,
39100
+ maxCumulativePacketsLost: null,
39101
+ lastJitter: null,
39102
+ maxJitter: null,
39103
+ lastExtendedHighestSequence: null
39104
+ };
39105
+ }
39106
+ function maxOrValue(previous, next) {
39107
+ return previous === null ? next : Math.max(previous, next);
39108
+ }
39109
+ /** Fold one report block into the tally. Returns a new tally. */
39110
+ function applyReceiverReportBlock(tally, block) {
39111
+ return {
39112
+ ...tally,
39113
+ blocksParsed: tally.blocksParsed + 1,
39114
+ lastFractionLostPct: block.fractionLostPct,
39115
+ worstFractionLostPct: maxOrValue(tally.worstFractionLostPct, block.fractionLostPct),
39116
+ lastCumulativePacketsLost: block.cumulativePacketsLost,
39117
+ maxCumulativePacketsLost: maxOrValue(tally.maxCumulativePacketsLost, block.cumulativePacketsLost),
39118
+ lastJitter: block.jitter,
39119
+ maxJitter: maxOrValue(tally.maxJitter, block.jitter),
39120
+ lastExtendedHighestSequence: block.extendedHighestSequence
39121
+ };
39122
+ }
39123
+ /** Book an RTCP datagram we could not read. Counted, never discarded silently. */
39124
+ function recordUnreadableRtcp(tally) {
39125
+ return {
39126
+ ...tally,
39127
+ unreadable: tally.unreadable + 1
39128
+ };
39129
+ }
39130
+ /**
39131
+ * Parse one decrypted RTCP datagram and fold it into a leg's tally.
39132
+ *
39133
+ * This is the seam the delegate calls: it owns SRTCP decryption and logging,
39134
+ * this owns everything that can be asserted from bytes alone. A malformed
39135
+ * packet returns a failure and an incremented `unreadable` — it never throws,
39136
+ * because this runs on a UDP `message` handler where a throw would take the
39137
+ * session with it.
39138
+ */
39139
+ function ingestDecryptedRtcp(plaintext, tally) {
39140
+ const parsed = parseCompoundRtcp(plaintext);
39141
+ if (!parsed.ok) return {
39142
+ tally: recordUnreadableRtcp(tally),
39143
+ reports: [],
39144
+ failure: parsed.failure
39145
+ };
39146
+ let next = tally;
39147
+ for (const report of parsed.reports) {
39148
+ if (report.blocks.length === 0) continue;
39149
+ next = {
39150
+ ...next,
39151
+ reportsParsed: next.reportsParsed + 1
39152
+ };
39153
+ for (const block of report.blocks) next = applyReceiverReportBlock(next, block);
39154
+ }
39155
+ return {
39156
+ tally: next,
39157
+ reports: parsed.reports,
39158
+ failure: null
39159
+ };
39160
+ }
39161
+ //#endregion
39162
+ //#region src/mappers/builders/stream-bitrate.ts
39163
+ /**
39164
+ * Fraction of the negotiated ceiling we actually aim the encoder at.
39165
+ *
39166
+ * `max_bit_rate` is what the controller budgeted for the stream; what crosses
39167
+ * the wire is the encoded payload PLUS its packetisation. At `mtu = 1378` each
39168
+ * packet carries a 12-byte RTP header and a 10-byte SRTP auth tag, and the
39169
+ * datagram adds 8 (UDP) + 20 (IPv4) — 50 bytes on ~1378, i.e. **3.6 %**. The
39170
+ * remaining ~6 % is margin for a VBV overshoot inside the buffer window.
39171
+ * Reserving it is the difference between "at the ceiling" and "over it".
39172
+ */
39173
+ var BITRATE_HEADROOM = .9;
39174
+ /** Encoder slots a `stream-params` provider exposes, in the cap's own order. */
39175
+ var ENCODER_PROFILE_KEYS = [
39176
+ "main",
39177
+ "sub",
39178
+ "ext"
39179
+ ];
39180
+ /**
39181
+ * Resolve every profile slot's rate from the camera's configuration, with the
39182
+ * broker's flow reading kept alongside it as a lower bound.
39183
+ *
39184
+ * A slot with neither simply carries two nulls — absence is never rendered as
39185
+ * a number, because a wrong number here silently re-creates the overshoot this
39186
+ * module exists to end.
39187
+ */
39188
+ function resolveProfileBitrates(input) {
39189
+ const camStreamById = /* @__PURE__ */ new Map();
39190
+ for (const stream of input.camStreams) camStreamById.set(stream.camStreamId, stream);
39191
+ const measuredByProfile = /* @__PURE__ */ new Map();
39192
+ for (const choice of input.choices) {
39193
+ if (choice.target.kind !== "profile") continue;
39194
+ const plausible = plausibleMeasured(choice.bitrateKbps);
39195
+ if (plausible !== null) measuredByProfile.set(choice.target.profile, plausible);
39196
+ }
39197
+ const out = /* @__PURE__ */ new Map();
39198
+ for (const slot of input.slots) {
39199
+ const source = slot.sourceCamStreamId === null ? void 0 : camStreamById.get(slot.sourceCamStreamId);
39200
+ out.set(slot.profile, {
39201
+ profile: slot.profile,
39202
+ publishedKbps: publishedRateFor(slot, source, input.streamParams),
39203
+ measuredKbps: measuredByProfile.get(slot.profile) ?? null
39204
+ });
39205
+ }
39206
+ for (const [profile, measured] of measuredByProfile) {
39207
+ if (out.has(profile)) continue;
39208
+ out.set(profile, {
39209
+ profile,
39210
+ publishedKbps: null,
39211
+ measuredKbps: measured
39212
+ });
39213
+ }
39214
+ return out;
39215
+ }
39216
+ /**
39217
+ * The rate the encoder may actually use, or `null` when the controller
39218
+ * negotiated no usable ceiling.
39219
+ */
39220
+ function budgetForNegotiatedRate(negotiatedMaxBitrateKbps) {
39221
+ if (!Number.isFinite(negotiatedMaxBitrateKbps) || negotiatedMaxBitrateKbps <= 0) return null;
39222
+ const budget = Math.floor(negotiatedMaxBitrateKbps * BITRATE_HEADROOM);
39223
+ return budget > 0 ? budget : null;
39224
+ }
39225
+ /**
39226
+ * Does this slot fit?
39227
+ *
39228
+ * Only the CONFIGURED rate can answer yes. The measured rate is a lower bound,
39229
+ * so it is allowed to answer no — including against an optimistic publication.
39230
+ */
39231
+ function classifyBitrateFit(evidence, budgetKbps) {
39232
+ if (evidence === void 0) return "unknown";
39233
+ const { publishedKbps, measuredKbps } = evidence;
39234
+ if (measuredKbps !== null && measuredKbps > budgetKbps) return "over-budget";
39235
+ if (publishedKbps === null) return "unknown";
39236
+ return publishedKbps <= budgetKbps ? "fits" : "over-budget";
39237
+ }
39238
+ /**
39239
+ * Pick the stream to serve, and decide whether it can be passed through.
39240
+ *
39241
+ * The selection runs the SAME `pickPreferredRtspEntry` the advertisement is
39242
+ * derived from (D51) — only the candidate set narrows. When at least one
39243
+ * pass-through-capable slot fits the budget, the picker resolves the target
39244
+ * resolution among those and we copy. Otherwise the picker resolves among ALL
39245
+ * entries — so the transcode decodes the slot closest to the negotiated
39246
+ * resolution rather than the largest one on the camera — and we re-encode.
39247
+ *
39248
+ * A pinned `streamPreference` is never overridden by the budget: the pinned
39249
+ * slot is transcoded rather than swapped for a cheaper one.
39250
+ *
39251
+ * Returns `null` when nothing is publishable at all.
39252
+ */
39253
+ function selectStreamForBudget(input) {
39254
+ const budgetKbps = budgetForNegotiatedRate(input.negotiatedMaxBitrateKbps);
39255
+ const notes = fitNotes(input.entries, input.bitrates, budgetKbps);
39256
+ const fallback = pickPreferredRtspEntry(input.entries, input.pref, input.deviceId, { targetResolution: input.targetResolution });
39257
+ if (fallback === null) return null;
39258
+ const fallbackProfile = toCamProfile$1(fallback.profileId);
39259
+ if (budgetKbps === null) {
39260
+ const base = {
39261
+ picked: fallback,
39262
+ profile: fallbackProfile,
39263
+ budgetKbps: null,
39264
+ notes
39265
+ };
39266
+ return canPassThrough(fallback.codec) ? {
39267
+ kind: "copy",
39268
+ reason: "no-negotiated-budget",
39269
+ ...base
39270
+ } : {
39271
+ kind: "transcode",
39272
+ reason: "source-codec",
39273
+ ...base
39274
+ };
39275
+ }
39276
+ const affordable = (input.pref !== "auto" && fallbackProfile === input.pref ? input.entries.filter((entry) => entry.profile === fallbackProfile) : input.entries).filter((entry) => {
39277
+ if (!canPassThrough(entry.codec)) return false;
39278
+ return classifyBitrateFit(input.bitrates.get(entry.profile), budgetKbps) === "fits";
39279
+ });
39280
+ if (affordable.length > 0) {
39281
+ const picked = pickPreferredRtspEntry(affordable, input.pref, input.deviceId, { targetResolution: input.targetResolution });
39282
+ if (picked !== null) return {
39283
+ kind: "copy",
39284
+ reason: "source-fits-budget",
39285
+ picked,
39286
+ profile: toCamProfile$1(picked.profileId),
39287
+ budgetKbps,
39288
+ notes
39289
+ };
39290
+ }
39291
+ if (canPassThrough(fallback.codec)) return {
39292
+ kind: "copy",
39293
+ reason: "over-budget-tolerated",
39294
+ picked: fallback,
39295
+ profile: fallbackProfile,
39296
+ budgetKbps,
39297
+ notes
39298
+ };
39299
+ return {
39300
+ kind: "transcode",
39301
+ reason: transcodeReason(fallback, fallbackProfile, input.bitrates, budgetKbps),
39302
+ picked: fallback,
39303
+ profile: fallbackProfile,
39304
+ budgetKbps,
39305
+ notes
39306
+ };
39307
+ }
39308
+ /**
39309
+ * Fill in a codec the profile restream entry did not carry, from its broker
39310
+ * slot. `getProfileRtspEntries` has historically omitted it for legacy
39311
+ * entries, and a slot whose codec is unknown must not be mistaken for H.264.
39312
+ */
39313
+ function withSlotCodecs(entries, slots) {
39314
+ const codecByProfile = /* @__PURE__ */ new Map();
39315
+ for (const slot of slots) if (slot.codec !== void 0) codecByProfile.set(slot.profile, slot.codec);
39316
+ return entries.map((entry) => {
39317
+ if (entry.codec !== void 0) return entry;
39318
+ const codec = codecByProfile.get(entry.profile);
39319
+ return codec === void 0 ? entry : {
39320
+ ...entry,
39321
+ codec
39322
+ };
39323
+ });
39324
+ }
39325
+ /**
39326
+ * The rate we will actually deliver: never more than the controller asked for,
39327
+ * and never more than the source produces. `-r` above the source rate makes
39328
+ * ffmpeg DUPLICATE frames, which spends the budget on nothing.
39329
+ */
39330
+ function deliverableFps(negotiatedFps, slotFps) {
39331
+ if (slotFps === null || !Number.isFinite(slotFps) || slotFps <= 0) return negotiatedFps;
39332
+ return Math.min(negotiatedFps, Math.floor(slotFps));
39333
+ }
39334
+ /**
39335
+ * The ffmpeg video-output arguments.
39336
+ *
39337
+ * Pass-through carries `-bsf:v dump_extra` so every IDR inlines its own
39338
+ * SPS/PPS — sources that publish parameter sets only in the RTSP SDP hand iOS
39339
+ * a keyframe it cannot decode otherwise.
39340
+ *
39341
+ * The transcode's cap is three flags, not one: `-b:v` is an average and on its
39342
+ * own permits exactly the burst that was measured. `-maxrate` plus a
39343
+ * **one-second** `-bufsize` bounds any one-second window at the negotiated
39344
+ * rate, which is also the only lever available on the 3.03 s peak jitter — the
39345
+ * VBV window is what forces x264 to size a key frame to fit rather than
39346
+ * emitting it as one tight burst.
39347
+ */
39348
+ /**
39349
+ * Seconds between forced IDRs on the transcode path.
39350
+ *
39351
+ * A join can only start decoding at a key frame, so this is the worst-case
39352
+ * wait a controller pays before the first picture — and the bound the RTSP
39353
+ * join-burst withhold falls back on when it declines to replay a wide GOP.
39354
+ */
39355
+ var KEYFRAME_INTERVAL_SEC = 4;
39356
+ function buildVideoEncodeArgs(input) {
39357
+ if (!input.transcode) return [
39358
+ "-c:v",
39359
+ "copy",
39360
+ "-bsf:v",
39361
+ "dump_extra"
39362
+ ];
39363
+ const rate = input.budgetKbps === null ? [] : [
39364
+ "-b:v",
39365
+ `${input.budgetKbps}k`,
39366
+ "-maxrate",
39367
+ `${input.budgetKbps}k`,
39368
+ "-bufsize",
39369
+ `${input.budgetKbps}k`
39370
+ ];
39371
+ return [
39372
+ "-c:v",
39373
+ "libx264",
39374
+ "-preset",
39375
+ "ultrafast",
39376
+ "-tune",
39377
+ "zerolatency",
39378
+ "-pix_fmt",
39379
+ "yuv420p",
39380
+ "-r",
39381
+ String(input.fps),
39382
+ "-s",
39383
+ `${input.width}x${input.height}`,
39384
+ "-g",
39385
+ String(Math.max(1, Math.round(input.fps * KEYFRAME_INTERVAL_SEC))),
39386
+ ...rate,
39387
+ "-profile:v",
39388
+ "baseline",
39389
+ "-level",
39390
+ "3.1",
39391
+ "-bsf:v",
39392
+ "dump_extra"
39393
+ ];
39394
+ }
39395
+ /** Compact `mid=2048pub/9meas:over-budget` rendering for a single log field. */
39396
+ function formatFitNotes(notes) {
39397
+ return notes.map((n) => `${n.profile}=${n.publishedKbps ?? "?"}pub/${n.measuredKbps ?? "?"}meas:${n.verdict}`);
39398
+ }
39399
+ function fitNotes(entries, bitrates, budgetKbps) {
39400
+ return entries.map((entry) => {
39401
+ const evidence = bitrates.get(entry.profile);
39402
+ return {
39403
+ profile: entry.profile,
39404
+ verdict: budgetKbps === null ? "unknown" : classifyBitrateFit(evidence, budgetKbps),
39405
+ publishedKbps: evidence?.publishedKbps ?? null,
39406
+ measuredKbps: evidence?.measuredKbps ?? null
39407
+ };
39408
+ });
39409
+ }
39410
+ function transcodeReason(picked, profile, bitrates, budgetKbps) {
39411
+ if (!canPassThrough(picked.codec)) return "source-codec";
39412
+ if (profile === null) return "unknown-bitrate";
39413
+ return classifyBitrateFit(bitrates.get(profile), budgetKbps) === "over-budget" ? "over-budget" : "unknown-bitrate";
39414
+ }
39415
+ /**
39416
+ * iOS Home renders only H.264 over the classic HAP SRTP path, so an H.265
39417
+ * source can never be passed through. An UNKNOWN codec is treated the same
39418
+ * way: guessing H.264 is how a camera that changed its encoder ends up
39419
+ * shipping bytes no controller can decode.
39420
+ */
39421
+ function canPassThrough(codec) {
39422
+ if (codec === void 0) return false;
39423
+ const lower = codec.toLowerCase();
39424
+ if (lower.includes("h265") || lower.includes("hevc")) return false;
39425
+ return lower.includes("h264") || lower.includes("avc");
39426
+ }
39427
+ function plausibleMeasured(value) {
39428
+ if (value === null || !Number.isFinite(value)) return null;
39429
+ return value >= 64 ? value : null;
39430
+ }
39431
+ /**
39432
+ * Map a profile slot onto the camera encoder feeding it, and read that
39433
+ * encoder's configured bitrate.
39434
+ *
39435
+ * The link is the slot's assigned cam-stream: its resolution (and frame rate,
39436
+ * when two encoders share a resolution) identifies which of `main`/`sub`/`ext`
39437
+ * produces it. Vendor-neutral on purpose — the cam-stream ids (`native:main`,
39438
+ * `native:slot-3`, …) are provider strings and matching on them would work for
39439
+ * exactly one provider. An ambiguous match returns `null`.
39440
+ */
39441
+ function publishedRateFor(slot, source, streamParams) {
39442
+ if (streamParams === null) return null;
39443
+ const resolution = source?.resolution ?? slot.resolution;
39444
+ if (resolution === void 0) return null;
39445
+ const byResolution = ENCODER_PROFILE_KEYS.map((key) => encoderConfig(streamParams, key)).filter((cfg) => cfg !== null && cfg.width === resolution.width && cfg.height === resolution.height);
39446
+ if (byResolution.length === 1) return positiveOrNull(byResolution[0]?.bitrate);
39447
+ const fps = source?.fps;
39448
+ if (fps === void 0) return null;
39449
+ const byFps = byResolution.filter((cfg) => cfg !== null && Math.floor(cfg.framerate) === Math.floor(fps));
39450
+ return byFps.length === 1 ? positiveOrNull(byFps[0]?.bitrate) : null;
39451
+ }
39452
+ function encoderConfig(status, key) {
39453
+ return status[key] ?? null;
39454
+ }
39455
+ function positiveOrNull(value) {
39456
+ if (value === void 0 || !Number.isFinite(value) || value <= 0) return null;
39457
+ return value;
39458
+ }
39459
+ var CAM_PROFILES$1 = [
39460
+ "high",
39461
+ "mid",
39462
+ "low"
39463
+ ];
39464
+ /**
39465
+ * `PickedStream.profileId` is the brokerId suffix, which for a profile-keyed
39466
+ * entry IS the profile name. Anything else addresses a raw cam-stream and must
39467
+ * not be coerced into a profile.
39468
+ */
39469
+ function toCamProfile$1(profileId) {
39470
+ return CAM_PROFILES$1.find((p) => p === profileId) ?? null;
39471
+ }
39472
+ //#endregion
39473
+ //#region src/mappers/builders/stream-bitrate-probe.ts
39474
+ /**
39475
+ * Resolve every profile slot's rate. Never throws: an unresolvable slot ends
39476
+ * up `unknown`, which the selector treats as "cannot prove it fits" and
39477
+ * therefore transcodes — the safe direction on the wire.
39478
+ */
39479
+ async function probeProfileBitrates(input) {
39480
+ const { proxy } = input.bctx;
39481
+ const camStreams = await probe$1(() => proxy.cameraStreams?.getCameraStreams({}), "cameraStreams.getCameraStreams", input.log);
39482
+ const streamParams = await probe$1(() => proxy.streamParams?.getStatus({}), "streamParams.getStatus", input.log);
39483
+ const choices = await probe$1(() => proxy.webrtcSession?.listStreams({}), "webrtcSession.listStreams", input.log);
39484
+ return resolveProfileBitrates({
39485
+ slots: input.slots,
39486
+ camStreams: camStreams ?? [],
39487
+ streamParams,
39488
+ choices: choices ?? []
39489
+ });
39490
+ }
39491
+ async function probe$1(call, label, log) {
39492
+ try {
39493
+ const pending = call();
39494
+ if (pending === void 0) {
39495
+ log.info("export-hap: bitrate probe skipped — cap not bound on this device", { meta: { call: label } });
39496
+ return null;
39497
+ }
39498
+ return await pending;
39499
+ } catch (err) {
39500
+ log.warn("export-hap: bitrate probe failed — the slot rate stays UNKNOWN", { meta: {
39501
+ call: label,
39502
+ error: err instanceof Error ? err.message : String(err)
39503
+ } });
39504
+ return null;
39505
+ }
39506
+ }
39507
+ /**
39508
+ * Two outputs from one input: video SRTP and audio SRTP, one process, one
39509
+ * lifetime, one kill signal. ffmpeg-internal stream selection (`-vn` / `-an`
39510
+ * plus per-output codec args) keeps both flowing through it.
39511
+ */
39512
+ function buildSessionFfmpegArgs(input) {
39513
+ return [
39514
+ "-hide_banner",
39515
+ "-loglevel",
39516
+ "warning",
39517
+ ...input.decodeArgs,
39518
+ "-rtsp_transport",
39519
+ "tcp",
39520
+ "-i",
39521
+ input.rtspUrl,
39522
+ "-an",
39523
+ "-map",
39524
+ "0:v:0",
39525
+ ...input.videoArgs,
39526
+ "-payload_type",
39527
+ String(input.videoPayloadType),
39528
+ "-ssrc",
39529
+ String(input.videoSsrcSigned),
39530
+ "-f",
39531
+ "rtp",
39532
+ input.videoTarget,
39533
+ "-vn",
39534
+ "-map",
39535
+ "0:a:0?",
39536
+ "-af",
39537
+ "aresample=async=1000:first_pts=0",
39538
+ "-c:a",
39539
+ "libopus",
39540
+ "-application",
39541
+ "lowdelay",
39542
+ "-frame_duration",
39543
+ String(input.audioPacketTimeMs),
39544
+ "-flags",
39545
+ "+global_header",
39546
+ "-ar",
39547
+ String(input.audioSampleRateKhz * 1e3),
39548
+ "-b:a",
39549
+ `24k`,
39550
+ "-bufsize",
39551
+ `96k`,
39552
+ "-ac",
39553
+ String(1),
39554
+ "-payload_type",
39555
+ String(input.audioPayloadType),
39556
+ "-ssrc",
39557
+ String(input.audioSsrcSigned),
39558
+ "-f",
39559
+ "rtp",
39560
+ input.audioTarget
39561
+ ];
39562
+ }
38332
39563
  /**
38333
39564
  * The resolutions we offer, before rates are attached. Same list the delegate
38334
39565
  * advertised before R2 — only the frame rate changes, so a controller that had
@@ -38467,6 +39698,129 @@ var CAM_PROFILES = [
38467
39698
  function toCamProfile(profileId) {
38468
39699
  return CAM_PROFILES.find((p) => p === profileId) ?? null;
38469
39700
  }
39701
+ /**
39702
+ * How long after spawn an exit still counts as "hardware init failed".
39703
+ *
39704
+ * Same window the ffmpeg decoder addon uses for its own cascade. A hardware
39705
+ * context that cannot be created fails within milliseconds; anything that ran
39706
+ * longer produced no frames for a different reason, and re-spawning it in
39707
+ * software would just hide that reason.
39708
+ */
39709
+ var HW_DECODE_FALLBACK_WINDOW_MS = 4e3;
39710
+ /** Backends whose decode binds to a DRM render node. */
39711
+ var RENDER_NODE_BACKENDS = ["vaapi", "qsv"];
39712
+ /**
39713
+ * Values of the decoder cap's `hwaccel` field that are operator CHOICES rather
39714
+ * than devices.
39715
+ */
39716
+ var HWACCEL_NON_BACKEND_CHOICES = ["auto", "none"];
39717
+ /**
39718
+ * Every backend the decoder cap can publish, taken from the cap's own UI option
39719
+ * list so this module cannot drift from it. A hand-written copy of the union is
39720
+ * exactly the "hand-written cap interface that rots silently" this repo has
39721
+ * been bitten by.
39722
+ */
39723
+ var HWACCEL_BACKENDS = new Set(HWACCEL_OPTIONS.map((option) => option.value).filter((value) => !HWACCEL_NON_BACKEND_CHOICES.includes(value)));
39724
+ /** Is this string a hardware backend, as opposed to `auto`, `none` or junk? */
39725
+ function isHwAccelBackend(value) {
39726
+ return HWACCEL_BACKENDS.has(value);
39727
+ }
39728
+ /**
39729
+ * Decide how this session decodes, and produce the ffmpeg input-side flags.
39730
+ *
39731
+ * Every path that ends in software carries a named reason, because a session
39732
+ * that quietly stopped using the GPU and a session that never had one look
39733
+ * identical in a CPU graph.
39734
+ */
39735
+ function selectHwDecode(input) {
39736
+ if (!input.transcode) return software("pass-through");
39737
+ if (input.hardwareAlreadyFailed === true) return software("hardware-attempt-failed");
39738
+ if (input.reading === null) return software("no-decoder-reading");
39739
+ const chosen = (input.reading.hwaccel ?? "").trim();
39740
+ if (chosen === "none") return software("operator-disabled");
39741
+ if (chosen !== "" && chosen !== "auto") return isHwAccelBackend(chosen) ? hardware(chosen, "operator", input) : software("unrecognised-backend");
39742
+ const probed = (input.reading.probedBestHwaccel ?? "").trim();
39743
+ if (probed === "" || probed === "none") return software("not-probed");
39744
+ return isHwAccelBackend(probed) ? hardware(probed, "probed", input) : software("unrecognised-backend");
39745
+ }
39746
+ /**
39747
+ * Did this ffmpeg exit look like a failed hardware init, rather than a
39748
+ * teardown or a fault software would hit too?
39749
+ *
39750
+ * All five conditions are necessary. Dropping the controller-stop check in
39751
+ * particular would respawn on every normal teardown, because iOS restarts a
39752
+ * session it is not enjoying and that is indistinguishable at the exit code.
39753
+ */
39754
+ function shouldRetryInSoftware(input) {
39755
+ if (!input.usedHardware) return false;
39756
+ if (input.hardwareAlreadyFailed) return false;
39757
+ if (input.stopRequestedByController) return false;
39758
+ if (input.videoPacketsForwarded > 0) return false;
39759
+ return input.runtimeMs <= HW_DECODE_FALLBACK_WINDOW_MS;
39760
+ }
39761
+ function software(reason) {
39762
+ return {
39763
+ kind: "software",
39764
+ reason,
39765
+ args: []
39766
+ };
39767
+ }
39768
+ function hardware(backend, source, input) {
39769
+ return {
39770
+ kind: "hardware",
39771
+ backend,
39772
+ source,
39773
+ args: decodeArgs(backend, input)
39774
+ };
39775
+ }
39776
+ /**
39777
+ * The input-side flags, and nothing else.
39778
+ *
39779
+ * No `-hwaccel_output_format`: the decoded frames have to land in system
39780
+ * memory for libx264 to scale and encode them. Setting it would keep them on
39781
+ * the GPU, which only pays off with a GPU scale filter — and that is the
39782
+ * decoder addon's job, not a two-output SRTP session's.
39783
+ */
39784
+ function decodeArgs(backend, input) {
39785
+ if (backend === "videotoolbox" && input.platform === "darwin") return ["-hwaccel", "auto"];
39786
+ const args = ["-hwaccel", backend];
39787
+ if (RENDER_NODE_BACKENDS.includes(backend)) args.push("-hwaccel_device", input.renderDevice ?? "/dev/dri/renderD128");
39788
+ return args;
39789
+ }
39790
+ //#endregion
39791
+ //#region src/mappers/builders/stream-hwaccel-probe.ts
39792
+ /**
39793
+ * Read this node's decode-hwaccel state, or `null` when nothing answered.
39794
+ *
39795
+ * Never throws. `null` means "we do not know", which
39796
+ * {@link import('./stream-hwaccel.js').selectHwDecode} turns into software —
39797
+ * the safe direction, because a guess here costs the whole stream.
39798
+ */
39799
+ async function probeDecoderHwaccel(input) {
39800
+ const { ctx, log } = input;
39801
+ const nodeId = ctx.kernel?.localNodeId;
39802
+ if (nodeId === void 0 || nodeId.length === 0) {
39803
+ log.warn("export-hap: hwaccel probe skipped — no local node id, decoding in SOFTWARE");
39804
+ return null;
39805
+ }
39806
+ try {
39807
+ const info = await ctx.api.decoder.getInfo.query(void 0, nodePin(nodeId));
39808
+ if (info === null || info === void 0) {
39809
+ log.info("export-hap: hwaccel probe returned nothing — decoding in SOFTWARE", { meta: { nodeId } });
39810
+ return null;
39811
+ }
39812
+ return {
39813
+ hwaccel: info.hwaccel ?? null,
39814
+ probedBestHwaccel: info.probedBestHwaccel ?? null
39815
+ };
39816
+ } catch (err) {
39817
+ log.warn("export-hap: hwaccel probe failed — decoding in SOFTWARE", { meta: {
39818
+ nodeId,
39819
+ error: err instanceof Error ? err.message : String(err)
39820
+ } });
39821
+ return null;
39822
+ }
39823
+ }
38470
39824
  //#endregion
38471
39825
  //#region src/mappers/builders/stream-telemetry.ts
38472
39826
  /**
@@ -38488,6 +39842,12 @@ var ZERO_DROP_COUNTERS = {
38488
39842
  "rtcp-no-srtcp": 0,
38489
39843
  /** Outbound RTCP: building or encrypting the Sender Report failed. */
38490
39844
  "rtcp-encrypt-failed": 0,
39845
+ /** Inbound RTCP: the leg has no SRTCP context, so the controller's report is unreadable. */
39846
+ "inbound-rtcp-no-srtcp": 0,
39847
+ /** Inbound RTCP: SRTCP decryption of a controller packet failed. */
39848
+ "inbound-rtcp-decrypt-failed": 0,
39849
+ /** Inbound RTCP: the decrypted bytes did not parse as RTCP. */
39850
+ "inbound-rtcp-parse-failed": 0,
38491
39851
  /** Upstream: packet shorter than an RTP header. */
38492
39852
  "upstream-short-packet": 0,
38493
39853
  /** Upstream: no inbound SRTP context (init failed at prepareStream). */
@@ -38543,6 +39903,21 @@ function classifyInboundPacket(packet) {
38543
39903
  return "rtp";
38544
39904
  }
38545
39905
  /**
39906
+ * Loss at or above this is not a start-up transient.
39907
+ *
39908
+ * A stream whose first key frame is slow reliably produces one report in the
39909
+ * low single digits; a transmit-side defect produces tens of percent, because
39910
+ * whatever makes a packet unusable makes most packets unusable. The threshold
39911
+ * sits between those two regimes rather than at any measured boundary — it is
39912
+ * a reading aid, and the raw `worstFractionLostPct` is always in the line
39913
+ * beside it.
39914
+ */
39915
+ var TRANSMIT_SUSPECT_FRACTION_LOST_PCT = 5;
39916
+ function lossVerdict(tally) {
39917
+ if (tally.blocksParsed === 0 || tally.worstFractionLostPct === null) return "no-reports";
39918
+ return tally.worstFractionLostPct >= TRANSMIT_SUSPECT_FRACTION_LOST_PCT ? "transmit-suspect" : "decode-suspect";
39919
+ }
39920
+ /**
38546
39921
  * Build the meta for `export-hap: stream session summary` — the single line a
38547
39922
  * future session greps to answer "why did this session die".
38548
39923
  */
@@ -38563,7 +39938,13 @@ function summariseSession(snapshot) {
38563
39938
  selectedBrokerId: slot?.brokerId ?? null,
38564
39939
  advertisedFps: slot?.advertisedFps ?? null,
38565
39940
  advertisedFpsSource: slot?.advertisedFpsSource ?? null,
39941
+ deliveredFps: slot?.deliveredFps ?? null,
38566
39942
  transcode: slot?.transcode ?? null,
39943
+ fitReason: slot?.fitReason ?? null,
39944
+ slotPublishedKbps: slot?.publishedKbps ?? null,
39945
+ slotMeasuredKbps: slot?.measuredKbps ?? null,
39946
+ encodeBudgetKbps: slot?.budgetKbps ?? null,
39947
+ fitNotes: slot?.fitNotes ?? [],
38567
39948
  videoPacketsForwarded: snapshot.videoPacketsForwarded,
38568
39949
  audioPacketsForwarded: snapshot.audioPacketsForwarded,
38569
39950
  videoRtcpSrSent: snapshot.videoRtcpSrSent,
@@ -38574,6 +39955,10 @@ function summariseSession(snapshot) {
38574
39955
  audioRtpReceived: snapshot.audioRtpReceived,
38575
39956
  videoGate: formatRtcpGate(snapshot.videoGate),
38576
39957
  audioGate: formatRtcpGate(snapshot.audioGate),
39958
+ videoReceiverReports: snapshot.videoReceiverReports,
39959
+ audioReceiverReports: snapshot.audioReceiverReports,
39960
+ videoLossVerdict: lossVerdict(snapshot.videoReceiverReports),
39961
+ audioLossVerdict: lossVerdict(snapshot.audioReceiverReports),
38577
39962
  mediaStarved: snapshot.videoPacketsForwarded === 0,
38578
39963
  drops: nonZeroDrops(snapshot.drops)
38579
39964
  };
@@ -38593,8 +39978,17 @@ var SRTP_SALT_LEN = 14;
38593
39978
  * make this one act.
38594
39979
  */
38595
39980
  var SESSION_HEARTBEAT_MS = 5e3;
38596
- var OPUS_BITRATE_KBPS = 24;
38597
- var OPUS_CHANNELS = 1;
39981
+ /**
39982
+ * Floor between two `export-hap: controller receiver report` lines on one leg.
39983
+ *
39984
+ * The controller sends several Receiver Reports a second — 21 and 34 in two
39985
+ * ~10 s sessions on 2026-08-06 — and every one of them at `info` would drown
39986
+ * the very line it is meant to make findable. The FIRST report on each leg is
39987
+ * always logged, in full and under its own message; after that this throttles
39988
+ * to the heartbeat's cadence so a session still produces a running record of
39989
+ * what the controller thinks without becoming one.
39990
+ */
39991
+ var RECEIVER_REPORT_LOG_INTERVAL_MS = 5e3;
38598
39992
  function buildCameraStreamingDelegate(bctx, advertised) {
38599
39993
  const { ctx, numericDeviceId } = bctx;
38600
39994
  const log = ctx.logger.withTags({ deviceId: numericDeviceId });
@@ -38702,7 +40096,7 @@ async function prepareStream(request, sessions, bctx) {
38702
40096
  },
38703
40097
  profile: import_src.ProtectionProfileAes128CmHmacSha1_80
38704
40098
  });
38705
- const makeOutSrtcp = (key, salt) => new import_src.SrtcpSession({
40099
+ const makeSrtcp = (key, salt) => new import_src.SrtcpSession({
38706
40100
  keys: {
38707
40101
  localMasterKey: key,
38708
40102
  localMasterSalt: salt,
@@ -38713,13 +40107,17 @@ async function prepareStream(request, sessions, bctx) {
38713
40107
  });
38714
40108
  let videoOutSrtp;
38715
40109
  let videoOutSrtcp;
40110
+ let videoInSrtcp;
38716
40111
  let audioOutSrtp;
38717
40112
  let audioOutSrtcp;
40113
+ let audioInSrtcp;
38718
40114
  try {
38719
40115
  videoOutSrtp = makeOutSrtp(request.video.srtp_key, request.video.srtp_salt);
38720
- videoOutSrtcp = makeOutSrtcp(request.video.srtp_key, request.video.srtp_salt);
40116
+ videoOutSrtcp = makeSrtcp(request.video.srtp_key, request.video.srtp_salt);
40117
+ videoInSrtcp = makeSrtcp(request.video.srtp_key, request.video.srtp_salt);
38721
40118
  audioOutSrtp = makeOutSrtp(request.audio.srtp_key, request.audio.srtp_salt);
38722
- audioOutSrtcp = makeOutSrtcp(request.audio.srtp_key, request.audio.srtp_salt);
40119
+ audioOutSrtcp = makeSrtcp(request.audio.srtp_key, request.audio.srtp_salt);
40120
+ audioInSrtcp = makeSrtcp(request.audio.srtp_key, request.audio.srtp_salt);
38723
40121
  } catch (err) {
38724
40122
  closeSocket(videoUdp);
38725
40123
  closeSocket(audioUdp);
@@ -38761,6 +40159,10 @@ async function prepareStream(request, sessions, bctx) {
38761
40159
  audioRtcpReceived: 0,
38762
40160
  videoRtpReceived: 0,
38763
40161
  audioRtpReceived: 0,
40162
+ videoReceiverReports: emptyReceiverReportTally(),
40163
+ audioReceiverReports: emptyReceiverReportTally(),
40164
+ videoRrLoggedAt: 0,
40165
+ audioRrLoggedAt: 0,
38764
40166
  videoGate: null,
38765
40167
  audioGate: null,
38766
40168
  heartbeat: null,
@@ -38772,6 +40174,7 @@ async function prepareStream(request, sessions, bctx) {
38772
40174
  videoLoopUdp,
38773
40175
  videoOutSrtp,
38774
40176
  videoOutSrtcp,
40177
+ videoInSrtcp,
38775
40178
  videoOutPacketCount: 0,
38776
40179
  videoOutOctetCount: 0,
38777
40180
  videoOutLastRtpTimestamp: 0,
@@ -38786,6 +40189,7 @@ async function prepareStream(request, sessions, bctx) {
38786
40189
  audioLoopUdp,
38787
40190
  audioOutSrtp,
38788
40191
  audioOutSrtcp,
40192
+ audioInSrtcp,
38789
40193
  audioSendGate: null,
38790
40194
  ipVersion,
38791
40195
  ffmpeg: null,
@@ -38962,6 +40366,111 @@ function countInbound(session, leg, packet, log) {
38962
40366
  leg,
38963
40367
  bytes: packet.length
38964
40368
  } });
40369
+ if (kind === "rtcp") readControllerRtcp(session, leg, packet, log);
40370
+ }
40371
+ /** Fold a decrypted RTCP datagram into the leg's tally. Immutable, per `ReceiverReportTally`. */
40372
+ function storeReceiverReports(session, leg, tally) {
40373
+ if (leg === "video") session.videoReceiverReports = tally;
40374
+ else session.audioReceiverReports = tally;
40375
+ }
40376
+ /**
40377
+ * Decrypt one inbound RTCP datagram and read the controller's Receiver Report.
40378
+ *
40379
+ * This is the datum the 2026-08-06 telemetry left missing. That round proved
40380
+ * iOS DOES send us RTCP on the video leg — 21 and 34 packets across two
40381
+ * sessions, the gate opening on a real controller packet at ~550 ms — which
40382
+ * killed the long-standing "iOS never probes us" belief. But counting packets
40383
+ * says only that the controller spoke; a Receiver Report says WHAT it said,
40384
+ * and that splits the remaining causes into two disjoint families that nothing
40385
+ * else in this system can tell apart. See `LossVerdict`.
40386
+ *
40387
+ * Runs on a UDP `message` handler, so nothing here may throw: a controller
40388
+ * that sends one deformed datagram must not take the session with it. Every
40389
+ * failure books a named drop and increments `unreadable`, because a report we
40390
+ * could not read and a report that never came must never look alike.
40391
+ */
40392
+ function readControllerRtcp(session, leg, packet, log) {
40393
+ const srtcp = leg === "video" ? session.videoInSrtcp : session.audioInSrtcp;
40394
+ const tally = leg === "video" ? session.videoReceiverReports : session.audioReceiverReports;
40395
+ if (!srtcp) {
40396
+ drop(session, "inbound-rtcp-no-srtcp");
40397
+ storeReceiverReports(session, leg, recordUnreadableRtcp(tally));
40398
+ logUnreadableRtcp(session, leg, "no-srtcp-context", log);
40399
+ return;
40400
+ }
40401
+ let plaintext;
40402
+ try {
40403
+ plaintext = srtcp.decrypt(packet);
40404
+ } catch (err) {
40405
+ drop(session, "inbound-rtcp-decrypt-failed");
40406
+ storeReceiverReports(session, leg, recordUnreadableRtcp(tally));
40407
+ logUnreadableRtcp(session, leg, `decrypt: ${errMsg$8(err)}`, log);
40408
+ return;
40409
+ }
40410
+ const outcome = ingestDecryptedRtcp(plaintext, tally);
40411
+ storeReceiverReports(session, leg, outcome.tally);
40412
+ if (outcome.failure !== null) {
40413
+ drop(session, "inbound-rtcp-parse-failed");
40414
+ logUnreadableRtcp(session, leg, `parse: ${outcome.failure}`, log);
40415
+ return;
40416
+ }
40417
+ const isFirst = tally.reportsParsed === 0 && outcome.tally.reportsParsed > 0;
40418
+ logReceiverReports(session, leg, outcome.reports, isFirst, log);
40419
+ }
40420
+ /** Throttled per leg — a controller sending nothing but garbage must not become the log. */
40421
+ function logUnreadableRtcp(session, leg, reason, log) {
40422
+ const tally = leg === "video" ? session.videoReceiverReports : session.audioReceiverReports;
40423
+ if (!shouldLogReceiverReport(session, leg)) return;
40424
+ log.warn("export-hap: inbound RTCP could not be read", { meta: {
40425
+ sessionId: session.sessionId,
40426
+ leg,
40427
+ reason,
40428
+ unreadable: tally.unreadable
40429
+ } });
40430
+ }
40431
+ /**
40432
+ * True at most once per `RECEIVER_REPORT_LOG_INTERVAL_MS` per leg. Stamps the
40433
+ * leg on the way out so the caller cannot forget to.
40434
+ */
40435
+ function shouldLogReceiverReport(session, leg) {
40436
+ const now = Date.now();
40437
+ if (now < (leg === "video" ? session.videoRrLoggedAt : session.audioRrLoggedAt) + RECEIVER_REPORT_LOG_INTERVAL_MS) return false;
40438
+ if (leg === "video") session.videoRrLoggedAt = now;
40439
+ else session.audioRrLoggedAt = now;
40440
+ return true;
40441
+ }
40442
+ /**
40443
+ * Emit the controller's own numbers.
40444
+ *
40445
+ * The FIRST report on a leg gets its own message: that one arriving at all is
40446
+ * the proof iOS is engaged with the stream, and it was worth a year of
40447
+ * argument. Everything after it is throttled to the heartbeat's cadence.
40448
+ */
40449
+ function logReceiverReports(session, leg, reports, isFirst, log) {
40450
+ const tally = leg === "video" ? session.videoReceiverReports : session.audioReceiverReports;
40451
+ const block = reports.flatMap((report) => report.blocks).at(-1);
40452
+ if (!block) return;
40453
+ const reporter = reports.find((report) => report.blocks.length > 0);
40454
+ const meta = {
40455
+ sessionId: session.sessionId,
40456
+ leg,
40457
+ reporterSsrc: reporter?.reporterSsrc ?? null,
40458
+ aboutSsrc: block.aboutSsrc,
40459
+ fractionLostPct: block.fractionLostPct,
40460
+ cumulativePacketsLost: block.cumulativePacketsLost,
40461
+ extendedHighestSequence: block.extendedHighestSequence,
40462
+ jitter: block.jitter,
40463
+ delaySinceLastSrMs: block.delaySinceLastSrMs,
40464
+ reportsParsed: tally.reportsParsed,
40465
+ worstFractionLostPct: tally.worstFractionLostPct
40466
+ };
40467
+ if (isFirst) {
40468
+ shouldLogReceiverReport(session, leg);
40469
+ log.info("export-hap: FIRST RTCP Receiver Report from controller — iOS is receiving and reporting", { meta });
40470
+ return;
40471
+ }
40472
+ if (!shouldLogReceiverReport(session, leg)) return;
40473
+ log.info("export-hap: controller receiver report", { meta });
38965
40474
  }
38966
40475
  /** Snapshot every counter into the summary meta. */
38967
40476
  function sessionSummaryMeta(session) {
@@ -38981,6 +40490,8 @@ function sessionSummaryMeta(session) {
38981
40490
  audioRtpReceived: session.audioRtpReceived,
38982
40491
  videoGate: session.videoGate,
38983
40492
  audioGate: session.audioGate,
40493
+ videoReceiverReports: session.videoReceiverReports,
40494
+ audioReceiverReports: session.audioReceiverReports,
38984
40495
  drops: session.drops,
38985
40496
  ffmpegExit: session.ffmpegExit,
38986
40497
  stopRequestedByController: session.stopRequestedByController
@@ -39016,7 +40527,15 @@ function stopHeartbeat(session) {
39016
40527
  * a HomeKit session died. It carries the negotiated parameters, the slot we
39017
40528
  * dialled and the rate we had promised for it, packet counts in both
39018
40529
  * directions on both legs, each leg's gate outcome, ffmpeg's exit, who asked
39019
- * for the teardown, and every named drop.
40530
+ * for the teardown, every named drop — and, since 2026-08-06, what the
40531
+ * controller itself reported receiving.
40532
+ *
40533
+ * Read `videoLossVerdict` first. `transmit-suspect` says the controller is not
40534
+ * getting our packets intact and nothing past the wire matters;
40535
+ * `decode-suspect` says it got them and rendered nothing anyway;
40536
+ * `no-reports` says we still cannot tell, and `videoReceiverReports.unreadable`
40537
+ * then distinguishes "the controller said nothing" from "we could not read what
40538
+ * it said".
39020
40539
  *
39021
40540
  * Emitted from the ffmpeg `exit` handler — the only place the exit code is
39022
40541
  * known — and directly from the teardown paths when there is no ffmpeg to wait
@@ -39329,11 +40848,24 @@ async function startFfmpegForSession(bctx, session, sessionId, video, advertised
39329
40848
  throw new Error(`export-hap: no profile RTSP entries for device ${numericDeviceId}`);
39330
40849
  }
39331
40850
  const pref = options.hapDeviceSettings.streamPreference;
39332
- const picked = pickPreferredRtspEntry(entries, pref, numericDeviceId, { targetResolution: {
39333
- width: video.width,
39334
- height: video.height
39335
- } });
39336
- if (!picked) {
40851
+ const brokerStreams = await proxy.cameraStreams?.getBrokerStreams({}) ?? [];
40852
+ const bitrates = await probeProfileBitrates({
40853
+ bctx,
40854
+ slots: brokerStreams,
40855
+ log: startLog
40856
+ });
40857
+ const fit = selectStreamForBudget({
40858
+ entries: withSlotCodecs(entries, brokerStreams),
40859
+ deviceId: numericDeviceId,
40860
+ pref,
40861
+ targetResolution: {
40862
+ width: video.width,
40863
+ height: video.height
40864
+ },
40865
+ negotiatedMaxBitrateKbps: video.max_bit_rate,
40866
+ bitrates
40867
+ });
40868
+ if (fit === null) {
39337
40869
  startLog.warn("export-hap: stream start DROPPED — no ENABLED profile RTSP entry", { meta: {
39338
40870
  sessionId,
39339
40871
  streamPreference: pref,
@@ -39342,14 +40874,18 @@ async function startFfmpegForSession(bctx, session, sessionId, video, advertised
39342
40874
  } });
39343
40875
  throw new Error(`export-hap: no enabled RTSP entries for device ${numericDeviceId}`);
39344
40876
  }
40877
+ const picked = fit.picked;
39345
40878
  const rtspUrl = picked.url;
39346
- const slot = (await proxy.cameraStreams?.getBrokerStreams({}) ?? []).find((s) => s.brokerId === picked.brokerId);
40879
+ const slot = brokerStreams.find((s) => s.profile === fit.profile);
39347
40880
  const codec = (picked.codec ?? slot?.codec ?? "").toLowerCase();
39348
- const needsTranscode = codec.includes("h265") || codec.includes("hevc");
40881
+ const needsTranscode = fit.kind === "transcode";
39349
40882
  const pickedProfile = toKnownProfile(picked.profileId);
39350
40883
  const resolvedFps = pickedProfile === null ? void 0 : advertised.fpsByProfile.get(pickedProfile);
39351
40884
  const advertisedFps = resolvedFps?.fps ?? video.fps;
39352
40885
  const advertisedFpsSource = resolvedFps?.source ?? "assumed";
40886
+ const deliveredFps = needsTranscode ? deliverableFps(video.fps, resolvedFps?.fps ?? null) : advertisedFps;
40887
+ const slotEvidence = pickedProfile === null ? void 0 : bitrates.get(pickedProfile);
40888
+ const fitNotes = formatFitNotes(fit.notes);
39353
40889
  session.selectedSlot = {
39354
40890
  profile: pickedProfile,
39355
40891
  brokerId: picked.brokerId,
@@ -39357,14 +40893,33 @@ async function startFfmpegForSession(bctx, session, sessionId, video, advertised
39357
40893
  height: picked.resolution?.height ?? null,
39358
40894
  advertisedFps,
39359
40895
  advertisedFpsSource,
40896
+ deliveredFps,
39360
40897
  codec: codec.length > 0 ? codec : "unknown",
39361
- transcode: needsTranscode
40898
+ transcode: needsTranscode,
40899
+ fitReason: fit.reason,
40900
+ publishedKbps: slotEvidence?.publishedKbps ?? null,
40901
+ measuredKbps: slotEvidence?.measuredKbps ?? null,
40902
+ budgetKbps: fit.budgetKbps,
40903
+ fitNotes
39362
40904
  };
39363
- if (advertisedFps !== video.fps) startLog.warn("export-hap: negotiated fps does NOT match the slot we are about to dial", { meta: {
40905
+ startLog.info("export-hap: stream bitrate fit resolved", { meta: {
40906
+ sessionId,
40907
+ decision: fit.kind,
40908
+ reason: fit.reason,
40909
+ negotiatedMaxBitrateKbps: video.max_bit_rate,
40910
+ encodeBudgetKbps: fit.budgetKbps,
40911
+ profile: pickedProfile,
40912
+ brokerId: picked.brokerId,
40913
+ slotPublishedKbps: slotEvidence?.publishedKbps ?? null,
40914
+ slotMeasuredKbps: slotEvidence?.measuredKbps ?? null,
40915
+ candidates: fitNotes
40916
+ } });
40917
+ if (deliveredFps !== video.fps) startLog.warn("export-hap: negotiated fps does NOT match the rate we are about to deliver", { meta: {
39364
40918
  sessionId,
39365
40919
  negotiatedFps: video.fps,
39366
40920
  slotFps: advertisedFps,
39367
40921
  slotFpsSource: advertisedFpsSource,
40922
+ deliveredFps,
39368
40923
  profile: pickedProfile,
39369
40924
  brokerId: picked.brokerId,
39370
40925
  transcode: needsTranscode
@@ -39373,101 +40928,91 @@ async function startFfmpegForSession(bctx, session, sessionId, video, advertised
39373
40928
  const audioLoopPort = session.audioLoopUdp.address().port;
39374
40929
  const videoTarget = `rtp://127.0.0.1:${videoLoopPort}?pkt_size=${video.mtu}`;
39375
40930
  const audioTarget = `rtp://127.0.0.1:${audioLoopPort}?pkt_size=${video.mtu}`;
39376
- const videoArgs = needsTranscode ? [
39377
- "-c:v",
39378
- "libx264",
39379
- "-preset",
39380
- "ultrafast",
39381
- "-tune",
39382
- "zerolatency",
39383
- "-pix_fmt",
39384
- "yuv420p",
39385
- "-r",
39386
- String(video.fps),
39387
- "-s",
39388
- `${video.width}x${video.height}`,
39389
- "-b:v",
39390
- `${video.max_bit_rate}k`,
39391
- "-bufsize",
39392
- `${video.max_bit_rate * 2}k`,
39393
- "-maxrate",
39394
- `${video.max_bit_rate}k`,
39395
- "-profile:v",
39396
- "baseline",
39397
- "-level",
39398
- "3.1"
39399
- ] : [
39400
- "-c:v",
39401
- "copy",
39402
- "-bsf:v",
39403
- "dump_extra"
39404
- ];
40931
+ const videoArgs = buildVideoEncodeArgs({
40932
+ transcode: needsTranscode,
40933
+ width: video.width,
40934
+ height: video.height,
40935
+ fps: deliveredFps,
40936
+ budgetKbps: fit.budgetKbps
40937
+ });
40938
+ const hwDecode = selectHwDecode({
40939
+ transcode: needsTranscode,
40940
+ reading: needsTranscode ? await probeDecoderHwaccel({
40941
+ ctx,
40942
+ log: startLog
40943
+ }) : null,
40944
+ platform: process.platform
40945
+ });
40946
+ logDecodePath(startLog, sessionId, hwDecode, needsTranscode);
39405
40947
  const videoSsrcSigned = session.videoSsrc | 0;
39406
40948
  const audioSsrcSigned = video.audio_ssrc | 0;
39407
- const args = [
39408
- "-hide_banner",
39409
- "-loglevel",
39410
- "warning",
39411
- "-rtsp_transport",
39412
- "tcp",
39413
- "-i",
40949
+ const buildArgs = (decodeArgs) => buildSessionFfmpegArgs({
40950
+ decodeArgs,
39414
40951
  rtspUrl,
39415
- "-an",
39416
- "-map",
39417
- "0:v:0",
39418
- ...videoArgs,
39419
- "-payload_type",
39420
- String(video.pt),
39421
- "-ssrc",
39422
- String(videoSsrcSigned),
39423
- "-f",
39424
- "rtp",
40952
+ videoArgs,
39425
40953
  videoTarget,
39426
- "-vn",
39427
- "-map",
39428
- "0:a:0?",
39429
- "-af",
39430
- "aresample=async=1000:first_pts=0",
39431
- "-c:a",
39432
- "libopus",
39433
- "-application",
39434
- "lowdelay",
39435
- "-frame_duration",
39436
- String(video.packet_time ?? 20),
39437
- "-flags",
39438
- "+global_header",
39439
- "-ar",
39440
- String((video.sample_rate ?? 16) * 1e3),
39441
- "-b:a",
39442
- `${OPUS_BITRATE_KBPS}k`,
39443
- "-bufsize",
39444
- `${OPUS_BITRATE_KBPS * 4}k`,
39445
- "-ac",
39446
- String(OPUS_CHANNELS),
39447
- "-payload_type",
39448
- String(video.audio_pt),
39449
- "-ssrc",
39450
- String(audioSsrcSigned),
39451
- "-f",
39452
- "rtp",
39453
- audioTarget
39454
- ];
39455
- const log = ctx.logger.withTags({ deviceId: numericDeviceId });
39456
- const proc = spawn("ffmpeg", args, { stdio: [
39457
- "ignore",
39458
- "ignore",
39459
- "pipe"
39460
- ] });
39461
- session.ffmpeg = proc;
39462
- proc.stderr?.on("data", (chunk) => {
39463
- const line = chunk.toString("utf8").trim();
39464
- if (!line) return;
39465
- log.info("export-hap: ffmpeg", { meta: {
39466
- sessionId,
39467
- line
39468
- } });
40954
+ audioTarget,
40955
+ videoPayloadType: video.pt,
40956
+ videoSsrcSigned,
40957
+ audioPayloadType: video.audio_pt,
40958
+ audioSsrcSigned,
40959
+ audioPacketTimeMs: video.packet_time ?? 20,
40960
+ audioSampleRateKhz: video.sample_rate ?? 16
39469
40961
  });
39470
- proc.once("exit", (code, signal) => {
40962
+ const log = ctx.logger.withTags({ deviceId: numericDeviceId });
40963
+ let hardwareAlreadyFailed = false;
40964
+ const spawnFfmpeg = (decision) => {
40965
+ const spawnedAtMs = Date.now();
40966
+ const usedHardware = decision.kind === "hardware";
40967
+ const proc = spawn("ffmpeg", buildArgs(decision.args), { stdio: [
40968
+ "ignore",
40969
+ "ignore",
40970
+ "pipe"
40971
+ ] });
40972
+ session.ffmpeg = proc;
40973
+ proc.stderr?.on("data", (chunk) => {
40974
+ const line = chunk.toString("utf8").trim();
40975
+ if (!line) return;
40976
+ log.info("export-hap: ffmpeg", { meta: {
40977
+ sessionId,
40978
+ line
40979
+ } });
40980
+ });
40981
+ proc.once("exit", (code, signal) => {
40982
+ if (shouldRetryInSoftware({
40983
+ usedHardware,
40984
+ hardwareAlreadyFailed,
40985
+ stopRequestedByController: session.stopRequestedByController,
40986
+ videoPacketsForwarded: session.videoPacketsForwarded,
40987
+ runtimeMs: Date.now() - spawnedAtMs
40988
+ })) {
40989
+ hardwareAlreadyFailed = true;
40990
+ log.warn("export-hap: hardware decode FAILED at init — respawning ffmpeg in software", { meta: {
40991
+ sessionId,
40992
+ backend: decision.kind === "hardware" ? decision.backend : null,
40993
+ backendSource: decision.kind === "hardware" ? decision.source : null,
40994
+ code,
40995
+ signal,
40996
+ runtimeMs: Date.now() - spawnedAtMs
40997
+ } });
40998
+ if (session.ffmpeg === proc) session.ffmpeg = null;
40999
+ spawnFfmpeg({
41000
+ kind: "software",
41001
+ reason: "hardware-attempt-failed",
41002
+ args: []
41003
+ });
41004
+ return;
41005
+ }
41006
+ onFfmpegExit(proc, code, signal);
41007
+ });
41008
+ proc.once("error", (err) => {
41009
+ log.warn("export-hap: ffmpeg spawn failed", { meta: {
41010
+ sessionId,
41011
+ error: err.message
41012
+ } });
41013
+ });
41014
+ };
41015
+ const onFfmpegExit = (proc, code, signal) => {
39471
41016
  session.ffmpegExit = {
39472
41017
  code,
39473
41018
  signal
@@ -39484,13 +41029,8 @@ async function startFfmpegForSession(bctx, session, sessionId, video, advertised
39484
41029
  else log.warn("export-hap: ffmpeg exited without a controller stop", { meta });
39485
41030
  if (session.ffmpeg === proc) session.ffmpeg = null;
39486
41031
  logSessionSummary(session, log, session.teardownTrigger ?? "ffmpeg-exit");
39487
- });
39488
- proc.once("error", (err) => {
39489
- log.warn("export-hap: ffmpeg spawn failed", { meta: {
39490
- sessionId,
39491
- error: err.message
39492
- } });
39493
- });
41032
+ };
41033
+ spawnFfmpeg(hwDecode);
39494
41034
  log.info("export-hap: stream started", { meta: {
39495
41035
  sessionId,
39496
41036
  transcode: needsTranscode,
@@ -39501,8 +41041,38 @@ async function startFfmpegForSession(bctx, session, sessionId, video, advertised
39501
41041
  negotiated: `${video.width}x${video.height}@${video.fps} ${video.max_bit_rate}kbps`,
39502
41042
  slotFps: advertisedFps,
39503
41043
  slotFpsSource: advertisedFpsSource,
41044
+ deliveredFps,
41045
+ fitReason: fit.reason,
41046
+ encodeBudgetKbps: fit.budgetKbps,
39504
41047
  audioCodec: "opus",
39505
- audioBitrateKbps: OPUS_BITRATE_KBPS
41048
+ audioBitrateKbps: 24,
41049
+ videoDecode: hwDecode.kind === "hardware" ? hwDecode.backend : "software"
41050
+ } });
41051
+ }
41052
+ /**
41053
+ * Say which decode path was resolved, and WHY, once per session.
41054
+ *
41055
+ * At `info` on purpose — Loki carries `info+`, and "did this session use the
41056
+ * GPU" is the first question anyone asks of a transcoding hub. The software
41057
+ * branch is the one that must never be silent: it is a real cost being paid,
41058
+ * and every reason it can be reached is a different fix.
41059
+ */
41060
+ function logDecodePath(log, sessionId, decision, transcode) {
41061
+ if (decision.kind === "hardware") {
41062
+ log.info("export-hap: video decode path resolved — HARDWARE", { meta: {
41063
+ sessionId,
41064
+ backend: decision.backend,
41065
+ backendSource: decision.source,
41066
+ decodeArgs: decision.args
41067
+ } });
41068
+ return;
41069
+ }
41070
+ const level = transcode ? "warn" : "info";
41071
+ const message = level === "warn" ? "export-hap: video decode path resolved — SOFTWARE, this transcode costs a core" : "export-hap: video decode path resolved — none needed";
41072
+ log[level](message, { meta: {
41073
+ sessionId,
41074
+ reason: decision.reason,
41075
+ transcode
39506
41076
  } });
39507
41077
  }
39508
41078
  /**
@@ -39727,7 +41297,7 @@ async function buildIntercom(input) {
39727
41297
  var RESET_DEBOUNCE_MS = 5e3;
39728
41298
  async function buildMotionSensor(bctx) {
39729
41299
  const { ctx, accessory, proxy, numericDeviceId, displayName } = bctx;
39730
- const motionService = accessory.addService(Service.MotionSensor, displayName);
41300
+ const motionService = accessory.addService(Service.MotionSensor, hapServiceName([displayName], `Camera ${numericDeviceId}`));
39731
41301
  motionService.setCharacteristic(Characteristic.MotionDetected, false);
39732
41302
  try {
39733
41303
  const detected = await proxy.motion?.isDetected({});
@@ -39782,12 +41352,12 @@ function errMsg$6(err) {
39782
41352
  * camera-enabled switch — distinct from privacy-mask).
39783
41353
  */
39784
41354
  async function buildPrivacySwitch(bctx) {
39785
- const { ctx, accessory, proxy, numericDeviceId } = bctx;
41355
+ const { ctx, accessory, proxy, numericDeviceId, displayName } = bctx;
39786
41356
  const log = ctx.logger.withTags({ deviceId: numericDeviceId });
39787
41357
  const subtype = "privacy-mask";
39788
- const configuredName = "Privacy";
39789
- const service = accessory.addService(Service.Switch, configuredName, subtype);
39790
- service.setCharacteristic(Characteristic.ConfiguredName, configuredName);
41358
+ const serviceName = privacyServiceName(displayName);
41359
+ const service = accessory.addService(Service.Switch, serviceName, subtype);
41360
+ service.setCharacteristic(Characteristic.Name, serviceName);
39791
41361
  try {
39792
41362
  const status = await proxy.privacyMask?.getStatus({});
39793
41363
  if (status && typeof status.enabled === "boolean") service.updateCharacteristic(Characteristic.On, status.enabled);
@@ -39877,18 +41447,23 @@ function ptzPresetLabel(presetName) {
39877
41447
  * `proxy.ptzAutotrack.setEnabled({enabled})`. Initial value is
39878
41448
  * hydrated from `getStatus({})`.
39879
41449
  *
39880
- * Naming: each switch uses a BARE per-action label ("Preset stanza",
39881
- * "Pan Left", "Autotrack") set as BOTH the service name AND its
39882
- * `ConfiguredName`, mirroring `child-switch.ts` / `privacy-switch.ts`.
39883
- * iOS Home renders sibling services on an accessory by their
39884
- * `ConfiguredName`. The old `${displayName} — <action>` form (em-dash
39885
- * U+2014 + redundant camera prefix) was rejected by HAP-NodeJS as an
39886
- * invalid `Name` characteristic, so iOS discarded it and showed generic
39887
- * "Interruttore N".
41450
+ * Naming: `<camera> <action>` "Videocamera ingresso Preset stanza" — built
41451
+ * by `ptzServiceName` and written to the `Name` characteristic.
41452
+ *
41453
+ * TWO separate naming defects have been through this file, and both are fixed
41454
+ * by that one line:
41455
+ * - `${displayName} <action>` embedded an em-dash (U+2014), which is
41456
+ * outside Apple's permitted set. hap-nodejs' `checkName` warned, iOS
41457
+ * discarded the name and showed "Interruttore N". A previous round dropped
41458
+ * the camera prefix along with the em-dash; only the em-dash was the fault.
41459
+ * - The bare label that replaced it was then written to `ConfiguredName`,
41460
+ * which `Service.Switch` does not list, so hap-nodejs rejected the
41461
+ * characteristic outright — SIX rejections per PTZ camera per build, never
41462
+ * reported because only the two switches on the non-PTZ camera were noticed.
39888
41463
  */
39889
41464
  var MOMENTARY_RESET_MS = 1e3;
39890
41465
  async function buildPtz(bctx) {
39891
- const { ctx, accessory, proxy, numericDeviceId, options } = bctx;
41466
+ const { ctx, accessory, proxy, numericDeviceId, displayName, options } = bctx;
39892
41467
  const log = ctx.logger.withTags({ deviceId: numericDeviceId });
39893
41468
  const timers = /* @__PURE__ */ new Set();
39894
41469
  const armReset = (cb, delay) => {
@@ -39900,10 +41475,10 @@ async function buildPtz(bctx) {
39900
41475
  };
39901
41476
  const presets = await readPresets(bctx);
39902
41477
  for (const preset of presets) {
39903
- const label = ptzPresetLabel(preset.name);
41478
+ const label = ptzServiceName(displayName, ptzPresetLabel(preset.name));
39904
41479
  const subtype = `ptz-preset-${preset.id}`;
39905
41480
  const service = accessory.addService(Service.Switch, label, subtype);
39906
- service.setCharacteristic(Characteristic.ConfiguredName, label);
41481
+ service.setCharacteristic(Characteristic.Name, label);
39907
41482
  service.getCharacteristic(Characteristic.On).onSet(async (value) => {
39908
41483
  if (value !== true) return;
39909
41484
  try {
@@ -39918,9 +41493,9 @@ async function buildPtz(bctx) {
39918
41493
  });
39919
41494
  }
39920
41495
  if (proxy.ptz) for (const dir of PTZ_DIRECTIONS) {
39921
- const label = dir.label;
41496
+ const label = ptzServiceName(displayName, dir.label);
39922
41497
  const service = accessory.addService(Service.Switch, label, dir.subtype);
39923
- service.setCharacteristic(Characteristic.ConfiguredName, label);
41498
+ service.setCharacteristic(Characteristic.Name, label);
39924
41499
  service.getCharacteristic(Characteristic.On).onSet(async (value) => {
39925
41500
  if (value !== true) return;
39926
41501
  try {
@@ -39964,12 +41539,12 @@ async function readPresets(bctx) {
39964
41539
  }
39965
41540
  }
39966
41541
  async function tryBuildAutotrack(bctx) {
39967
- const { ctx, accessory, proxy, numericDeviceId } = bctx;
41542
+ const { ctx, accessory, proxy, numericDeviceId, displayName } = bctx;
39968
41543
  if (!proxy.ptzAutotrack) return { async dispose() {} };
39969
41544
  const log = ctx.logger.withTags({ deviceId: numericDeviceId });
39970
- const label = PTZ_AUTOTRACK_LABEL;
41545
+ const label = ptzServiceName(displayName, PTZ_AUTOTRACK_LABEL);
39971
41546
  const service = accessory.addService(Service.Switch, label, "ptz-autotrack");
39972
- service.setCharacteristic(Characteristic.ConfiguredName, label);
41547
+ service.setCharacteristic(Characteristic.Name, label);
39973
41548
  try {
39974
41549
  const status = await proxy.ptzAutotrack.getStatus({});
39975
41550
  if (status && typeof status.enabled === "boolean") service.updateCharacteristic(Characteristic.On, status.enabled);
@@ -40096,7 +41671,7 @@ async function buildChildSwitch(bctx, subtype, deviceType) {
40096
41671
  const isLightingDevice = deviceType === DeviceType.Light || deviceType === DeviceType.Generic;
40097
41672
  const useLightbulb = hasBrightness && isLightingDevice;
40098
41673
  const service = useLightbulb ? accessory.addService(Service.Lightbulb, displayName, subtype) : accessory.addService(Service.Switch, displayName, subtype);
40099
- service.setCharacteristic(Characteristic.ConfiguredName, displayName);
41674
+ service.setCharacteristic(Characteristic.Name, displayName);
40100
41675
  try {
40101
41676
  const switchStatus = await proxy.switch?.getStatus({});
40102
41677
  if (switchStatus && typeof switchStatus.on === "boolean") service.updateCharacteristic(Characteristic.On, switchStatus.on);
@@ -40168,7 +41743,7 @@ async function buildChildServicesFor(input) {
40168
41743
  accessory: parentCtx.accessory,
40169
41744
  proxy: childProxy,
40170
41745
  numericDeviceId: child.id,
40171
- displayName: formatChildServiceName(parentDisplayName, child),
41746
+ displayName: childServiceName(parentDisplayName, child),
40172
41747
  options
40173
41748
  };
40174
41749
  const subtype = `child-${child.id}`;
@@ -40199,21 +41774,6 @@ async function listChildren(ctx, parentNumericId) {
40199
41774
  }
40200
41775
  }
40201
41776
  /**
40202
- * Service name displayed inside the camera tile detail in iOS Home.
40203
- * Prefer the child's own role ("Siren", "Floodlight") when meaningful
40204
- * — the camera name is already implied by the surrounding Accessory.
40205
- * Falls back to the child's stored device name when role is empty.
40206
- */
40207
- function formatChildServiceName(parentName, child) {
40208
- const role = child.role && child.role.length > 0 ? toTitleCase(child.role) : null;
40209
- if (role) return role;
40210
- if (child.name.toLowerCase().includes(parentName.toLowerCase())) {
40211
- const stripped = child.name.replace(new RegExp(`\\b${escapeRegex(parentName)}\\b`, "i"), "").replace(/\s+[—-]\s+/, " ").trim();
40212
- if (stripped.length > 0) return stripped;
40213
- }
40214
- return child.name;
40215
- }
40216
- /**
40217
41777
  * Coerce the raw `child.type` string (from `deviceManager.getChildren`)
40218
41778
  * to a `DeviceType` enum value. Unknown / mis-cased values fall back to
40219
41779
  * `Generic` so an unrecognised driver behaves like the safest existing
@@ -40225,12 +41785,6 @@ function asDeviceType(raw) {
40225
41785
  for (const value of Object.values(DeviceType)) if (value === lower) return value;
40226
41786
  return DeviceType.Generic;
40227
41787
  }
40228
- function escapeRegex(s) {
40229
- return s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
40230
- }
40231
- function toTitleCase(raw) {
40232
- return raw.split("-").filter((part) => part.length > 0).map((part) => part.charAt(0).toUpperCase() + part.slice(1)).join(" ");
40233
- }
40234
41788
  function errMsg$2(err) {
40235
41789
  return err instanceof Error ? err.message : String(err);
40236
41790
  }