@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.js CHANGED
@@ -7510,6 +7510,14 @@ var EncodeProfileSchema = object({
7510
7510
  "main",
7511
7511
  "high"
7512
7512
  ]).optional(),
7513
+ /**
7514
+ * `-level`, e.g. `'3.1'`. A consumer that ADVERTISES a level in its SDP
7515
+ * (`profile-level-id=42e01f` is Baseline 3.1) must constrain the encoder to
7516
+ * it, or it ships a stream that does not match its own advertisement — the
7517
+ * defect class that kept HomeKit black for a year and that Alexa carried
7518
+ * silently. Optional because a browser negotiates the level itself.
7519
+ */
7520
+ level: string().optional(),
7513
7521
  width: number().int().positive().optional(),
7514
7522
  height: number().int().positive().optional(),
7515
7523
  fps: number().positive().optional(),
@@ -7557,6 +7565,29 @@ var EncodeProfileSchema = object({
7557
7565
  outputArgs: array(string()).optional()
7558
7566
  });
7559
7567
  /**
7568
+ * The shape every live egress starts from: H.264 Baseline 3.1 at 720p25.
7569
+ * Baseline because it is the one profile every consumer in this repo decodes
7570
+ * (Echo, iOS, an old browser); 3.1 because that is what the SDPs advertise.
7571
+ */
7572
+ var BASE_LIVE_EGRESS_PROFILE = {
7573
+ video: {
7574
+ codec: "h264",
7575
+ profile: "baseline",
7576
+ level: "3.1",
7577
+ width: 1280,
7578
+ height: 720,
7579
+ fps: 25,
7580
+ bitrateKbps: 2500,
7581
+ gopFrames: 25,
7582
+ bf: 0,
7583
+ preset: "veryfast",
7584
+ tune: "zerolatency"
7585
+ },
7586
+ audio: "passthrough"
7587
+ };
7588
+ ({ ...BASE_LIVE_EGRESS_PROFILE }), { ...BASE_LIVE_EGRESS_PROFILE.video };
7589
+ ({ ...BASE_LIVE_EGRESS_PROFILE });
7590
+ /**
7560
7591
  * Deep wiring healthcheck — snapshot of active reachability probes across
7561
7592
  * every declared capability + widget of every installed plugin, on every
7562
7593
  * node. Produced by the backend `WiringHealthService` and surfaced via
@@ -7606,6 +7637,236 @@ object({
7606
7637
  })
7607
7638
  });
7608
7639
  /**
7640
+ * Per-camera FUNCTION SWITCHES — the one coherent on/off surface over the
7641
+ * pipeline functions an operator thinks in terms of.
7642
+ *
7643
+ * ## This file adds no state
7644
+ *
7645
+ * Every switch here is a VIEW onto an authority that already existed
7646
+ * ([D61](../../../../docs/decisions/adr-0062.md)). The whole point of the
7647
+ * group is that there is exactly one place each function is turned off, and
7648
+ * the group routes to it:
7649
+ *
7650
+ * | Switch | Authority | Proven "off stops the work" gate |
7651
+ * | --- | --- | --- |
7652
+ * | `stream-broker` | `deviceManager.setDisabled` | `StreamBrokerManager.reconcileAllCatalogs` releases the brokers; `ensureBroker` refuses re-creation |
7653
+ * | `object-detection` | `deviceManager.setWrapperActive('detection-pipeline')` | `PipelineSettingsStore.resolvePipelineForDevice` returns `{ steps: [], audio: null }` |
7654
+ * | `audio-analysis` | `deviceManager.setWrapperActive('audio-analysis')` | `AudioSubscriptionController.subscribeAudioStream` returns `null` before opening the stream |
7655
+ * | `recording` | `recording.setDeviceConfig` → `RecordingConfig.enabled` | `band-decision.shouldRecord` returns false; the controller detaches the device |
7656
+ * | `notifications` | `notificationRules.setDeviceMuted` | `NotificationCenter.evaluateAndEnqueue` returns before any rule is evaluated |
7657
+ *
7658
+ * The wrapper-binding pair is not a new idea: `legacy-migrations.ts` already
7659
+ * migrated the legacy `audioEnabled` / `pipelineEnabled` /
7660
+ * `motionDetectionEnabled` booleans ONTO `setWrapperActive`. The group is the
7661
+ * surface that decision never got.
7662
+ *
7663
+ * ## Two rules that are load-bearing
7664
+ *
7665
+ * - **Recording's switch is `enabled`, never the bands.** `bands` is the only
7666
+ * authored intent and `mode` is derived from it (`deriveRecordingMode`).
7667
+ * Expressing "off" by clearing bands destroys the operator's schedule and
7668
+ * turning the camera back on would then silently record nothing.
7669
+ * - **A switch that is off must be reported as off**, not merely produce
7670
+ * nothing. {@link CameraSwitch.enabled} is what a status surface renders as
7671
+ * "disabled by an operator" instead of "broken" — see
7672
+ * `CameraStatus.switchedOff`.
7673
+ */
7674
+ /**
7675
+ * The five functions the operator named (2026-08-05). Deliberately NOT one id
7676
+ * per pipeline step: face recognition and plate/LPR are per-step toggles on
7677
+ * `pipelineOrchestrator.setCameraStepToggle` and belong in the pipeline
7678
+ * editor, not in a five-button safety group.
7679
+ */
7680
+ var CameraSwitchIdSchema = _enum([
7681
+ "stream-broker",
7682
+ "object-detection",
7683
+ "audio-analysis",
7684
+ "recording",
7685
+ "notifications"
7686
+ ]);
7687
+ /** Stable render order — broadest blast radius first. */
7688
+ var CAMERA_SWITCH_ORDER = [
7689
+ "stream-broker",
7690
+ "object-detection",
7691
+ "audio-analysis",
7692
+ "recording",
7693
+ "notifications"
7694
+ ];
7695
+ /**
7696
+ * WHERE the switch's state actually lives. A discriminated union rather than a
7697
+ * string so both the writer (the orchestrator's `setCameraSwitch`) and any
7698
+ * reader can exhaustively narrow — and so "the group added a parallel map" is
7699
+ * a compile error rather than a review comment.
7700
+ */
7701
+ var CameraSwitchAuthoritySchema = discriminatedUnion("kind", [
7702
+ object({ kind: literal("device-disabled") }),
7703
+ object({
7704
+ kind: literal("wrapper-binding"),
7705
+ capName: string()
7706
+ }),
7707
+ object({ kind: literal("recording-config") }),
7708
+ object({ kind: literal("notification-mute") })
7709
+ ]);
7710
+ /**
7711
+ * Why a switch is not offered for this camera. Rendered instead of the
7712
+ * control, never as a dead control — an absent function and a broken one must
7713
+ * not look the same.
7714
+ */
7715
+ var CameraSwitchUnavailableReasonSchema = _enum(["no-provider", "source-unreachable"]);
7716
+ /**
7717
+ * One switch, resolved for one camera.
7718
+ *
7719
+ * `label` and `costWhenOff` travel ON THE WIRE rather than being looked up
7720
+ * client-side: the viewer is a separate repository that does not import
7721
+ * `@camstack/types`, and a cost line duplicated in two clients is a cost line
7722
+ * that will disagree with itself. Five rows per camera is nothing.
7723
+ */
7724
+ var CameraSwitchSchema = object({
7725
+ id: CameraSwitchIdSchema,
7726
+ label: string(),
7727
+ /**
7728
+ * What the operator LOSES while this is off, in one sentence. Required, not
7729
+ * optional: a switch that cannot say what it costs should not ship.
7730
+ */
7731
+ costWhenOff: string(),
7732
+ /** False = do not render a control. `unavailableReason` says why. */
7733
+ available: boolean(),
7734
+ unavailableReason: CameraSwitchUnavailableReasonSchema.optional(),
7735
+ /** Current state. Meaningless when `available` is false — read it as `true`. */
7736
+ enabled: boolean(),
7737
+ authority: CameraSwitchAuthoritySchema
7738
+ });
7739
+ /** The whole group for one camera. */
7740
+ var CameraSwitchGroupSchema = object({
7741
+ deviceId: number().int(),
7742
+ switches: array(CameraSwitchSchema).readonly(),
7743
+ /** Unix ms when the group was composed server-side. */
7744
+ fetchedAt: number()
7745
+ });
7746
+ /**
7747
+ * THE catalog. One entry per switch; the cost lines are the operator-facing
7748
+ * contract and are written to be true rather than reassuring.
7749
+ */
7750
+ var CAMERA_SWITCH_CATALOG = {
7751
+ "stream-broker": {
7752
+ id: "stream-broker",
7753
+ label: "Camera",
7754
+ 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.",
7755
+ authority: { kind: "device-disabled" }
7756
+ },
7757
+ "object-detection": {
7758
+ id: "object-detection",
7759
+ label: "Object detection & tracking",
7760
+ 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.",
7761
+ authority: {
7762
+ kind: "wrapper-binding",
7763
+ capName: "detection-pipeline"
7764
+ }
7765
+ },
7766
+ "audio-analysis": {
7767
+ id: "audio-analysis",
7768
+ label: "Audio detection & classification",
7769
+ 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.",
7770
+ authority: {
7771
+ kind: "wrapper-binding",
7772
+ capName: "audio-analysis"
7773
+ }
7774
+ },
7775
+ recording: {
7776
+ id: "recording",
7777
+ label: "Recording",
7778
+ 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.",
7779
+ authority: { kind: "recording-config" }
7780
+ },
7781
+ notifications: {
7782
+ id: "notifications",
7783
+ label: "Notifications",
7784
+ 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.",
7785
+ authority: { kind: "notification-mute" }
7786
+ }
7787
+ };
7788
+ /** Resolve one switch's `{ available, enabled }` pair. */
7789
+ function resolveState(descriptor, input) {
7790
+ switch (descriptor.authority.kind) {
7791
+ case "device-disabled": return {
7792
+ available: true,
7793
+ enabled: !input.deviceDisabled
7794
+ };
7795
+ case "wrapper-binding": {
7796
+ const capName = descriptor.authority.capName;
7797
+ if (input.bindableCapNames === null || input.activeWrapperCapNames === null) return {
7798
+ available: false,
7799
+ enabled: true,
7800
+ unavailableReason: "source-unreachable"
7801
+ };
7802
+ if (!input.bindableCapNames.includes(capName)) return {
7803
+ available: false,
7804
+ enabled: true,
7805
+ unavailableReason: "no-provider"
7806
+ };
7807
+ return {
7808
+ available: true,
7809
+ enabled: input.activeWrapperCapNames.includes(capName)
7810
+ };
7811
+ }
7812
+ case "recording-config":
7813
+ if (input.recordingEnabled === null) return {
7814
+ available: false,
7815
+ enabled: true,
7816
+ unavailableReason: "source-unreachable"
7817
+ };
7818
+ return {
7819
+ available: true,
7820
+ enabled: input.recordingEnabled
7821
+ };
7822
+ case "notification-mute":
7823
+ if (input.notificationsMuted === null) return {
7824
+ available: false,
7825
+ enabled: true,
7826
+ unavailableReason: "source-unreachable"
7827
+ };
7828
+ return {
7829
+ available: true,
7830
+ enabled: !input.notificationsMuted
7831
+ };
7832
+ }
7833
+ }
7834
+ /**
7835
+ * Pure derivation of the whole group. No I/O — the orchestrator gathers, this
7836
+ * decides, so the decision is testable without a hub.
7837
+ *
7838
+ * Order is {@link CAMERA_SWITCH_ORDER}; unavailable switches are RETURNED
7839
+ * rather than filtered out, so a client can explain the gap instead of
7840
+ * silently rendering four buttons where another camera shows five.
7841
+ */
7842
+ function deriveCameraSwitches(input) {
7843
+ return CAMERA_SWITCH_ORDER.map((id) => {
7844
+ const descriptor = CAMERA_SWITCH_CATALOG[id];
7845
+ const state = resolveState(descriptor, input);
7846
+ return {
7847
+ id: descriptor.id,
7848
+ label: descriptor.label,
7849
+ costWhenOff: descriptor.costWhenOff,
7850
+ available: state.available,
7851
+ ...state.unavailableReason !== void 0 ? { unavailableReason: state.unavailableReason } : {},
7852
+ enabled: state.enabled,
7853
+ authority: descriptor.authority
7854
+ };
7855
+ });
7856
+ }
7857
+ /**
7858
+ * The ids an operator has switched OFF, for a status surface.
7859
+ *
7860
+ * This is the answer to "a disabled function must be visible as DISABLED, not
7861
+ * merely quiet": a camera reporting zero detections with
7862
+ * `switchedOff: ['object-detection']` was turned off; the same camera with an
7863
+ * empty list is broken. Unavailable switches never appear — a function nobody
7864
+ * provides was not switched off by anyone.
7865
+ */
7866
+ function switchedOffIds(switches) {
7867
+ return switches.filter((s) => s.available && !s.enabled).map((s) => s.id);
7868
+ }
7869
+ /**
7609
7870
  * Ops-log — the durable, append-only operations audit shared by the
7610
7871
  * recordings and events management surfaces.
7611
7872
  *
@@ -7624,14 +7885,16 @@ var OpsLogOpSchema = _enum([
7624
7885
  "manual-delete",
7625
7886
  "rescan",
7626
7887
  "retention-run",
7627
- "relocate"
7888
+ "relocate",
7889
+ "orphan-audit"
7628
7890
  ]);
7629
7891
  /** Why the operation ran. */
7630
7892
  var OpsLogReasonSchema = _enum([
7631
7893
  "retention",
7632
7894
  "quota",
7633
7895
  "manual",
7634
- "operator"
7896
+ "operator",
7897
+ "maintenance"
7635
7898
  ]);
7636
7899
  /** One audit row, shared verbatim by both domains. */
7637
7900
  var OpsLogEntrySchema = object({
@@ -9536,6 +9799,100 @@ var RtpSourceSchema = object({
9536
9799
  encoder: string(),
9537
9800
  pipelineKey: string()
9538
9801
  });
9802
+ /**
9803
+ * The encode request — **structured and serialisable, with NO raw-flag escape
9804
+ * hatch.** This is deliberate and it is the one lesson taken from
9805
+ * `getStreamWithCodec`: that method's `outputArgs: string[]` is simultaneously
9806
+ * its extensibility mechanism AND part of `pipelineKeyFor`'s sharing key, so
9807
+ * adding a flag silently forks the shared child, and two consumers that mean
9808
+ * the same thing but spell it differently never share. Here every knob is a
9809
+ * NAMED field: a new requirement becomes a schema field (and a codegen run),
9810
+ * never an opaque array.
9811
+ *
9812
+ * `inputArgs` / `outputArgs` are omitted from the profile for the same reason.
9813
+ * The operator-facing derived-stream transform editor still has them — that is
9814
+ * a different surface (`publishCameraStream({ kind: 'derived' })`) with a
9815
+ * different purpose (reshaping a badly-behaved SOURCE), and it is unchanged.
9816
+ */
9817
+ var EgressEncodeSchema = EncodeProfileSchema.omit({
9818
+ inputArgs: true,
9819
+ outputArgs: true
9820
+ });
9821
+ /**
9822
+ * How the encoder is bounded. `'tight'` is a one-second VBV window for a
9823
+ * consumer whose budget is enforced per second (HomeKit); `'relaxed'` is two
9824
+ * seconds, letting a keyframe spike borrow from the next second (a browser,
9825
+ * an Echo). Named rather than numeric so the INTENT survives.
9826
+ */
9827
+ var EgressRateControlSchema = _enum(["tight", "relaxed"]);
9828
+ var EgressTranscodeRequestSchema = object({
9829
+ deviceId: number().int().nonnegative(),
9830
+ /** Which published stream to read. */
9831
+ source: discriminatedUnion("kind", [object({
9832
+ kind: literal("profile"),
9833
+ profile: CamProfileSchema
9834
+ }), object({
9835
+ kind: literal("cam-stream"),
9836
+ camStreamId: string().min(1)
9837
+ })]),
9838
+ encode: EgressEncodeSchema,
9839
+ rateControl: EgressRateControlSchema.optional(),
9840
+ /**
9841
+ * `-bsf:v`. A consumer that negotiates its OWN SDP (HomeKit) cannot carry
9842
+ * out-of-band extradata and needs `dump_extra` on both the copy and encode
9843
+ * branches. Enumerated, not free text.
9844
+ */
9845
+ bitstreamFilter: _enum([
9846
+ "dump_extra",
9847
+ "h264_mp4toannexb",
9848
+ "hevc_mp4toannexb"
9849
+ ]).optional(),
9850
+ pixelFormat: _enum(["yuv420p", "nv12"]).optional(),
9851
+ /**
9852
+ * Operator/consumer override for decode hardware. ABSENT is the normal case
9853
+ * and the one that matters: the broker then resolves the backend from the
9854
+ * DECODER ADDON's per-node `probedBestHwaccel` (see
9855
+ * `@camstack/types` `ffmpeg/hwaccel.ts`), which is the ranking known to work
9856
+ * on this hardware — never the raw kernel resolver's qsv-first order.
9857
+ */
9858
+ decodeHwAccel: _enum([
9859
+ "auto",
9860
+ "none",
9861
+ "videotoolbox",
9862
+ "vaapi",
9863
+ "qsv",
9864
+ "cuda"
9865
+ ]).optional(),
9866
+ /**
9867
+ * Host to embed in the returned restream `url`. The broker mints hub-local
9868
+ * `127.0.0.1` URLs; a consumer on another node passes a cluster-resolvable
9869
+ * host (`NodeTopologyService.reachableHostByNode`) so the returned URL is
9870
+ * dialable from there. Same contract as `getStreamWithCodec.hostname` —
9871
+ * `substituteRtspHost` rewrites only the dial address, never the restreamer.
9872
+ */
9873
+ hostname: string().optional(),
9874
+ /** Attribution for the broker panel. Never part of the sharing key. */
9875
+ tag: string().optional()
9876
+ });
9877
+ var EgressTranscodeSchema = object({
9878
+ /** Dial-able RTSP url (host-substituted when `hostname` was supplied). */
9879
+ url: string(),
9880
+ /** Release handle. Refcounted — the child dies when the last holder releases. */
9881
+ pipelineKey: string(),
9882
+ videoCodec: _enum(["H264", "H265"]),
9883
+ resolution: object({
9884
+ width: number().int().positive(),
9885
+ height: number().int().positive()
9886
+ }),
9887
+ transcoded: boolean(),
9888
+ encoder: string(),
9889
+ /**
9890
+ * The decode backend the child ACTUALLY ran with — `null` for software.
9891
+ * Returned rather than assumed: a consumer that asked for hardware and got
9892
+ * software needs to be able to see that without reading the broker's logs.
9893
+ */
9894
+ decodeHwAccel: string().nullable()
9895
+ });
9539
9896
  method(object({
9540
9897
  deviceId: number().int().nonnegative(),
9541
9898
  camStreamId: string().min(1),
@@ -9645,6 +10002,15 @@ method(object({
9645
10002
  }), {
9646
10003
  kind: "mutation",
9647
10004
  auth: "admin"
10005
+ }), method(EgressTranscodeRequestSchema, EgressTranscodeSchema, {
10006
+ kind: "mutation",
10007
+ auth: "admin"
10008
+ }), method(object({ pipelineKey: string() }), object({
10009
+ released: boolean(),
10010
+ refcount: number().int().nonnegative()
10011
+ }), {
10012
+ kind: "mutation",
10013
+ auth: "admin"
9648
10014
  }), method(SubscribeAudioChunksInputSchema, SubscribeAudioChunksResultSchema, { kind: "mutation" }), method(object({
9649
10015
  subscriptionId: string(),
9650
10016
  maxCount: number().int().positive().default(8)
@@ -13993,12 +14359,13 @@ var NcConditionsSchema = object({
13993
14359
  * source; otherwise the subject's source must equal it. Legacy records
13994
14360
  * with no stamped source are treated as `pipeline`. The union spans both
13995
14361
  * record kinds — object events carry `pipeline` | `onboard`, synthetic
13996
- * tracks carry `sensor`.
14362
+ * tracks carry `sensor` (a linked device) or `audio` (a D62 audio marker).
13997
14363
  */
13998
14364
  source: _enum([
13999
14365
  "pipeline",
14000
14366
  "onboard",
14001
14367
  "sensor",
14368
+ "audio",
14002
14369
  "any"
14003
14370
  ]).optional(),
14004
14371
  /**
@@ -14574,6 +14941,12 @@ method(object({}), object({ rules: array(NcRuleSchema) }), { auth: "admin" }), m
14574
14941
  }), object({ success: literal(true) }), {
14575
14942
  kind: "mutation",
14576
14943
  auth: "admin"
14944
+ }), method(object({}), object({ mutedDeviceIds: array(number().int()).readonly() }), { auth: "admin" }), method(object({
14945
+ deviceId: number().int(),
14946
+ muted: boolean()
14947
+ }), object({ success: literal(true) }), {
14948
+ kind: "mutation",
14949
+ auth: "admin"
14577
14950
  }), method(object({
14578
14951
  rule: NcRuleInputSchema,
14579
14952
  lookbackMinutes: number().int().min(1).max(1440).default(60)
@@ -14912,12 +15285,60 @@ var TrackAudioLabelSchema = object({
14912
15285
  });
14913
15286
  /**
14914
15287
  * How a track was produced. `pipeline` (default / absent) = the spatial
14915
- * detection+tracking pipeline. `sensor` = a SYNTHETIC track projected from a
14916
- * linked sensor/control state change (no positions; carries a snapshot). The
14917
- * spatial subsystems (tracker association, occupancy count, re-id/embedding,
14918
- * resurrection) MUST skip `sensor` tracks they have no bbox trajectory.
15288
+ * detection+tracking pipeline. Every OTHER value is a SYNTHETIC projection
15289
+ * no positions, a single snapshot, and no bbox trajectory at all:
15290
+ *
15291
+ * - `sensor` — a linked sensor/control device state change.
15292
+ * - `audio` — an audio event on the camera itself that was anomalous for
15293
+ * THAT camera, loud, and heard while nothing visual was happening (D62).
15294
+ *
15295
+ * The spatial subsystems (tracker association, occupancy count, re-id /
15296
+ * embedding, resurrection) MUST skip every synthetic source. Test for that
15297
+ * with `isSpatialTrack`, which allow-lists `pipeline` — a `!== 'sensor'`
15298
+ * check silently readmits every source added after it was written.
14919
15299
  */
14920
- var TrackSourceSchema = _enum(["pipeline", "sensor"]);
15300
+ var TrackSourceSchema = _enum([
15301
+ "pipeline",
15302
+ "sensor",
15303
+ "audio"
15304
+ ]);
15305
+ /**
15306
+ * Per-track OPERATOR flags — set by hand from the admin UI or the viewer, never
15307
+ * by the pipeline. Spread into `TrackSchema` and `KeyEventSchema` from one place
15308
+ * so the two surfaces cannot drift.
15309
+ *
15310
+ * **Absent ≠ false.** A track that has never been touched omits the field; an
15311
+ * explicitly un-flagged track carries `false`. Legacy rows written before the
15312
+ * columns existed read as absent, and a consumer that needs a boolean should say
15313
+ * `flag === true`, not `flag !== false`.
15314
+ *
15315
+ * What the flags DO is deliberately UNDEFINED at the time of writing: they are
15316
+ * operator curation, and the behaviour they drive will be specified separately.
15317
+ * In particular a `markForTrain` track is NOT pinned against retention — see
15318
+ * `docs/decisions/adr-0059.md` for why that is a store-level change, not a flag.
15319
+ */
15320
+ var TrackFlagFields = {
15321
+ /** Operator marked this track as training material. */
15322
+ markForTrain: boolean().optional(),
15323
+ /** Operator marked this track for diagnostic attention. */
15324
+ debug: boolean().optional()
15325
+ };
15326
+ /**
15327
+ * The write half: a PARTIAL patch. An omitted key is left untouched, so setting
15328
+ * one flag can never clear the other — the toggles are independent and are
15329
+ * driven from three surfaces that do not know about each other.
15330
+ */
15331
+ var TrackFlagsPatchSchema = object(TrackFlagFields);
15332
+ /**
15333
+ * The resolved flag state after a write. Both fields are REQUIRED here (absent
15334
+ * collapses to `false`) so a caller can drive a toggle's checked state off the
15335
+ * mutation result without a re-fetch.
15336
+ */
15337
+ var TrackFlagsSchema = object({
15338
+ trackId: string(),
15339
+ markForTrain: boolean(),
15340
+ debug: boolean()
15341
+ });
14921
15342
  var TrackSchema = object({
14922
15343
  trackId: string(),
14923
15344
  deviceId: number(),
@@ -14960,7 +15381,8 @@ var TrackSchema = object({
14960
15381
  /** Normalized 0..1 trajectory envelope (see {@link TrackEnvelopeSchema}).
14961
15382
  * Populated from the persisted envelope columns on historical reads;
14962
15383
  * absent on legacy rows, dims-less tracks and active (in-RAM) tracks. */
14963
- envelope: TrackEnvelopeSchema.optional()
15384
+ envelope: TrackEnvelopeSchema.optional(),
15385
+ ...TrackFlagFields
14964
15386
  });
14965
15387
  var BaseEventFields = {
14966
15388
  id: string(),
@@ -15173,7 +15595,8 @@ var KeyEventSchema = object({
15173
15595
  /** Highest-confidence ObjectEvent id for the track (empty when none). */
15174
15596
  bestEventId: string(),
15175
15597
  /** Track lifetime in ms (lastSeen - firstSeen). */
15176
- windowMs: number().optional()
15598
+ windowMs: number().optional(),
15599
+ ...TrackFlagFields
15177
15600
  });
15178
15601
  object({
15179
15602
  trackId: string(),
@@ -15259,7 +15682,31 @@ var RebuildObjectEmbeddingsInput = object({
15259
15682
  since: number().optional(),
15260
15683
  until: number().optional(),
15261
15684
  /** Stop after this many tracks; the result reports whether more remain. */
15262
- maxTracks: number().int().positive().optional()
15685
+ maxTracks: number().int().positive().optional(),
15686
+ /**
15687
+ * Run every embedding on THIS node instead of round-robining the fleet.
15688
+ *
15689
+ * Named `executeOnNodeId` and not `nodeId` on purpose: an inline `nodeId`
15690
+ * field in cap args is read by `parent-unowned-call.ts` as a ROUTING PIN, so
15691
+ * calling it that would pin the rebuild REQUEST itself to that node — the
15692
+ * rebuild orchestration lives on the hub, and only the per-track step runs
15693
+ * remotely. This field is data; the per-track pin is applied inside.
15694
+ *
15695
+ * Absent ⇒ round-robin over every online node whose runner can serve the
15696
+ * pinned model.
15697
+ */
15698
+ executeOnNodeId: string().optional(),
15699
+ /**
15700
+ * Milliseconds to wait between tracks; omit for the built-in default, `0` to
15701
+ * run flat out.
15702
+ *
15703
+ * A rebuild is bulk maintenance on hub-main's single thread. Measured
15704
+ * 2026-08-06, an unpaced pass held that thread busy 82.2 s out of 120 and
15705
+ * pushed `nodes.topology` from 0.25 s to 26 s for 43 minutes. The value in
15706
+ * force is logged at start and finish so a deliberately slow pass reads
15707
+ * differently from a stalled one.
15708
+ */
15709
+ pacingMs: number().int().nonnegative().optional()
15263
15710
  });
15264
15711
  /**
15265
15712
  * Result of emptying the CLIP index.
@@ -15293,13 +15740,23 @@ var RebuildStatusSchema = object({
15293
15740
  /** Tracks with no usable detection box. */
15294
15741
  missingBbox: number(),
15295
15742
  /**
15296
- * Tracks the pipeline REFUSED rather than broke on: the camera is not
15297
- * attached, or `clip-embedding` is not enabled in its step tree. Separate
15298
- * from `failed` because the remedy is a configuration change, not an engine
15299
- * investigation and because a pass over decommissioned cameras would
15300
- * otherwise read as a total engine outage.
15743
+ * Tracks an executing node REFUSED rather than broke on an unreadable key
15744
+ * frame, a step that threw. Separate from `failed` because the remedy is
15745
+ * different, and because a whole camera silently contributing zero vectors
15746
+ * is the shape of failure a rebuild must never hide.
15301
15747
  */
15302
15748
  notRunnable: number(),
15749
+ /**
15750
+ * The pass stopped because NO node could serve the pinned model.
15751
+ *
15752
+ * Distinct from `notRunnable` on purpose: that one says "this track was
15753
+ * refused", this one says "the cluster cannot do this work at all" — every
15754
+ * candidate node either lacks the `clip-embedding` step, lacks a build of the
15755
+ * pinned model for its engine format, or dropped out. The remedy is a model /
15756
+ * engine change, not a per-camera one. Non-zero here always comes with
15757
+ * `complete: false`.
15758
+ */
15759
+ noCapableNode: number(),
15303
15760
  failed: number(),
15304
15761
  /** Set once a pass ends: true only when EVERYTHING was covered. */
15305
15762
  complete: boolean().nullable(),
@@ -15371,7 +15828,12 @@ DeviceType.Camera, method(object({ deviceId: number() }), array(TrackSchema).rea
15371
15828
  }), {
15372
15829
  kind: "mutation",
15373
15830
  auth: "admin"
15374
- }), method(object({}), EventStoreFootprintSchema, {
15831
+ }), method(object({
15832
+ /** Log/audit scope only — the trackId is globally unique on its own. */
15833
+ deviceId: number(),
15834
+ trackId: string(),
15835
+ flags: TrackFlagsPatchSchema
15836
+ }), TrackFlagsSchema, { kind: "mutation" }), method(object({}), EventStoreFootprintSchema, {
15375
15837
  kind: "query",
15376
15838
  auth: "admin"
15377
15839
  }), method(object({
@@ -16166,6 +16628,53 @@ var DetailResultSchema = object({
16166
16628
  nativeFaceShortSidePx: number().optional()
16167
16629
  });
16168
16630
  /**
16631
+ * Why an executing node REFUSED a stateless step run (`runStatelessStep`).
16632
+ *
16633
+ * A refusal is a first-class answer, not an error, because the caller's next
16634
+ * move depends on WHICH one it is — and because "the pass produced nothing"
16635
+ * must never be reachable without a named, counted cause. The two tiers:
16636
+ *
16637
+ * - **node-level** (`unknown-step`, `model-not-servable`) — this node can
16638
+ * never serve this (step, model) pair. The caller drops it from its rotation
16639
+ * and retries the same work elsewhere; nothing about the work changes.
16640
+ * - **work-level** (`unreadable-frame`, `execution-failed`) — this node is
16641
+ * fine, this one request is not. Retrying it on another node would only
16642
+ * spread the same failure.
16643
+ */
16644
+ var StatelessStepRefusalSchema = _enum([
16645
+ "unknown-step",
16646
+ "model-not-servable",
16647
+ "unreadable-frame",
16648
+ "execution-failed"
16649
+ ]);
16650
+ /**
16651
+ * Answer to `runStatelessStep` — a discriminated union rather than a nullable
16652
+ * result, because `null` is exactly what made the camera-bound detail path
16653
+ * unable to tell "refused" from "never asked".
16654
+ */
16655
+ var RunStatelessStepResultSchema = discriminatedUnion("kind", [object({
16656
+ kind: literal("ran"),
16657
+ /** The node that actually executed it — the pin, echoed back for the log. */
16658
+ nodeId: string(),
16659
+ /**
16660
+ * The model the step ran with.
16661
+ *
16662
+ * The node verified this exact id has a build for the format it dispatched
16663
+ * on BEFORE running, so the executor's format resolution returns it
16664
+ * unchanged. A caller that pinned a model must compare this field and
16665
+ * treat a mismatch as a refusal — the whole point of the pin is that a
16666
+ * pass writes one feature space.
16667
+ */
16668
+ modelId: string(),
16669
+ details: array(DetailResultSchema)
16670
+ }), object({
16671
+ kind: literal("refused"),
16672
+ nodeId: string(),
16673
+ reason: StatelessStepRefusalSchema,
16674
+ /** Human-readable specifics — the format tried, the formats shipped, etc. */
16675
+ detail: string()
16676
+ })]);
16677
+ /**
16169
16678
  * Per-camera tunable ranges + defaults. Single source of truth used
16170
16679
  * by both the Zod data schema (validation + default fallback) and
16171
16680
  * the device settings UI (slider min/max/step). Touch one place and
@@ -16654,7 +17163,32 @@ method(RunnerCameraConfigSchema, object({ success: literal(true) }), { kind: "mu
16654
17163
  cropJpeg: string().optional(),
16655
17164
  parent: DetailParentSchema,
16656
17165
  steps: array(string()).optional()
16657
- }), object({ details: array(DetailResultSchema) }).nullable(), { kind: "mutation" });
17166
+ }), object({ details: array(DetailResultSchema) }).nullable(), { kind: "mutation" }), method(object({
17167
+ /** Catalog step id, e.g. `clip-embedding`. */
17168
+ stepId: string(),
17169
+ /**
17170
+ * REQUIRED model pin. The node runs this exact model or refuses with
17171
+ * `model-not-servable` — it never substitutes a format default, because
17172
+ * a fleet pass that round-robins across nodes would then fill one index
17173
+ * from several encoders.
17174
+ */
17175
+ modelId: string(),
17176
+ /** FULL FRAME, base64 JPEG. The runner cuts — do NOT pre-crop. */
17177
+ frameJpeg: string(),
17178
+ /**
17179
+ * The subject box, NORMALISED [0,1] against `frameJpeg`. Normalised on
17180
+ * purpose: the caller stores boxes against a downscaled analysis frame
17181
+ * while the stored key frame is native-resolution, and the only side
17182
+ * that reliably knows the image's pixel dimensions is the side that
17183
+ * decodes it. Denormalising here removes a second reader of the
17184
+ * dimensions and the class of mismatch that comes with it.
17185
+ */
17186
+ bbox: NativeCropBboxSchema,
17187
+ /** Parent class of the subject (`person`, `vehicle`, …) — carried into the result. */
17188
+ className: string(),
17189
+ /** Camera the pixels came from. Diagnostics + log tags ONLY — never routing. */
17190
+ sourceDeviceId: number()
17191
+ }), RunStatelessStepResultSchema, { kind: "mutation" });
16658
17192
  var CameraPipelineConfigSchema = object({
16659
17193
  engine: PipelineEngineChoiceSchema.optional(),
16660
17194
  steps: array(PipelineStepInputSchema).readonly(),
@@ -16952,6 +17486,20 @@ var CameraStatusSchema = object({
16952
17486
  detection: CameraDetectionStatusSchema.nullable(),
16953
17487
  audio: CameraAudioStatusSchema.nullable(),
16954
17488
  recording: CameraRecordingStatusSchema.nullable(),
17489
+ /**
17490
+ * Per-camera function switches an OPERATOR has turned off
17491
+ * ([D61](../../../../docs/decisions/adr-0067.md)).
17492
+ *
17493
+ * This is the difference between DISABLED and BROKEN. A camera whose
17494
+ * `detection` block reports zero fps and whose `switchedOff` contains
17495
+ * `'object-detection'` was switched off by a person; the same camera with an
17496
+ * empty list is failing. Every status surface must render the two
17497
+ * differently — a quiet camera that looks identical to a dead one is the
17498
+ * silence-reads-as-never-happened trap this repo keeps paying for.
17499
+ *
17500
+ * Empty when nothing is off. Never contains a switch no provider offers.
17501
+ */
17502
+ switchedOff: array(CameraSwitchIdSchema).readonly(),
16955
17503
  /** Unix timestamp (ms) when this snapshot was composed server-side. */
16956
17504
  fetchedAt: number()
16957
17505
  });
@@ -17351,6 +17899,43 @@ var pipelineOrchestratorCapability = {
17351
17899
  agentNodeId: string().optional()
17352
17900
  }), CameraPipelineConfigSchema),
17353
17901
  /**
17902
+ * The whole per-camera function switch group, DERIVED — never a stored
17903
+ * list ([D61](../../../../docs/decisions/adr-0067.md)).
17904
+ *
17905
+ * The group adds no state. Each switch is a view onto the authority that
17906
+ * already owned it (`deviceManager.setDisabled`,
17907
+ * `deviceManager.setWrapperActive`, `RecordingConfig.enabled`,
17908
+ * `notificationRules.setDeviceMuted`), and `switch.authority` says which.
17909
+ * Availability comes from `deviceManager.listBindableCapsForDeviceType`,
17910
+ * so a deployment with no audio analyzer renders no audio switch.
17911
+ *
17912
+ * `auth: 'view'` deliberately — a NON-admin must be able to see that a
17913
+ * camera is quiet because somebody switched it off. Only the mutation is
17914
+ * admin-gated.
17915
+ */
17916
+ getCameraSwitches: method(object({ deviceId: number() }), CameraSwitchGroupSchema),
17917
+ /**
17918
+ * Flip ONE switch, routed to its existing authority.
17919
+ *
17920
+ * Never writes a parallel map: `recording` patches `RecordingConfig.enabled`
17921
+ * and leaves `bands` byte-identical (clearing bands to express "off"
17922
+ * destroys the operator's authored schedule and turning the camera back on
17923
+ * would silently record nothing), and the two pipeline switches write the
17924
+ * SAME wrapper binding the legacy `pipelineEnabled` / `audioEnabled`
17925
+ * booleans were migrated onto.
17926
+ *
17927
+ * Rejects a switch this camera does not offer rather than persisting a
17928
+ * write nothing reads.
17929
+ */
17930
+ setCameraSwitch: method(object({
17931
+ deviceId: number(),
17932
+ switchId: CameraSwitchIdSchema,
17933
+ enabled: boolean()
17934
+ }), CameraSwitchGroupSchema, {
17935
+ kind: "mutation",
17936
+ auth: "admin"
17937
+ }),
17938
+ /**
17354
17939
  * Server-composed aggregated status for a single camera.
17355
17940
  *
17356
17941
  * Fans out in parallel (bounded, per-stage graceful degradation) to
@@ -26419,6 +27004,12 @@ Object.freeze({
26419
27004
  addonId: null,
26420
27005
  access: "view"
26421
27006
  },
27007
+ "notificationRules.listDeviceMutes": {
27008
+ capName: "notification-rules",
27009
+ capScope: "system",
27010
+ addonId: null,
27011
+ access: "view"
27012
+ },
26422
27013
  "notificationRules.listRules": {
26423
27014
  capName: "notification-rules",
26424
27015
  capScope: "system",
@@ -26437,6 +27028,12 @@ Object.freeze({
26437
27028
  addonId: null,
26438
27029
  access: "create"
26439
27030
  },
27031
+ "notificationRules.setDeviceMuted": {
27032
+ capName: "notification-rules",
27033
+ capScope: "system",
27034
+ addonId: null,
27035
+ access: "create"
27036
+ },
26440
27037
  "notificationRules.setRuleEnabled": {
26441
27038
  capName: "notification-rules",
26442
27039
  capScope: "system",
@@ -26713,6 +27310,12 @@ Object.freeze({
26713
27310
  addonId: null,
26714
27311
  access: "view"
26715
27312
  },
27313
+ "pipelineAnalytics.setTrackFlags": {
27314
+ capName: "pipeline-analytics",
27315
+ capScope: "device",
27316
+ addonId: null,
27317
+ access: "create"
27318
+ },
26716
27319
  "pipelineAnalytics.wipeAllAnalytics": {
26717
27320
  capName: "pipeline-analytics",
26718
27321
  capScope: "device",
@@ -27019,6 +27622,12 @@ Object.freeze({
27019
27622
  addonId: null,
27020
27623
  access: "view"
27021
27624
  },
27625
+ "pipelineOrchestrator.getCameraSwitches": {
27626
+ capName: "pipeline-orchestrator",
27627
+ capScope: "system",
27628
+ addonId: null,
27629
+ access: "view"
27630
+ },
27022
27631
  "pipelineOrchestrator.getCapabilityBindings": {
27023
27632
  capName: "pipeline-orchestrator",
27024
27633
  capScope: "system",
@@ -27151,6 +27760,12 @@ Object.freeze({
27151
27760
  addonId: null,
27152
27761
  access: "create"
27153
27762
  },
27763
+ "pipelineOrchestrator.setCameraSwitch": {
27764
+ capName: "pipeline-orchestrator",
27765
+ capScope: "system",
27766
+ addonId: null,
27767
+ access: "create"
27768
+ },
27154
27769
  "pipelineOrchestrator.setCapabilityBinding": {
27155
27770
  capName: "pipeline-orchestrator",
27156
27771
  capScope: "system",
@@ -27241,6 +27856,12 @@ Object.freeze({
27241
27856
  addonId: null,
27242
27857
  access: "create"
27243
27858
  },
27859
+ "pipelineRunner.runStatelessStep": {
27860
+ capName: "pipeline-runner",
27861
+ capScope: "system",
27862
+ addonId: null,
27863
+ access: "create"
27864
+ },
27244
27865
  "plateGallery.assignPlate": {
27245
27866
  capName: "plate-gallery",
27246
27867
  capScope: "system",
@@ -28057,6 +28678,12 @@ Object.freeze({
28057
28678
  addonId: null,
28058
28679
  access: "create"
28059
28680
  },
28681
+ "streamBroker.acquireEgressTranscode": {
28682
+ capName: "stream-broker",
28683
+ capScope: "system",
28684
+ addonId: null,
28685
+ access: "create"
28686
+ },
28060
28687
  "streamBroker.assignProfile": {
28061
28688
  capName: "stream-broker",
28062
28689
  capScope: "system",
@@ -28165,6 +28792,12 @@ Object.freeze({
28165
28792
  addonId: null,
28166
28793
  access: "create"
28167
28794
  },
28795
+ "streamBroker.releaseEgressTranscode": {
28796
+ capName: "stream-broker",
28797
+ capScope: "system",
28798
+ addonId: null,
28799
+ access: "create"
28800
+ },
28168
28801
  "streamBroker.releaseStreamWithCodec": {
28169
28802
  capName: "stream-broker",
28170
28803
  capScope: "system",
@@ -30970,6 +31603,7 @@ function composeCameraStatus(input) {
30970
31603
  detection: mapDetection(input.detectionResult),
30971
31604
  audio: mapAudio(input.audioResult),
30972
31605
  recording: mapRecording(input.recordingResult),
31606
+ switchedOff: input.switchedOff,
30973
31607
  fetchedAt: input.fetchedAt
30974
31608
  };
30975
31609
  }
@@ -31193,12 +31827,23 @@ var CameraStatusService = class {
31193
31827
  };
31194
31828
  }), STAGE_TIMEOUT_MS);
31195
31829
  }
31196
- /** Audio stage (from cached audio assignment). Orchestrator-local — no remote call needed. */
31197
- buildAudioStage(audioNodeId) {
31198
- return audioNodeId ? {
31830
+ /**
31831
+ * Audio stage. `nodeId` is orchestrator-local (the cached assignment);
31832
+ * `enabled` is the REAL `audio-analysis` switch.
31833
+ *
31834
+ * It used to be a hardcoded `true` whenever an audio node was assigned — so
31835
+ * a camera whose operator had turned audio off reported `audio.enabled:
31836
+ * true` and produced nothing, which is indistinguishable from broken. That
31837
+ * is precisely the failure the switch group exists to remove, and leaving
31838
+ * the lie in place would have made the group's own status block disagree
31839
+ * with it.
31840
+ */
31841
+ buildAudioStage(audioNodeId, switchedOff) {
31842
+ if (audioNodeId === null) return null;
31843
+ return {
31199
31844
  nodeId: audioNodeId,
31200
- enabled: true
31201
- } : null;
31845
+ enabled: !switchedOff.includes("audio-analysis")
31846
+ };
31202
31847
  }
31203
31848
  /**
31204
31849
  * Recording stage (recording cap getStatus). `recording.getStatus` is
@@ -31236,15 +31881,17 @@ var CameraStatusService = class {
31236
31881
  const decoderFetch = this.buildDecoderStage(detectionNodeId);
31237
31882
  const motionResult = this.buildMotionStage(deviceId);
31238
31883
  const detectionFetch = this.buildDetectionStage(api, detectionNodeId, deviceId);
31239
- const audioResult = this.buildAudioStage(audioNodeId);
31240
31884
  const recordingFetch = this.buildRecordingStage(api, deviceId);
31241
- const [sourceResult, brokerResult, decoderResult, detectionResult, recordingResult] = await Promise.all([
31885
+ const switchesFetch = this.boundedStage(this.deps.switchedOffIdsFor(deviceId).catch(() => null), STAGE_TIMEOUT_MS).then((ids) => ids ?? []);
31886
+ const [sourceResult, brokerResult, decoderResult, detectionResult, recordingResult, switchedOff] = await Promise.all([
31242
31887
  sourceFetch,
31243
31888
  brokerFetch,
31244
31889
  decoderFetch,
31245
31890
  detectionFetch,
31246
- recordingFetch
31891
+ recordingFetch,
31892
+ switchesFetch
31247
31893
  ]);
31894
+ const audioResult = this.buildAudioStage(audioNodeId, switchedOff);
31248
31895
  const liveDecoderNodeId = brokerResult !== null ? liveDecoder.nodeId : null;
31249
31896
  const decoderNodeId = liveDecoderNodeId ?? detectionNodeId;
31250
31897
  const reasons = {
@@ -31267,7 +31914,8 @@ var CameraStatusService = class {
31267
31914
  motionResult,
31268
31915
  detectionResult,
31269
31916
  audioResult,
31270
- recordingResult
31917
+ recordingResult,
31918
+ switchedOff
31271
31919
  });
31272
31920
  }
31273
31921
  /**
@@ -31288,6 +31936,195 @@ var CameraStatusService = class {
31288
31936
  }
31289
31937
  };
31290
31938
  //#endregion
31939
+ //#region src/camera-switch-service.ts
31940
+ function isDeviceShape(v) {
31941
+ if (v === null || typeof v !== "object") return false;
31942
+ const rec = { ...v };
31943
+ return typeof rec["type"] === "string" && typeof rec["disabled"] === "boolean";
31944
+ }
31945
+ /** Narrow `recording.getDeviceConfig` to the fields a switch may touch. */
31946
+ function isRecordingConfig(v) {
31947
+ if (v === null || typeof v !== "object") return false;
31948
+ const rec = { ...v };
31949
+ return typeof rec["enabled"] === "boolean" && Array.isArray(rec["bands"]);
31950
+ }
31951
+ var CameraSwitchService = class {
31952
+ deps;
31953
+ constructor(deps) {
31954
+ this.deps = deps;
31955
+ }
31956
+ /** The whole group for one camera, derived. */
31957
+ async getCameraSwitches(deviceId) {
31958
+ const { derivation } = await this.gather(deviceId);
31959
+ return {
31960
+ deviceId,
31961
+ switches: deriveCameraSwitches(derivation),
31962
+ fetchedAt: Date.now()
31963
+ };
31964
+ }
31965
+ /**
31966
+ * The ids an operator has switched off, for `CameraStatus.switchedOff`.
31967
+ *
31968
+ * This is what lets a status surface tell DISABLED from BROKEN: a camera
31969
+ * reporting nothing with `['object-detection']` here was turned off; the
31970
+ * same camera with an empty list is failing.
31971
+ */
31972
+ async switchedOffIdsFor(deviceId) {
31973
+ const { derivation } = await this.gather(deviceId);
31974
+ return switchedOffIds(deriveCameraSwitches(derivation));
31975
+ }
31976
+ /**
31977
+ * Flip one switch and return the RECOMPOSED group, so a client renders what
31978
+ * the server settled on rather than its own optimistic guess.
31979
+ */
31980
+ async setCameraSwitch(deviceId, switchId, enabled) {
31981
+ const api = this.deps.api();
31982
+ if (!api) throw new Error("camera switches unavailable — the hub api is not wired yet");
31983
+ const state = await this.gather(deviceId);
31984
+ const current = deriveCameraSwitches(state.derivation).find((s) => s.id === switchId);
31985
+ if (!current) throw new Error(`unknown camera switch '${switchId}'`);
31986
+ if (!current.available) {
31987
+ this.deps.logger.warn("camera switch write REFUSED — the switch is not available here", {
31988
+ tags: { deviceId },
31989
+ meta: {
31990
+ switchId,
31991
+ reason: current.unavailableReason
31992
+ }
31993
+ });
31994
+ throw new Error(`camera switch '${switchId}' is not available for device ${deviceId} (${current.unavailableReason})`);
31995
+ }
31996
+ await this.applyWrite(api, deviceId, current, state, enabled);
31997
+ this.deps.logger.info("camera switch changed", {
31998
+ tags: { deviceId },
31999
+ meta: {
32000
+ switchId,
32001
+ enabled,
32002
+ authority: current.authority.kind
32003
+ }
32004
+ });
32005
+ return this.getCameraSwitches(deviceId);
32006
+ }
32007
+ async applyWrite(api, deviceId, current, state, enabled) {
32008
+ const authority = current.authority;
32009
+ switch (authority.kind) {
32010
+ case "device-disabled":
32011
+ await api.deviceManager.setDisabled.mutate({
32012
+ deviceId,
32013
+ disabled: !enabled
32014
+ });
32015
+ return;
32016
+ case "wrapper-binding": {
32017
+ const wrapperAddonId = state.wrapperAddonIdByCap.get(authority.capName);
32018
+ if (wrapperAddonId === void 0) throw new Error(`no wrapper registered for '${authority.capName}' — nothing could apply this switch`);
32019
+ await api.deviceManager.setWrapperActive.mutate({
32020
+ deviceId,
32021
+ capName: authority.capName,
32022
+ wrapperAddonId,
32023
+ active: enabled
32024
+ });
32025
+ return;
32026
+ }
32027
+ case "recording-config": {
32028
+ const config = state.recordingConfig;
32029
+ if (config === null) throw new Error(`recording config unreadable for device ${deviceId} — refusing to write`);
32030
+ await api.recording.setDeviceConfig.mutate({
32031
+ deviceId,
32032
+ config: {
32033
+ ...config,
32034
+ enabled
32035
+ }
32036
+ });
32037
+ return;
32038
+ }
32039
+ case "notification-mute":
32040
+ await api.notificationRules.setDeviceMuted.mutate({
32041
+ deviceId,
32042
+ muted: !enabled
32043
+ });
32044
+ return;
32045
+ }
32046
+ }
32047
+ /**
32048
+ * One bounded parallel fan-out over the four authorities. Every branch
32049
+ * degrades to `null` INDEPENDENTLY — one unreachable addon removes its own
32050
+ * switch from the group and leaves the other four usable.
32051
+ */
32052
+ async gather(deviceId) {
32053
+ const api = this.deps.api();
32054
+ if (!api) return {
32055
+ derivation: {
32056
+ deviceId,
32057
+ deviceDisabled: false,
32058
+ bindableCapNames: null,
32059
+ activeWrapperCapNames: null,
32060
+ recordingEnabled: null,
32061
+ notificationsMuted: null
32062
+ },
32063
+ wrapperAddonIdByCap: /* @__PURE__ */ new Map(),
32064
+ recordingConfig: null
32065
+ };
32066
+ const devicePromise = api.deviceManager.getDevice.query({ deviceId }).then((d) => isDeviceShape(d) ? d : null).catch((err) => {
32067
+ this.warn(deviceId, "getDevice", err);
32068
+ return null;
32069
+ });
32070
+ const bindingsPromise = api.deviceManager.getBindings.query({ deviceId }).then((b) => b.entries.filter((e) => e.kind === "wrapped").map((e) => e.capName)).catch((err) => {
32071
+ this.warn(deviceId, "getBindings", err);
32072
+ return null;
32073
+ });
32074
+ const boundProviderPromise = api.deviceManager.getBindings.query({ deviceId }).then((b) => {
32075
+ const map = /* @__PURE__ */ new Map();
32076
+ for (const e of b.entries) if (e.kind === "wrapped" && e.providerAddonId !== "") map.set(e.capName, e.providerAddonId);
32077
+ return map;
32078
+ }).catch(() => /* @__PURE__ */ new Map());
32079
+ const recordingPromise = api.recording.getDeviceConfig.query({ deviceId }).then((c) => isRecordingConfig(c) ? c : null).catch((err) => {
32080
+ this.warn(deviceId, "recording.getDeviceConfig", err);
32081
+ return null;
32082
+ });
32083
+ const mutesPromise = api.notificationRules.listDeviceMutes.query({}).then((r) => r.mutedDeviceIds).catch((err) => {
32084
+ this.warn(deviceId, "notificationRules.listDeviceMutes", err);
32085
+ return null;
32086
+ });
32087
+ const [device, activeWrapperCapNames, boundProviders, recordingConfig, mutedDeviceIds] = await Promise.all([
32088
+ devicePromise,
32089
+ bindingsPromise,
32090
+ boundProviderPromise,
32091
+ recordingPromise,
32092
+ mutesPromise
32093
+ ]);
32094
+ const bindable = device === null ? null : await api.deviceManager.listBindableCapsForDeviceType.query({ deviceType: device.type }).catch((err) => {
32095
+ this.warn(deviceId, "listBindableCapsForDeviceType", err);
32096
+ return null;
32097
+ });
32098
+ const wrapperAddonIdByCap = /* @__PURE__ */ new Map();
32099
+ for (const entry of bindable ?? []) {
32100
+ const first = entry.wrappers[0];
32101
+ const chosen = boundProviders.get(entry.capName) ?? first;
32102
+ if (chosen !== void 0) wrapperAddonIdByCap.set(entry.capName, chosen);
32103
+ }
32104
+ return {
32105
+ derivation: {
32106
+ deviceId,
32107
+ deviceDisabled: device?.disabled ?? false,
32108
+ bindableCapNames: bindable === null ? null : bindable.filter((b) => b.wrappers.length > 0).map((b) => b.capName),
32109
+ activeWrapperCapNames,
32110
+ recordingEnabled: recordingConfig === null ? null : recordingConfig.enabled,
32111
+ notificationsMuted: mutedDeviceIds === null ? null : mutedDeviceIds.includes(deviceId)
32112
+ },
32113
+ wrapperAddonIdByCap,
32114
+ recordingConfig
32115
+ };
32116
+ }
32117
+ warn(deviceId, source, err) {
32118
+ this.deps.logger.warn("camera switch source unreachable — its switch is not offered", {
32119
+ tags: { deviceId },
32120
+ meta: {
32121
+ source,
32122
+ error: errMsg(err)
32123
+ }
32124
+ });
32125
+ }
32126
+ };
32127
+ //#endregion
31291
32128
  //#region src/apply-device-provisioning.ts
31292
32129
  /**
31293
32130
  * Merge base then override for one step. Override keys win; undefined = inherit.
@@ -36792,6 +37629,10 @@ async function buildOrchestratorControllers(deps) {
36792
37629
  redispatchAllActiveCameras: (reason) => placement.redispatchAllActiveCameras(reason),
36793
37630
  clearOnSettingsChange: () => loadShed.clearOnSettingsChange()
36794
37631
  });
37632
+ const cameraSwitchService = new CameraSwitchService({
37633
+ api: () => deps.ctx().api ?? null,
37634
+ logger: deps.ctx().logger
37635
+ });
36795
37636
  const cameraStatusService = new CameraStatusService({
36796
37637
  api: () => deps.ctx().api,
36797
37638
  getAssignment: (deviceId) => ledger.getAssignment(deviceId),
@@ -36800,7 +37641,8 @@ async function buildOrchestratorControllers(deps) {
36800
37641
  hasCameraConfig: (deviceId) => ledger.hasConfig(deviceId),
36801
37642
  getPendingReason: (deviceId) => ledger.getPendingReason(deviceId),
36802
37643
  assignSource: (deviceId) => topology.assignSource(deviceId),
36803
- listAssignedDeviceIds: () => ledger.listAssignedDeviceIds()
37644
+ listAssignedDeviceIds: () => ledger.listAssignedDeviceIds(),
37645
+ switchedOffIdsFor: (deviceId) => cameraSwitchService.switchedOffIdsFor(deviceId)
36804
37646
  });
36805
37647
  const reconcile = new ReconcileController({
36806
37648
  api: () => deps.ctx().api ?? null,
@@ -37044,6 +37886,7 @@ async function buildOrchestratorControllers(deps) {
37044
37886
  session,
37045
37887
  deviceConfig,
37046
37888
  cameraStatusService,
37889
+ cameraSwitchService,
37047
37890
  reconcile,
37048
37891
  nodeLifecycle,
37049
37892
  pipelineWatchdog,
@@ -37596,6 +38439,8 @@ var PipelineOrchestratorAddon = class PipelineOrchestratorAddon extends BaseAddo
37596
38439
  * the end of `onShutdown`.
37597
38440
  */
37598
38441
  cameraStatusService = null;
38442
+ /** The per-camera function switch group (D61) — see `camera-switch-service.ts`. */
38443
+ cameraSwitchService = null;
37599
38444
  /**
37600
38445
  * Device-details aggregator contributions (`getDeviceSettingsContribution`/
37601
38446
  * `getDeviceLiveContribution`/`applyDeviceSettingsPatch`/
@@ -37779,6 +38624,7 @@ var PipelineOrchestratorAddon = class PipelineOrchestratorAddon extends BaseAddo
37779
38624
  this.session = controllers.session;
37780
38625
  this.deviceConfig = controllers.deviceConfig;
37781
38626
  this.cameraStatusService = controllers.cameraStatusService;
38627
+ this.cameraSwitchService = controllers.cameraSwitchService;
37782
38628
  this.reconcile = controllers.reconcile;
37783
38629
  this.nodeLifecycle = controllers.nodeLifecycle;
37784
38630
  this.pipelineWatchdog = controllers.pipelineWatchdog;
@@ -37891,6 +38737,7 @@ var PipelineOrchestratorAddon = class PipelineOrchestratorAddon extends BaseAddo
37891
38737
  this.zonesProvider = null;
37892
38738
  this.zoneRulesProvider = null;
37893
38739
  this.cameraStatusService = null;
38740
+ this.cameraSwitchService = null;
37894
38741
  this.deviceConfig = null;
37895
38742
  this.settingsStore?.dispose();
37896
38743
  this.settingsStore = null;
@@ -38545,6 +39392,22 @@ var PipelineOrchestratorAddon = class PipelineOrchestratorAddon extends BaseAddo
38545
39392
  return this.settingsStore.deleteTemplate(input);
38546
39393
  }
38547
39394
  /**
39395
+ * The per-camera FUNCTION SWITCH group (D61) — derived, never stored.
39396
+ *
39397
+ * Read-only and view-authed: a non-admin must be able to SEE that a camera
39398
+ * is quiet because somebody switched it off.
39399
+ */
39400
+ async getCameraSwitches(input) {
39401
+ return this.cameraSwitchService.getCameraSwitches(input.deviceId);
39402
+ }
39403
+ /**
39404
+ * Flip one switch, routed to the authority that already owned the function.
39405
+ * Returns the recomposed group so a client never renders its own guess.
39406
+ */
39407
+ async setCameraSwitch(input) {
39408
+ return this.cameraSwitchService.setCameraSwitch(input.deviceId, input.switchId, input.enabled);
39409
+ }
39410
+ /**
38548
39411
  * Server-composed aggregated status for a single camera.
38549
39412
  *
38550
39413
  * Fans out in parallel (bounded, per-stage graceful degradation) to