@camstack/addon-pipeline-orchestrator 1.2.25 → 1.2.26

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.
package/dist/index.mjs CHANGED
@@ -7482,6 +7482,14 @@ var EncodeProfileSchema = object({
7482
7482
  "main",
7483
7483
  "high"
7484
7484
  ]).optional(),
7485
+ /**
7486
+ * `-level`, e.g. `'3.1'`. A consumer that ADVERTISES a level in its SDP
7487
+ * (`profile-level-id=42e01f` is Baseline 3.1) must constrain the encoder to
7488
+ * it, or it ships a stream that does not match its own advertisement — the
7489
+ * defect class that kept HomeKit black for a year and that Alexa carried
7490
+ * silently. Optional because a browser negotiates the level itself.
7491
+ */
7492
+ level: string().optional(),
7485
7493
  width: number().int().positive().optional(),
7486
7494
  height: number().int().positive().optional(),
7487
7495
  fps: number().positive().optional(),
@@ -7529,6 +7537,29 @@ var EncodeProfileSchema = object({
7529
7537
  outputArgs: array(string()).optional()
7530
7538
  });
7531
7539
  /**
7540
+ * The shape every live egress starts from: H.264 Baseline 3.1 at 720p25.
7541
+ * Baseline because it is the one profile every consumer in this repo decodes
7542
+ * (Echo, iOS, an old browser); 3.1 because that is what the SDPs advertise.
7543
+ */
7544
+ var BASE_LIVE_EGRESS_PROFILE = {
7545
+ video: {
7546
+ codec: "h264",
7547
+ profile: "baseline",
7548
+ level: "3.1",
7549
+ width: 1280,
7550
+ height: 720,
7551
+ fps: 25,
7552
+ bitrateKbps: 2500,
7553
+ gopFrames: 25,
7554
+ bf: 0,
7555
+ preset: "veryfast",
7556
+ tune: "zerolatency"
7557
+ },
7558
+ audio: "passthrough"
7559
+ };
7560
+ ({ ...BASE_LIVE_EGRESS_PROFILE }), { ...BASE_LIVE_EGRESS_PROFILE.video };
7561
+ ({ ...BASE_LIVE_EGRESS_PROFILE });
7562
+ /**
7532
7563
  * Deep wiring healthcheck — snapshot of active reachability probes across
7533
7564
  * every declared capability + widget of every installed plugin, on every
7534
7565
  * node. Produced by the backend `WiringHealthService` and surfaced via
@@ -7578,6 +7609,236 @@ object({
7578
7609
  })
7579
7610
  });
7580
7611
  /**
7612
+ * Per-camera FUNCTION SWITCHES — the one coherent on/off surface over the
7613
+ * pipeline functions an operator thinks in terms of.
7614
+ *
7615
+ * ## This file adds no state
7616
+ *
7617
+ * Every switch here is a VIEW onto an authority that already existed
7618
+ * ([D61](../../../../docs/decisions/adr-0062.md)). The whole point of the
7619
+ * group is that there is exactly one place each function is turned off, and
7620
+ * the group routes to it:
7621
+ *
7622
+ * | Switch | Authority | Proven "off stops the work" gate |
7623
+ * | --- | --- | --- |
7624
+ * | `stream-broker` | `deviceManager.setDisabled` | `StreamBrokerManager.reconcileAllCatalogs` releases the brokers; `ensureBroker` refuses re-creation |
7625
+ * | `object-detection` | `deviceManager.setWrapperActive('detection-pipeline')` | `PipelineSettingsStore.resolvePipelineForDevice` returns `{ steps: [], audio: null }` |
7626
+ * | `audio-analysis` | `deviceManager.setWrapperActive('audio-analysis')` | `AudioSubscriptionController.subscribeAudioStream` returns `null` before opening the stream |
7627
+ * | `recording` | `recording.setDeviceConfig` → `RecordingConfig.enabled` | `band-decision.shouldRecord` returns false; the controller detaches the device |
7628
+ * | `notifications` | `notificationRules.setDeviceMuted` | `NotificationCenter.evaluateAndEnqueue` returns before any rule is evaluated |
7629
+ *
7630
+ * The wrapper-binding pair is not a new idea: `legacy-migrations.ts` already
7631
+ * migrated the legacy `audioEnabled` / `pipelineEnabled` /
7632
+ * `motionDetectionEnabled` booleans ONTO `setWrapperActive`. The group is the
7633
+ * surface that decision never got.
7634
+ *
7635
+ * ## Two rules that are load-bearing
7636
+ *
7637
+ * - **Recording's switch is `enabled`, never the bands.** `bands` is the only
7638
+ * authored intent and `mode` is derived from it (`deriveRecordingMode`).
7639
+ * Expressing "off" by clearing bands destroys the operator's schedule and
7640
+ * turning the camera back on would then silently record nothing.
7641
+ * - **A switch that is off must be reported as off**, not merely produce
7642
+ * nothing. {@link CameraSwitch.enabled} is what a status surface renders as
7643
+ * "disabled by an operator" instead of "broken" — see
7644
+ * `CameraStatus.switchedOff`.
7645
+ */
7646
+ /**
7647
+ * The five functions the operator named (2026-08-05). Deliberately NOT one id
7648
+ * per pipeline step: face recognition and plate/LPR are per-step toggles on
7649
+ * `pipelineOrchestrator.setCameraStepToggle` and belong in the pipeline
7650
+ * editor, not in a five-button safety group.
7651
+ */
7652
+ var CameraSwitchIdSchema = _enum([
7653
+ "stream-broker",
7654
+ "object-detection",
7655
+ "audio-analysis",
7656
+ "recording",
7657
+ "notifications"
7658
+ ]);
7659
+ /** Stable render order — broadest blast radius first. */
7660
+ var CAMERA_SWITCH_ORDER = [
7661
+ "stream-broker",
7662
+ "object-detection",
7663
+ "audio-analysis",
7664
+ "recording",
7665
+ "notifications"
7666
+ ];
7667
+ /**
7668
+ * WHERE the switch's state actually lives. A discriminated union rather than a
7669
+ * string so both the writer (the orchestrator's `setCameraSwitch`) and any
7670
+ * reader can exhaustively narrow — and so "the group added a parallel map" is
7671
+ * a compile error rather than a review comment.
7672
+ */
7673
+ var CameraSwitchAuthoritySchema = discriminatedUnion("kind", [
7674
+ object({ kind: literal("device-disabled") }),
7675
+ object({
7676
+ kind: literal("wrapper-binding"),
7677
+ capName: string()
7678
+ }),
7679
+ object({ kind: literal("recording-config") }),
7680
+ object({ kind: literal("notification-mute") })
7681
+ ]);
7682
+ /**
7683
+ * Why a switch is not offered for this camera. Rendered instead of the
7684
+ * control, never as a dead control — an absent function and a broken one must
7685
+ * not look the same.
7686
+ */
7687
+ var CameraSwitchUnavailableReasonSchema = _enum(["no-provider", "source-unreachable"]);
7688
+ /**
7689
+ * One switch, resolved for one camera.
7690
+ *
7691
+ * `label` and `costWhenOff` travel ON THE WIRE rather than being looked up
7692
+ * client-side: the viewer is a separate repository that does not import
7693
+ * `@camstack/types`, and a cost line duplicated in two clients is a cost line
7694
+ * that will disagree with itself. Five rows per camera is nothing.
7695
+ */
7696
+ var CameraSwitchSchema = object({
7697
+ id: CameraSwitchIdSchema,
7698
+ label: string(),
7699
+ /**
7700
+ * What the operator LOSES while this is off, in one sentence. Required, not
7701
+ * optional: a switch that cannot say what it costs should not ship.
7702
+ */
7703
+ costWhenOff: string(),
7704
+ /** False = do not render a control. `unavailableReason` says why. */
7705
+ available: boolean(),
7706
+ unavailableReason: CameraSwitchUnavailableReasonSchema.optional(),
7707
+ /** Current state. Meaningless when `available` is false — read it as `true`. */
7708
+ enabled: boolean(),
7709
+ authority: CameraSwitchAuthoritySchema
7710
+ });
7711
+ /** The whole group for one camera. */
7712
+ var CameraSwitchGroupSchema = object({
7713
+ deviceId: number().int(),
7714
+ switches: array(CameraSwitchSchema).readonly(),
7715
+ /** Unix ms when the group was composed server-side. */
7716
+ fetchedAt: number()
7717
+ });
7718
+ /**
7719
+ * THE catalog. One entry per switch; the cost lines are the operator-facing
7720
+ * contract and are written to be true rather than reassuring.
7721
+ */
7722
+ var CAMERA_SWITCH_CATALOG = {
7723
+ "stream-broker": {
7724
+ id: "stream-broker",
7725
+ label: "Camera",
7726
+ costWhenOff: "Off: the whole camera stops. No live view, no recording, no detection and no notifications — its streams are released and nothing dials it again until you turn it back on.",
7727
+ authority: { kind: "device-disabled" }
7728
+ },
7729
+ "object-detection": {
7730
+ id: "object-detection",
7731
+ label: "Object detection & tracking",
7732
+ costWhenOff: "Off: nothing is detected or tracked on this camera, so it produces no events — and with no events there are no object notifications and no event-triggered recording. Live view and continuous recording are unaffected.",
7733
+ authority: {
7734
+ kind: "wrapper-binding",
7735
+ capName: "detection-pipeline"
7736
+ }
7737
+ },
7738
+ "audio-analysis": {
7739
+ id: "audio-analysis",
7740
+ label: "Audio detection & classification",
7741
+ costWhenOff: "Off: no audio is decoded or classified for this camera. Tracks carry no audio labels and no audio-triggered rule can fire. Audio recorded alongside video is unaffected.",
7742
+ authority: {
7743
+ kind: "wrapper-binding",
7744
+ capName: "audio-analysis"
7745
+ }
7746
+ },
7747
+ recording: {
7748
+ id: "recording",
7749
+ label: "Recording",
7750
+ costWhenOff: "Off: nothing new is written to disk. Footage already recorded stays, but retention keeps deleting it — so this camera’s history shrinks and is not replaced. Your recording schedule is kept and resumes when you turn it back on.",
7751
+ authority: { kind: "recording-config" }
7752
+ },
7753
+ notifications: {
7754
+ id: "notifications",
7755
+ label: "Notifications",
7756
+ costWhenOff: "Off: this camera never notifies anyone, on any rule, with no expiry. Detection, events and recording carry on exactly as before — you simply stop being told about them.",
7757
+ authority: { kind: "notification-mute" }
7758
+ }
7759
+ };
7760
+ /** Resolve one switch's `{ available, enabled }` pair. */
7761
+ function resolveState(descriptor, input) {
7762
+ switch (descriptor.authority.kind) {
7763
+ case "device-disabled": return {
7764
+ available: true,
7765
+ enabled: !input.deviceDisabled
7766
+ };
7767
+ case "wrapper-binding": {
7768
+ const capName = descriptor.authority.capName;
7769
+ if (input.bindableCapNames === null || input.activeWrapperCapNames === null) return {
7770
+ available: false,
7771
+ enabled: true,
7772
+ unavailableReason: "source-unreachable"
7773
+ };
7774
+ if (!input.bindableCapNames.includes(capName)) return {
7775
+ available: false,
7776
+ enabled: true,
7777
+ unavailableReason: "no-provider"
7778
+ };
7779
+ return {
7780
+ available: true,
7781
+ enabled: input.activeWrapperCapNames.includes(capName)
7782
+ };
7783
+ }
7784
+ case "recording-config":
7785
+ if (input.recordingEnabled === null) return {
7786
+ available: false,
7787
+ enabled: true,
7788
+ unavailableReason: "source-unreachable"
7789
+ };
7790
+ return {
7791
+ available: true,
7792
+ enabled: input.recordingEnabled
7793
+ };
7794
+ case "notification-mute":
7795
+ if (input.notificationsMuted === null) return {
7796
+ available: false,
7797
+ enabled: true,
7798
+ unavailableReason: "source-unreachable"
7799
+ };
7800
+ return {
7801
+ available: true,
7802
+ enabled: !input.notificationsMuted
7803
+ };
7804
+ }
7805
+ }
7806
+ /**
7807
+ * Pure derivation of the whole group. No I/O — the orchestrator gathers, this
7808
+ * decides, so the decision is testable without a hub.
7809
+ *
7810
+ * Order is {@link CAMERA_SWITCH_ORDER}; unavailable switches are RETURNED
7811
+ * rather than filtered out, so a client can explain the gap instead of
7812
+ * silently rendering four buttons where another camera shows five.
7813
+ */
7814
+ function deriveCameraSwitches(input) {
7815
+ return CAMERA_SWITCH_ORDER.map((id) => {
7816
+ const descriptor = CAMERA_SWITCH_CATALOG[id];
7817
+ const state = resolveState(descriptor, input);
7818
+ return {
7819
+ id: descriptor.id,
7820
+ label: descriptor.label,
7821
+ costWhenOff: descriptor.costWhenOff,
7822
+ available: state.available,
7823
+ ...state.unavailableReason !== void 0 ? { unavailableReason: state.unavailableReason } : {},
7824
+ enabled: state.enabled,
7825
+ authority: descriptor.authority
7826
+ };
7827
+ });
7828
+ }
7829
+ /**
7830
+ * The ids an operator has switched OFF, for a status surface.
7831
+ *
7832
+ * This is the answer to "a disabled function must be visible as DISABLED, not
7833
+ * merely quiet": a camera reporting zero detections with
7834
+ * `switchedOff: ['object-detection']` was turned off; the same camera with an
7835
+ * empty list is broken. Unavailable switches never appear — a function nobody
7836
+ * provides was not switched off by anyone.
7837
+ */
7838
+ function switchedOffIds(switches) {
7839
+ return switches.filter((s) => s.available && !s.enabled).map((s) => s.id);
7840
+ }
7841
+ /**
7581
7842
  * Ops-log — the durable, append-only operations audit shared by the
7582
7843
  * recordings and events management surfaces.
7583
7844
  *
@@ -7596,14 +7857,16 @@ var OpsLogOpSchema = _enum([
7596
7857
  "manual-delete",
7597
7858
  "rescan",
7598
7859
  "retention-run",
7599
- "relocate"
7860
+ "relocate",
7861
+ "orphan-audit"
7600
7862
  ]);
7601
7863
  /** Why the operation ran. */
7602
7864
  var OpsLogReasonSchema = _enum([
7603
7865
  "retention",
7604
7866
  "quota",
7605
7867
  "manual",
7606
- "operator"
7868
+ "operator",
7869
+ "maintenance"
7607
7870
  ]);
7608
7871
  /** One audit row, shared verbatim by both domains. */
7609
7872
  var OpsLogEntrySchema = object({
@@ -9508,6 +9771,100 @@ var RtpSourceSchema = object({
9508
9771
  encoder: string(),
9509
9772
  pipelineKey: string()
9510
9773
  });
9774
+ /**
9775
+ * The encode request — **structured and serialisable, with NO raw-flag escape
9776
+ * hatch.** This is deliberate and it is the one lesson taken from
9777
+ * `getStreamWithCodec`: that method's `outputArgs: string[]` is simultaneously
9778
+ * its extensibility mechanism AND part of `pipelineKeyFor`'s sharing key, so
9779
+ * adding a flag silently forks the shared child, and two consumers that mean
9780
+ * the same thing but spell it differently never share. Here every knob is a
9781
+ * NAMED field: a new requirement becomes a schema field (and a codegen run),
9782
+ * never an opaque array.
9783
+ *
9784
+ * `inputArgs` / `outputArgs` are omitted from the profile for the same reason.
9785
+ * The operator-facing derived-stream transform editor still has them — that is
9786
+ * a different surface (`publishCameraStream({ kind: 'derived' })`) with a
9787
+ * different purpose (reshaping a badly-behaved SOURCE), and it is unchanged.
9788
+ */
9789
+ var EgressEncodeSchema = EncodeProfileSchema.omit({
9790
+ inputArgs: true,
9791
+ outputArgs: true
9792
+ });
9793
+ /**
9794
+ * How the encoder is bounded. `'tight'` is a one-second VBV window for a
9795
+ * consumer whose budget is enforced per second (HomeKit); `'relaxed'` is two
9796
+ * seconds, letting a keyframe spike borrow from the next second (a browser,
9797
+ * an Echo). Named rather than numeric so the INTENT survives.
9798
+ */
9799
+ var EgressRateControlSchema = _enum(["tight", "relaxed"]);
9800
+ var EgressTranscodeRequestSchema = object({
9801
+ deviceId: number().int().nonnegative(),
9802
+ /** Which published stream to read. */
9803
+ source: discriminatedUnion("kind", [object({
9804
+ kind: literal("profile"),
9805
+ profile: CamProfileSchema
9806
+ }), object({
9807
+ kind: literal("cam-stream"),
9808
+ camStreamId: string().min(1)
9809
+ })]),
9810
+ encode: EgressEncodeSchema,
9811
+ rateControl: EgressRateControlSchema.optional(),
9812
+ /**
9813
+ * `-bsf:v`. A consumer that negotiates its OWN SDP (HomeKit) cannot carry
9814
+ * out-of-band extradata and needs `dump_extra` on both the copy and encode
9815
+ * branches. Enumerated, not free text.
9816
+ */
9817
+ bitstreamFilter: _enum([
9818
+ "dump_extra",
9819
+ "h264_mp4toannexb",
9820
+ "hevc_mp4toannexb"
9821
+ ]).optional(),
9822
+ pixelFormat: _enum(["yuv420p", "nv12"]).optional(),
9823
+ /**
9824
+ * Operator/consumer override for decode hardware. ABSENT is the normal case
9825
+ * and the one that matters: the broker then resolves the backend from the
9826
+ * DECODER ADDON's per-node `probedBestHwaccel` (see
9827
+ * `@camstack/types` `ffmpeg/hwaccel.ts`), which is the ranking known to work
9828
+ * on this hardware — never the raw kernel resolver's qsv-first order.
9829
+ */
9830
+ decodeHwAccel: _enum([
9831
+ "auto",
9832
+ "none",
9833
+ "videotoolbox",
9834
+ "vaapi",
9835
+ "qsv",
9836
+ "cuda"
9837
+ ]).optional(),
9838
+ /**
9839
+ * Host to embed in the returned restream `url`. The broker mints hub-local
9840
+ * `127.0.0.1` URLs; a consumer on another node passes a cluster-resolvable
9841
+ * host (`NodeTopologyService.reachableHostByNode`) so the returned URL is
9842
+ * dialable from there. Same contract as `getStreamWithCodec.hostname` —
9843
+ * `substituteRtspHost` rewrites only the dial address, never the restreamer.
9844
+ */
9845
+ hostname: string().optional(),
9846
+ /** Attribution for the broker panel. Never part of the sharing key. */
9847
+ tag: string().optional()
9848
+ });
9849
+ var EgressTranscodeSchema = object({
9850
+ /** Dial-able RTSP url (host-substituted when `hostname` was supplied). */
9851
+ url: string(),
9852
+ /** Release handle. Refcounted — the child dies when the last holder releases. */
9853
+ pipelineKey: string(),
9854
+ videoCodec: _enum(["H264", "H265"]),
9855
+ resolution: object({
9856
+ width: number().int().positive(),
9857
+ height: number().int().positive()
9858
+ }),
9859
+ transcoded: boolean(),
9860
+ encoder: string(),
9861
+ /**
9862
+ * The decode backend the child ACTUALLY ran with — `null` for software.
9863
+ * Returned rather than assumed: a consumer that asked for hardware and got
9864
+ * software needs to be able to see that without reading the broker's logs.
9865
+ */
9866
+ decodeHwAccel: string().nullable()
9867
+ });
9511
9868
  method(object({
9512
9869
  deviceId: number().int().nonnegative(),
9513
9870
  camStreamId: string().min(1),
@@ -9617,6 +9974,15 @@ method(object({
9617
9974
  }), {
9618
9975
  kind: "mutation",
9619
9976
  auth: "admin"
9977
+ }), method(EgressTranscodeRequestSchema, EgressTranscodeSchema, {
9978
+ kind: "mutation",
9979
+ auth: "admin"
9980
+ }), method(object({ pipelineKey: string() }), object({
9981
+ released: boolean(),
9982
+ refcount: number().int().nonnegative()
9983
+ }), {
9984
+ kind: "mutation",
9985
+ auth: "admin"
9620
9986
  }), method(SubscribeAudioChunksInputSchema, SubscribeAudioChunksResultSchema, { kind: "mutation" }), method(object({
9621
9987
  subscriptionId: string(),
9622
9988
  maxCount: number().int().positive().default(8)
@@ -13965,12 +14331,13 @@ var NcConditionsSchema = object({
13965
14331
  * source; otherwise the subject's source must equal it. Legacy records
13966
14332
  * with no stamped source are treated as `pipeline`. The union spans both
13967
14333
  * record kinds — object events carry `pipeline` | `onboard`, synthetic
13968
- * tracks carry `sensor`.
14334
+ * tracks carry `sensor` (a linked device) or `audio` (a D62 audio marker).
13969
14335
  */
13970
14336
  source: _enum([
13971
14337
  "pipeline",
13972
14338
  "onboard",
13973
14339
  "sensor",
14340
+ "audio",
13974
14341
  "any"
13975
14342
  ]).optional(),
13976
14343
  /**
@@ -14546,6 +14913,12 @@ method(object({}), object({ rules: array(NcRuleSchema) }), { auth: "admin" }), m
14546
14913
  }), object({ success: literal(true) }), {
14547
14914
  kind: "mutation",
14548
14915
  auth: "admin"
14916
+ }), method(object({}), object({ mutedDeviceIds: array(number().int()).readonly() }), { auth: "admin" }), method(object({
14917
+ deviceId: number().int(),
14918
+ muted: boolean()
14919
+ }), object({ success: literal(true) }), {
14920
+ kind: "mutation",
14921
+ auth: "admin"
14549
14922
  }), method(object({
14550
14923
  rule: NcRuleInputSchema,
14551
14924
  lookbackMinutes: number().int().min(1).max(1440).default(60)
@@ -14884,12 +15257,60 @@ var TrackAudioLabelSchema = object({
14884
15257
  });
14885
15258
  /**
14886
15259
  * How a track was produced. `pipeline` (default / absent) = the spatial
14887
- * detection+tracking pipeline. `sensor` = a SYNTHETIC track projected from a
14888
- * linked sensor/control state change (no positions; carries a snapshot). The
14889
- * spatial subsystems (tracker association, occupancy count, re-id/embedding,
14890
- * resurrection) MUST skip `sensor` tracks they have no bbox trajectory.
15260
+ * detection+tracking pipeline. Every OTHER value is a SYNTHETIC projection
15261
+ * no positions, a single snapshot, and no bbox trajectory at all:
15262
+ *
15263
+ * - `sensor` — a linked sensor/control device state change.
15264
+ * - `audio` — an audio event on the camera itself that was anomalous for
15265
+ * THAT camera, loud, and heard while nothing visual was happening (D62).
15266
+ *
15267
+ * The spatial subsystems (tracker association, occupancy count, re-id /
15268
+ * embedding, resurrection) MUST skip every synthetic source. Test for that
15269
+ * with `isSpatialTrack`, which allow-lists `pipeline` — a `!== 'sensor'`
15270
+ * check silently readmits every source added after it was written.
14891
15271
  */
14892
- var TrackSourceSchema = _enum(["pipeline", "sensor"]);
15272
+ var TrackSourceSchema = _enum([
15273
+ "pipeline",
15274
+ "sensor",
15275
+ "audio"
15276
+ ]);
15277
+ /**
15278
+ * Per-track OPERATOR flags — set by hand from the admin UI or the viewer, never
15279
+ * by the pipeline. Spread into `TrackSchema` and `KeyEventSchema` from one place
15280
+ * so the two surfaces cannot drift.
15281
+ *
15282
+ * **Absent ≠ false.** A track that has never been touched omits the field; an
15283
+ * explicitly un-flagged track carries `false`. Legacy rows written before the
15284
+ * columns existed read as absent, and a consumer that needs a boolean should say
15285
+ * `flag === true`, not `flag !== false`.
15286
+ *
15287
+ * What the flags DO is deliberately UNDEFINED at the time of writing: they are
15288
+ * operator curation, and the behaviour they drive will be specified separately.
15289
+ * In particular a `markForTrain` track is NOT pinned against retention — see
15290
+ * `docs/decisions/adr-0059.md` for why that is a store-level change, not a flag.
15291
+ */
15292
+ var TrackFlagFields = {
15293
+ /** Operator marked this track as training material. */
15294
+ markForTrain: boolean().optional(),
15295
+ /** Operator marked this track for diagnostic attention. */
15296
+ debug: boolean().optional()
15297
+ };
15298
+ /**
15299
+ * The write half: a PARTIAL patch. An omitted key is left untouched, so setting
15300
+ * one flag can never clear the other — the toggles are independent and are
15301
+ * driven from three surfaces that do not know about each other.
15302
+ */
15303
+ var TrackFlagsPatchSchema = object(TrackFlagFields);
15304
+ /**
15305
+ * The resolved flag state after a write. Both fields are REQUIRED here (absent
15306
+ * collapses to `false`) so a caller can drive a toggle's checked state off the
15307
+ * mutation result without a re-fetch.
15308
+ */
15309
+ var TrackFlagsSchema = object({
15310
+ trackId: string(),
15311
+ markForTrain: boolean(),
15312
+ debug: boolean()
15313
+ });
14893
15314
  var TrackSchema = object({
14894
15315
  trackId: string(),
14895
15316
  deviceId: number(),
@@ -14932,7 +15353,8 @@ var TrackSchema = object({
14932
15353
  /** Normalized 0..1 trajectory envelope (see {@link TrackEnvelopeSchema}).
14933
15354
  * Populated from the persisted envelope columns on historical reads;
14934
15355
  * absent on legacy rows, dims-less tracks and active (in-RAM) tracks. */
14935
- envelope: TrackEnvelopeSchema.optional()
15356
+ envelope: TrackEnvelopeSchema.optional(),
15357
+ ...TrackFlagFields
14936
15358
  });
14937
15359
  var BaseEventFields = {
14938
15360
  id: string(),
@@ -15145,7 +15567,8 @@ var KeyEventSchema = object({
15145
15567
  /** Highest-confidence ObjectEvent id for the track (empty when none). */
15146
15568
  bestEventId: string(),
15147
15569
  /** Track lifetime in ms (lastSeen - firstSeen). */
15148
- windowMs: number().optional()
15570
+ windowMs: number().optional(),
15571
+ ...TrackFlagFields
15149
15572
  });
15150
15573
  object({
15151
15574
  trackId: string(),
@@ -15231,7 +15654,31 @@ var RebuildObjectEmbeddingsInput = object({
15231
15654
  since: number().optional(),
15232
15655
  until: number().optional(),
15233
15656
  /** Stop after this many tracks; the result reports whether more remain. */
15234
- maxTracks: number().int().positive().optional()
15657
+ maxTracks: number().int().positive().optional(),
15658
+ /**
15659
+ * Run every embedding on THIS node instead of round-robining the fleet.
15660
+ *
15661
+ * Named `executeOnNodeId` and not `nodeId` on purpose: an inline `nodeId`
15662
+ * field in cap args is read by `parent-unowned-call.ts` as a ROUTING PIN, so
15663
+ * calling it that would pin the rebuild REQUEST itself to that node — the
15664
+ * rebuild orchestration lives on the hub, and only the per-track step runs
15665
+ * remotely. This field is data; the per-track pin is applied inside.
15666
+ *
15667
+ * Absent ⇒ round-robin over every online node whose runner can serve the
15668
+ * pinned model.
15669
+ */
15670
+ executeOnNodeId: string().optional(),
15671
+ /**
15672
+ * Milliseconds to wait between tracks; omit for the built-in default, `0` to
15673
+ * run flat out.
15674
+ *
15675
+ * A rebuild is bulk maintenance on hub-main's single thread. Measured
15676
+ * 2026-08-06, an unpaced pass held that thread busy 82.2 s out of 120 and
15677
+ * pushed `nodes.topology` from 0.25 s to 26 s for 43 minutes. The value in
15678
+ * force is logged at start and finish so a deliberately slow pass reads
15679
+ * differently from a stalled one.
15680
+ */
15681
+ pacingMs: number().int().nonnegative().optional()
15235
15682
  });
15236
15683
  /**
15237
15684
  * Result of emptying the CLIP index.
@@ -15265,13 +15712,23 @@ var RebuildStatusSchema = object({
15265
15712
  /** Tracks with no usable detection box. */
15266
15713
  missingBbox: number(),
15267
15714
  /**
15268
- * Tracks the pipeline REFUSED rather than broke on: the camera is not
15269
- * attached, or `clip-embedding` is not enabled in its step tree. Separate
15270
- * from `failed` because the remedy is a configuration change, not an engine
15271
- * investigation and because a pass over decommissioned cameras would
15272
- * otherwise read as a total engine outage.
15715
+ * Tracks an executing node REFUSED rather than broke on an unreadable key
15716
+ * frame, a step that threw. Separate from `failed` because the remedy is
15717
+ * different, and because a whole camera silently contributing zero vectors
15718
+ * is the shape of failure a rebuild must never hide.
15273
15719
  */
15274
15720
  notRunnable: number(),
15721
+ /**
15722
+ * The pass stopped because NO node could serve the pinned model.
15723
+ *
15724
+ * Distinct from `notRunnable` on purpose: that one says "this track was
15725
+ * refused", this one says "the cluster cannot do this work at all" — every
15726
+ * candidate node either lacks the `clip-embedding` step, lacks a build of the
15727
+ * pinned model for its engine format, or dropped out. The remedy is a model /
15728
+ * engine change, not a per-camera one. Non-zero here always comes with
15729
+ * `complete: false`.
15730
+ */
15731
+ noCapableNode: number(),
15275
15732
  failed: number(),
15276
15733
  /** Set once a pass ends: true only when EVERYTHING was covered. */
15277
15734
  complete: boolean().nullable(),
@@ -15343,7 +15800,12 @@ DeviceType.Camera, method(object({ deviceId: number() }), array(TrackSchema).rea
15343
15800
  }), {
15344
15801
  kind: "mutation",
15345
15802
  auth: "admin"
15346
- }), method(object({}), EventStoreFootprintSchema, {
15803
+ }), method(object({
15804
+ /** Log/audit scope only — the trackId is globally unique on its own. */
15805
+ deviceId: number(),
15806
+ trackId: string(),
15807
+ flags: TrackFlagsPatchSchema
15808
+ }), TrackFlagsSchema, { kind: "mutation" }), method(object({}), EventStoreFootprintSchema, {
15347
15809
  kind: "query",
15348
15810
  auth: "admin"
15349
15811
  }), method(object({
@@ -16138,6 +16600,53 @@ var DetailResultSchema = object({
16138
16600
  nativeFaceShortSidePx: number().optional()
16139
16601
  });
16140
16602
  /**
16603
+ * Why an executing node REFUSED a stateless step run (`runStatelessStep`).
16604
+ *
16605
+ * A refusal is a first-class answer, not an error, because the caller's next
16606
+ * move depends on WHICH one it is — and because "the pass produced nothing"
16607
+ * must never be reachable without a named, counted cause. The two tiers:
16608
+ *
16609
+ * - **node-level** (`unknown-step`, `model-not-servable`) — this node can
16610
+ * never serve this (step, model) pair. The caller drops it from its rotation
16611
+ * and retries the same work elsewhere; nothing about the work changes.
16612
+ * - **work-level** (`unreadable-frame`, `execution-failed`) — this node is
16613
+ * fine, this one request is not. Retrying it on another node would only
16614
+ * spread the same failure.
16615
+ */
16616
+ var StatelessStepRefusalSchema = _enum([
16617
+ "unknown-step",
16618
+ "model-not-servable",
16619
+ "unreadable-frame",
16620
+ "execution-failed"
16621
+ ]);
16622
+ /**
16623
+ * Answer to `runStatelessStep` — a discriminated union rather than a nullable
16624
+ * result, because `null` is exactly what made the camera-bound detail path
16625
+ * unable to tell "refused" from "never asked".
16626
+ */
16627
+ var RunStatelessStepResultSchema = discriminatedUnion("kind", [object({
16628
+ kind: literal("ran"),
16629
+ /** The node that actually executed it — the pin, echoed back for the log. */
16630
+ nodeId: string(),
16631
+ /**
16632
+ * The model the step ran with.
16633
+ *
16634
+ * The node verified this exact id has a build for the format it dispatched
16635
+ * on BEFORE running, so the executor's format resolution returns it
16636
+ * unchanged. A caller that pinned a model must compare this field and
16637
+ * treat a mismatch as a refusal — the whole point of the pin is that a
16638
+ * pass writes one feature space.
16639
+ */
16640
+ modelId: string(),
16641
+ details: array(DetailResultSchema)
16642
+ }), object({
16643
+ kind: literal("refused"),
16644
+ nodeId: string(),
16645
+ reason: StatelessStepRefusalSchema,
16646
+ /** Human-readable specifics — the format tried, the formats shipped, etc. */
16647
+ detail: string()
16648
+ })]);
16649
+ /**
16141
16650
  * Per-camera tunable ranges + defaults. Single source of truth used
16142
16651
  * by both the Zod data schema (validation + default fallback) and
16143
16652
  * the device settings UI (slider min/max/step). Touch one place and
@@ -16626,7 +17135,32 @@ method(RunnerCameraConfigSchema, object({ success: literal(true) }), { kind: "mu
16626
17135
  cropJpeg: string().optional(),
16627
17136
  parent: DetailParentSchema,
16628
17137
  steps: array(string()).optional()
16629
- }), object({ details: array(DetailResultSchema) }).nullable(), { kind: "mutation" });
17138
+ }), object({ details: array(DetailResultSchema) }).nullable(), { kind: "mutation" }), method(object({
17139
+ /** Catalog step id, e.g. `clip-embedding`. */
17140
+ stepId: string(),
17141
+ /**
17142
+ * REQUIRED model pin. The node runs this exact model or refuses with
17143
+ * `model-not-servable` — it never substitutes a format default, because
17144
+ * a fleet pass that round-robins across nodes would then fill one index
17145
+ * from several encoders.
17146
+ */
17147
+ modelId: string(),
17148
+ /** FULL FRAME, base64 JPEG. The runner cuts — do NOT pre-crop. */
17149
+ frameJpeg: string(),
17150
+ /**
17151
+ * The subject box, NORMALISED [0,1] against `frameJpeg`. Normalised on
17152
+ * purpose: the caller stores boxes against a downscaled analysis frame
17153
+ * while the stored key frame is native-resolution, and the only side
17154
+ * that reliably knows the image's pixel dimensions is the side that
17155
+ * decodes it. Denormalising here removes a second reader of the
17156
+ * dimensions and the class of mismatch that comes with it.
17157
+ */
17158
+ bbox: NativeCropBboxSchema,
17159
+ /** Parent class of the subject (`person`, `vehicle`, …) — carried into the result. */
17160
+ className: string(),
17161
+ /** Camera the pixels came from. Diagnostics + log tags ONLY — never routing. */
17162
+ sourceDeviceId: number()
17163
+ }), RunStatelessStepResultSchema, { kind: "mutation" });
16630
17164
  var CameraPipelineConfigSchema = object({
16631
17165
  engine: PipelineEngineChoiceSchema.optional(),
16632
17166
  steps: array(PipelineStepInputSchema).readonly(),
@@ -16924,6 +17458,20 @@ var CameraStatusSchema = object({
16924
17458
  detection: CameraDetectionStatusSchema.nullable(),
16925
17459
  audio: CameraAudioStatusSchema.nullable(),
16926
17460
  recording: CameraRecordingStatusSchema.nullable(),
17461
+ /**
17462
+ * Per-camera function switches an OPERATOR has turned off
17463
+ * ([D61](../../../../docs/decisions/adr-0067.md)).
17464
+ *
17465
+ * This is the difference between DISABLED and BROKEN. A camera whose
17466
+ * `detection` block reports zero fps and whose `switchedOff` contains
17467
+ * `'object-detection'` was switched off by a person; the same camera with an
17468
+ * empty list is failing. Every status surface must render the two
17469
+ * differently — a quiet camera that looks identical to a dead one is the
17470
+ * silence-reads-as-never-happened trap this repo keeps paying for.
17471
+ *
17472
+ * Empty when nothing is off. Never contains a switch no provider offers.
17473
+ */
17474
+ switchedOff: array(CameraSwitchIdSchema).readonly(),
16927
17475
  /** Unix timestamp (ms) when this snapshot was composed server-side. */
16928
17476
  fetchedAt: number()
16929
17477
  });
@@ -17323,6 +17871,43 @@ var pipelineOrchestratorCapability = {
17323
17871
  agentNodeId: string().optional()
17324
17872
  }), CameraPipelineConfigSchema),
17325
17873
  /**
17874
+ * The whole per-camera function switch group, DERIVED — never a stored
17875
+ * list ([D61](../../../../docs/decisions/adr-0067.md)).
17876
+ *
17877
+ * The group adds no state. Each switch is a view onto the authority that
17878
+ * already owned it (`deviceManager.setDisabled`,
17879
+ * `deviceManager.setWrapperActive`, `RecordingConfig.enabled`,
17880
+ * `notificationRules.setDeviceMuted`), and `switch.authority` says which.
17881
+ * Availability comes from `deviceManager.listBindableCapsForDeviceType`,
17882
+ * so a deployment with no audio analyzer renders no audio switch.
17883
+ *
17884
+ * `auth: 'view'` deliberately — a NON-admin must be able to see that a
17885
+ * camera is quiet because somebody switched it off. Only the mutation is
17886
+ * admin-gated.
17887
+ */
17888
+ getCameraSwitches: method(object({ deviceId: number() }), CameraSwitchGroupSchema),
17889
+ /**
17890
+ * Flip ONE switch, routed to its existing authority.
17891
+ *
17892
+ * Never writes a parallel map: `recording` patches `RecordingConfig.enabled`
17893
+ * and leaves `bands` byte-identical (clearing bands to express "off"
17894
+ * destroys the operator's authored schedule and turning the camera back on
17895
+ * would silently record nothing), and the two pipeline switches write the
17896
+ * SAME wrapper binding the legacy `pipelineEnabled` / `audioEnabled`
17897
+ * booleans were migrated onto.
17898
+ *
17899
+ * Rejects a switch this camera does not offer rather than persisting a
17900
+ * write nothing reads.
17901
+ */
17902
+ setCameraSwitch: method(object({
17903
+ deviceId: number(),
17904
+ switchId: CameraSwitchIdSchema,
17905
+ enabled: boolean()
17906
+ }), CameraSwitchGroupSchema, {
17907
+ kind: "mutation",
17908
+ auth: "admin"
17909
+ }),
17910
+ /**
17326
17911
  * Server-composed aggregated status for a single camera.
17327
17912
  *
17328
17913
  * Fans out in parallel (bounded, per-stage graceful degradation) to
@@ -26391,6 +26976,12 @@ Object.freeze({
26391
26976
  addonId: null,
26392
26977
  access: "view"
26393
26978
  },
26979
+ "notificationRules.listDeviceMutes": {
26980
+ capName: "notification-rules",
26981
+ capScope: "system",
26982
+ addonId: null,
26983
+ access: "view"
26984
+ },
26394
26985
  "notificationRules.listRules": {
26395
26986
  capName: "notification-rules",
26396
26987
  capScope: "system",
@@ -26409,6 +27000,12 @@ Object.freeze({
26409
27000
  addonId: null,
26410
27001
  access: "create"
26411
27002
  },
27003
+ "notificationRules.setDeviceMuted": {
27004
+ capName: "notification-rules",
27005
+ capScope: "system",
27006
+ addonId: null,
27007
+ access: "create"
27008
+ },
26412
27009
  "notificationRules.setRuleEnabled": {
26413
27010
  capName: "notification-rules",
26414
27011
  capScope: "system",
@@ -26685,6 +27282,12 @@ Object.freeze({
26685
27282
  addonId: null,
26686
27283
  access: "view"
26687
27284
  },
27285
+ "pipelineAnalytics.setTrackFlags": {
27286
+ capName: "pipeline-analytics",
27287
+ capScope: "device",
27288
+ addonId: null,
27289
+ access: "create"
27290
+ },
26688
27291
  "pipelineAnalytics.wipeAllAnalytics": {
26689
27292
  capName: "pipeline-analytics",
26690
27293
  capScope: "device",
@@ -26991,6 +27594,12 @@ Object.freeze({
26991
27594
  addonId: null,
26992
27595
  access: "view"
26993
27596
  },
27597
+ "pipelineOrchestrator.getCameraSwitches": {
27598
+ capName: "pipeline-orchestrator",
27599
+ capScope: "system",
27600
+ addonId: null,
27601
+ access: "view"
27602
+ },
26994
27603
  "pipelineOrchestrator.getCapabilityBindings": {
26995
27604
  capName: "pipeline-orchestrator",
26996
27605
  capScope: "system",
@@ -27123,6 +27732,12 @@ Object.freeze({
27123
27732
  addonId: null,
27124
27733
  access: "create"
27125
27734
  },
27735
+ "pipelineOrchestrator.setCameraSwitch": {
27736
+ capName: "pipeline-orchestrator",
27737
+ capScope: "system",
27738
+ addonId: null,
27739
+ access: "create"
27740
+ },
27126
27741
  "pipelineOrchestrator.setCapabilityBinding": {
27127
27742
  capName: "pipeline-orchestrator",
27128
27743
  capScope: "system",
@@ -27213,6 +27828,12 @@ Object.freeze({
27213
27828
  addonId: null,
27214
27829
  access: "create"
27215
27830
  },
27831
+ "pipelineRunner.runStatelessStep": {
27832
+ capName: "pipeline-runner",
27833
+ capScope: "system",
27834
+ addonId: null,
27835
+ access: "create"
27836
+ },
27216
27837
  "plateGallery.assignPlate": {
27217
27838
  capName: "plate-gallery",
27218
27839
  capScope: "system",
@@ -28029,6 +28650,12 @@ Object.freeze({
28029
28650
  addonId: null,
28030
28651
  access: "create"
28031
28652
  },
28653
+ "streamBroker.acquireEgressTranscode": {
28654
+ capName: "stream-broker",
28655
+ capScope: "system",
28656
+ addonId: null,
28657
+ access: "create"
28658
+ },
28032
28659
  "streamBroker.assignProfile": {
28033
28660
  capName: "stream-broker",
28034
28661
  capScope: "system",
@@ -28137,6 +28764,12 @@ Object.freeze({
28137
28764
  addonId: null,
28138
28765
  access: "create"
28139
28766
  },
28767
+ "streamBroker.releaseEgressTranscode": {
28768
+ capName: "stream-broker",
28769
+ capScope: "system",
28770
+ addonId: null,
28771
+ access: "create"
28772
+ },
28140
28773
  "streamBroker.releaseStreamWithCodec": {
28141
28774
  capName: "stream-broker",
28142
28775
  capScope: "system",
@@ -30942,6 +31575,7 @@ function composeCameraStatus(input) {
30942
31575
  detection: mapDetection(input.detectionResult),
30943
31576
  audio: mapAudio(input.audioResult),
30944
31577
  recording: mapRecording(input.recordingResult),
31578
+ switchedOff: input.switchedOff,
30945
31579
  fetchedAt: input.fetchedAt
30946
31580
  };
30947
31581
  }
@@ -31165,12 +31799,23 @@ var CameraStatusService = class {
31165
31799
  };
31166
31800
  }), STAGE_TIMEOUT_MS);
31167
31801
  }
31168
- /** Audio stage (from cached audio assignment). Orchestrator-local — no remote call needed. */
31169
- buildAudioStage(audioNodeId) {
31170
- return audioNodeId ? {
31802
+ /**
31803
+ * Audio stage. `nodeId` is orchestrator-local (the cached assignment);
31804
+ * `enabled` is the REAL `audio-analysis` switch.
31805
+ *
31806
+ * It used to be a hardcoded `true` whenever an audio node was assigned — so
31807
+ * a camera whose operator had turned audio off reported `audio.enabled:
31808
+ * true` and produced nothing, which is indistinguishable from broken. That
31809
+ * is precisely the failure the switch group exists to remove, and leaving
31810
+ * the lie in place would have made the group's own status block disagree
31811
+ * with it.
31812
+ */
31813
+ buildAudioStage(audioNodeId, switchedOff) {
31814
+ if (audioNodeId === null) return null;
31815
+ return {
31171
31816
  nodeId: audioNodeId,
31172
- enabled: true
31173
- } : null;
31817
+ enabled: !switchedOff.includes("audio-analysis")
31818
+ };
31174
31819
  }
31175
31820
  /**
31176
31821
  * Recording stage (recording cap getStatus). `recording.getStatus` is
@@ -31208,15 +31853,17 @@ var CameraStatusService = class {
31208
31853
  const decoderFetch = this.buildDecoderStage(detectionNodeId);
31209
31854
  const motionResult = this.buildMotionStage(deviceId);
31210
31855
  const detectionFetch = this.buildDetectionStage(api, detectionNodeId, deviceId);
31211
- const audioResult = this.buildAudioStage(audioNodeId);
31212
31856
  const recordingFetch = this.buildRecordingStage(api, deviceId);
31213
- const [sourceResult, brokerResult, decoderResult, detectionResult, recordingResult] = await Promise.all([
31857
+ const switchesFetch = this.boundedStage(this.deps.switchedOffIdsFor(deviceId).catch(() => null), STAGE_TIMEOUT_MS).then((ids) => ids ?? []);
31858
+ const [sourceResult, brokerResult, decoderResult, detectionResult, recordingResult, switchedOff] = await Promise.all([
31214
31859
  sourceFetch,
31215
31860
  brokerFetch,
31216
31861
  decoderFetch,
31217
31862
  detectionFetch,
31218
- recordingFetch
31863
+ recordingFetch,
31864
+ switchesFetch
31219
31865
  ]);
31866
+ const audioResult = this.buildAudioStage(audioNodeId, switchedOff);
31220
31867
  const liveDecoderNodeId = brokerResult !== null ? liveDecoder.nodeId : null;
31221
31868
  const decoderNodeId = liveDecoderNodeId ?? detectionNodeId;
31222
31869
  const reasons = {
@@ -31239,7 +31886,8 @@ var CameraStatusService = class {
31239
31886
  motionResult,
31240
31887
  detectionResult,
31241
31888
  audioResult,
31242
- recordingResult
31889
+ recordingResult,
31890
+ switchedOff
31243
31891
  });
31244
31892
  }
31245
31893
  /**
@@ -31260,6 +31908,195 @@ var CameraStatusService = class {
31260
31908
  }
31261
31909
  };
31262
31910
  //#endregion
31911
+ //#region src/camera-switch-service.ts
31912
+ function isDeviceShape(v) {
31913
+ if (v === null || typeof v !== "object") return false;
31914
+ const rec = { ...v };
31915
+ return typeof rec["type"] === "string" && typeof rec["disabled"] === "boolean";
31916
+ }
31917
+ /** Narrow `recording.getDeviceConfig` to the fields a switch may touch. */
31918
+ function isRecordingConfig(v) {
31919
+ if (v === null || typeof v !== "object") return false;
31920
+ const rec = { ...v };
31921
+ return typeof rec["enabled"] === "boolean" && Array.isArray(rec["bands"]);
31922
+ }
31923
+ var CameraSwitchService = class {
31924
+ deps;
31925
+ constructor(deps) {
31926
+ this.deps = deps;
31927
+ }
31928
+ /** The whole group for one camera, derived. */
31929
+ async getCameraSwitches(deviceId) {
31930
+ const { derivation } = await this.gather(deviceId);
31931
+ return {
31932
+ deviceId,
31933
+ switches: deriveCameraSwitches(derivation),
31934
+ fetchedAt: Date.now()
31935
+ };
31936
+ }
31937
+ /**
31938
+ * The ids an operator has switched off, for `CameraStatus.switchedOff`.
31939
+ *
31940
+ * This is what lets a status surface tell DISABLED from BROKEN: a camera
31941
+ * reporting nothing with `['object-detection']` here was turned off; the
31942
+ * same camera with an empty list is failing.
31943
+ */
31944
+ async switchedOffIdsFor(deviceId) {
31945
+ const { derivation } = await this.gather(deviceId);
31946
+ return switchedOffIds(deriveCameraSwitches(derivation));
31947
+ }
31948
+ /**
31949
+ * Flip one switch and return the RECOMPOSED group, so a client renders what
31950
+ * the server settled on rather than its own optimistic guess.
31951
+ */
31952
+ async setCameraSwitch(deviceId, switchId, enabled) {
31953
+ const api = this.deps.api();
31954
+ if (!api) throw new Error("camera switches unavailable — the hub api is not wired yet");
31955
+ const state = await this.gather(deviceId);
31956
+ const current = deriveCameraSwitches(state.derivation).find((s) => s.id === switchId);
31957
+ if (!current) throw new Error(`unknown camera switch '${switchId}'`);
31958
+ if (!current.available) {
31959
+ this.deps.logger.warn("camera switch write REFUSED — the switch is not available here", {
31960
+ tags: { deviceId },
31961
+ meta: {
31962
+ switchId,
31963
+ reason: current.unavailableReason
31964
+ }
31965
+ });
31966
+ throw new Error(`camera switch '${switchId}' is not available for device ${deviceId} (${current.unavailableReason})`);
31967
+ }
31968
+ await this.applyWrite(api, deviceId, current, state, enabled);
31969
+ this.deps.logger.info("camera switch changed", {
31970
+ tags: { deviceId },
31971
+ meta: {
31972
+ switchId,
31973
+ enabled,
31974
+ authority: current.authority.kind
31975
+ }
31976
+ });
31977
+ return this.getCameraSwitches(deviceId);
31978
+ }
31979
+ async applyWrite(api, deviceId, current, state, enabled) {
31980
+ const authority = current.authority;
31981
+ switch (authority.kind) {
31982
+ case "device-disabled":
31983
+ await api.deviceManager.setDisabled.mutate({
31984
+ deviceId,
31985
+ disabled: !enabled
31986
+ });
31987
+ return;
31988
+ case "wrapper-binding": {
31989
+ const wrapperAddonId = state.wrapperAddonIdByCap.get(authority.capName);
31990
+ if (wrapperAddonId === void 0) throw new Error(`no wrapper registered for '${authority.capName}' — nothing could apply this switch`);
31991
+ await api.deviceManager.setWrapperActive.mutate({
31992
+ deviceId,
31993
+ capName: authority.capName,
31994
+ wrapperAddonId,
31995
+ active: enabled
31996
+ });
31997
+ return;
31998
+ }
31999
+ case "recording-config": {
32000
+ const config = state.recordingConfig;
32001
+ if (config === null) throw new Error(`recording config unreadable for device ${deviceId} — refusing to write`);
32002
+ await api.recording.setDeviceConfig.mutate({
32003
+ deviceId,
32004
+ config: {
32005
+ ...config,
32006
+ enabled
32007
+ }
32008
+ });
32009
+ return;
32010
+ }
32011
+ case "notification-mute":
32012
+ await api.notificationRules.setDeviceMuted.mutate({
32013
+ deviceId,
32014
+ muted: !enabled
32015
+ });
32016
+ return;
32017
+ }
32018
+ }
32019
+ /**
32020
+ * One bounded parallel fan-out over the four authorities. Every branch
32021
+ * degrades to `null` INDEPENDENTLY — one unreachable addon removes its own
32022
+ * switch from the group and leaves the other four usable.
32023
+ */
32024
+ async gather(deviceId) {
32025
+ const api = this.deps.api();
32026
+ if (!api) return {
32027
+ derivation: {
32028
+ deviceId,
32029
+ deviceDisabled: false,
32030
+ bindableCapNames: null,
32031
+ activeWrapperCapNames: null,
32032
+ recordingEnabled: null,
32033
+ notificationsMuted: null
32034
+ },
32035
+ wrapperAddonIdByCap: /* @__PURE__ */ new Map(),
32036
+ recordingConfig: null
32037
+ };
32038
+ const devicePromise = api.deviceManager.getDevice.query({ deviceId }).then((d) => isDeviceShape(d) ? d : null).catch((err) => {
32039
+ this.warn(deviceId, "getDevice", err);
32040
+ return null;
32041
+ });
32042
+ const bindingsPromise = api.deviceManager.getBindings.query({ deviceId }).then((b) => b.entries.filter((e) => e.kind === "wrapped").map((e) => e.capName)).catch((err) => {
32043
+ this.warn(deviceId, "getBindings", err);
32044
+ return null;
32045
+ });
32046
+ const boundProviderPromise = api.deviceManager.getBindings.query({ deviceId }).then((b) => {
32047
+ const map = /* @__PURE__ */ new Map();
32048
+ for (const e of b.entries) if (e.kind === "wrapped" && e.providerAddonId !== "") map.set(e.capName, e.providerAddonId);
32049
+ return map;
32050
+ }).catch(() => /* @__PURE__ */ new Map());
32051
+ const recordingPromise = api.recording.getDeviceConfig.query({ deviceId }).then((c) => isRecordingConfig(c) ? c : null).catch((err) => {
32052
+ this.warn(deviceId, "recording.getDeviceConfig", err);
32053
+ return null;
32054
+ });
32055
+ const mutesPromise = api.notificationRules.listDeviceMutes.query({}).then((r) => r.mutedDeviceIds).catch((err) => {
32056
+ this.warn(deviceId, "notificationRules.listDeviceMutes", err);
32057
+ return null;
32058
+ });
32059
+ const [device, activeWrapperCapNames, boundProviders, recordingConfig, mutedDeviceIds] = await Promise.all([
32060
+ devicePromise,
32061
+ bindingsPromise,
32062
+ boundProviderPromise,
32063
+ recordingPromise,
32064
+ mutesPromise
32065
+ ]);
32066
+ const bindable = device === null ? null : await api.deviceManager.listBindableCapsForDeviceType.query({ deviceType: device.type }).catch((err) => {
32067
+ this.warn(deviceId, "listBindableCapsForDeviceType", err);
32068
+ return null;
32069
+ });
32070
+ const wrapperAddonIdByCap = /* @__PURE__ */ new Map();
32071
+ for (const entry of bindable ?? []) {
32072
+ const first = entry.wrappers[0];
32073
+ const chosen = boundProviders.get(entry.capName) ?? first;
32074
+ if (chosen !== void 0) wrapperAddonIdByCap.set(entry.capName, chosen);
32075
+ }
32076
+ return {
32077
+ derivation: {
32078
+ deviceId,
32079
+ deviceDisabled: device?.disabled ?? false,
32080
+ bindableCapNames: bindable === null ? null : bindable.filter((b) => b.wrappers.length > 0).map((b) => b.capName),
32081
+ activeWrapperCapNames,
32082
+ recordingEnabled: recordingConfig === null ? null : recordingConfig.enabled,
32083
+ notificationsMuted: mutedDeviceIds === null ? null : mutedDeviceIds.includes(deviceId)
32084
+ },
32085
+ wrapperAddonIdByCap,
32086
+ recordingConfig
32087
+ };
32088
+ }
32089
+ warn(deviceId, source, err) {
32090
+ this.deps.logger.warn("camera switch source unreachable — its switch is not offered", {
32091
+ tags: { deviceId },
32092
+ meta: {
32093
+ source,
32094
+ error: errMsg(err)
32095
+ }
32096
+ });
32097
+ }
32098
+ };
32099
+ //#endregion
31263
32100
  //#region src/apply-device-provisioning.ts
31264
32101
  /**
31265
32102
  * Merge base then override for one step. Override keys win; undefined = inherit.
@@ -36764,6 +37601,10 @@ async function buildOrchestratorControllers(deps) {
36764
37601
  redispatchAllActiveCameras: (reason) => placement.redispatchAllActiveCameras(reason),
36765
37602
  clearOnSettingsChange: () => loadShed.clearOnSettingsChange()
36766
37603
  });
37604
+ const cameraSwitchService = new CameraSwitchService({
37605
+ api: () => deps.ctx().api ?? null,
37606
+ logger: deps.ctx().logger
37607
+ });
36767
37608
  const cameraStatusService = new CameraStatusService({
36768
37609
  api: () => deps.ctx().api,
36769
37610
  getAssignment: (deviceId) => ledger.getAssignment(deviceId),
@@ -36772,7 +37613,8 @@ async function buildOrchestratorControllers(deps) {
36772
37613
  hasCameraConfig: (deviceId) => ledger.hasConfig(deviceId),
36773
37614
  getPendingReason: (deviceId) => ledger.getPendingReason(deviceId),
36774
37615
  assignSource: (deviceId) => topology.assignSource(deviceId),
36775
- listAssignedDeviceIds: () => ledger.listAssignedDeviceIds()
37616
+ listAssignedDeviceIds: () => ledger.listAssignedDeviceIds(),
37617
+ switchedOffIdsFor: (deviceId) => cameraSwitchService.switchedOffIdsFor(deviceId)
36776
37618
  });
36777
37619
  const reconcile = new ReconcileController({
36778
37620
  api: () => deps.ctx().api ?? null,
@@ -37016,6 +37858,7 @@ async function buildOrchestratorControllers(deps) {
37016
37858
  session,
37017
37859
  deviceConfig,
37018
37860
  cameraStatusService,
37861
+ cameraSwitchService,
37019
37862
  reconcile,
37020
37863
  nodeLifecycle,
37021
37864
  pipelineWatchdog,
@@ -37568,6 +38411,8 @@ var PipelineOrchestratorAddon = class PipelineOrchestratorAddon extends BaseAddo
37568
38411
  * the end of `onShutdown`.
37569
38412
  */
37570
38413
  cameraStatusService = null;
38414
+ /** The per-camera function switch group (D61) — see `camera-switch-service.ts`. */
38415
+ cameraSwitchService = null;
37571
38416
  /**
37572
38417
  * Device-details aggregator contributions (`getDeviceSettingsContribution`/
37573
38418
  * `getDeviceLiveContribution`/`applyDeviceSettingsPatch`/
@@ -37751,6 +38596,7 @@ var PipelineOrchestratorAddon = class PipelineOrchestratorAddon extends BaseAddo
37751
38596
  this.session = controllers.session;
37752
38597
  this.deviceConfig = controllers.deviceConfig;
37753
38598
  this.cameraStatusService = controllers.cameraStatusService;
38599
+ this.cameraSwitchService = controllers.cameraSwitchService;
37754
38600
  this.reconcile = controllers.reconcile;
37755
38601
  this.nodeLifecycle = controllers.nodeLifecycle;
37756
38602
  this.pipelineWatchdog = controllers.pipelineWatchdog;
@@ -37863,6 +38709,7 @@ var PipelineOrchestratorAddon = class PipelineOrchestratorAddon extends BaseAddo
37863
38709
  this.zonesProvider = null;
37864
38710
  this.zoneRulesProvider = null;
37865
38711
  this.cameraStatusService = null;
38712
+ this.cameraSwitchService = null;
37866
38713
  this.deviceConfig = null;
37867
38714
  this.settingsStore?.dispose();
37868
38715
  this.settingsStore = null;
@@ -38517,6 +39364,22 @@ var PipelineOrchestratorAddon = class PipelineOrchestratorAddon extends BaseAddo
38517
39364
  return this.settingsStore.deleteTemplate(input);
38518
39365
  }
38519
39366
  /**
39367
+ * The per-camera FUNCTION SWITCH group (D61) — derived, never stored.
39368
+ *
39369
+ * Read-only and view-authed: a non-admin must be able to SEE that a camera
39370
+ * is quiet because somebody switched it off.
39371
+ */
39372
+ async getCameraSwitches(input) {
39373
+ return this.cameraSwitchService.getCameraSwitches(input.deviceId);
39374
+ }
39375
+ /**
39376
+ * Flip one switch, routed to the authority that already owned the function.
39377
+ * Returns the recomposed group so a client never renders its own guess.
39378
+ */
39379
+ async setCameraSwitch(input) {
39380
+ return this.cameraSwitchService.setCameraSwitch(input.deviceId, input.switchId, input.enabled);
39381
+ }
39382
+ /**
38520
39383
  * Server-composed aggregated status for a single camera.
38521
39384
  *
38522
39385
  * Fans out in parallel (bounded, per-stage graceful degradation) to