@camstack/addon-pipeline-orchestrator 0.1.26 → 0.1.27

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.
Files changed (38) hide show
  1. package/dist/@mf-types/compiled-types/widgets/zone-editor/ZoneForm.d.ts +1 -1
  2. package/dist/@mf-types/compiled-types/widgets/zone-editor/ZoneForm.d.ts.map +1 -1
  3. package/dist/@mf-types/compiled-types/widgets/zone-editor/ZoneList.d.ts +1 -1
  4. package/dist/@mf-types/compiled-types/widgets/zone-editor/ZoneList.d.ts.map +1 -1
  5. package/dist/@mf-types/compiled-types/widgets/zone-editor/ZoneOverlay.d.ts.map +1 -1
  6. package/dist/@mf-types/compiled-types/widgets/zone-editor/types.d.ts +33 -0
  7. package/dist/@mf-types/compiled-types/widgets/zone-editor/types.d.ts.map +1 -0
  8. package/dist/@mf-types.zip +0 -0
  9. package/dist/{ReactKonva--rywLr1Y.mjs → ReactKonva-BpqYt5jc.mjs} +2 -2
  10. package/dist/__mfe_internal__addon_pipeline_orchestrator_widgets__loadShare___mf_0_camstack_mf_1_types__loadShare__.mjs-CP1zJ0aB.mjs +20 -0
  11. package/dist/__mfe_internal__addon_pipeline_orchestrator_widgets__loadShare___mf_0_camstack_mf_1_ui_mf_2_library__loadShare__.mjs-B1VWqPID.mjs +35 -0
  12. package/dist/{__mfe_internal__addon_pipeline_orchestrator_widgets__loadShare__react__loadShare__.mjs-0qpbQxoV.mjs → __mfe_internal__addon_pipeline_orchestrator_widgets__loadShare__react__loadShare__.mjs-ZXZUECVq.mjs} +5 -5
  13. package/dist/{__mfe_internal__addon_pipeline_orchestrator_widgets__loadShare__react__loadShare__.mjs_commonjs-proxy-DekuE8px.mjs → __mfe_internal__addon_pipeline_orchestrator_widgets__loadShare__react__loadShare__.mjs_commonjs-proxy-WhBt7NtJ.mjs} +1 -1
  14. package/dist/{__mfe_internal__addon_pipeline_orchestrator_widgets__loadShare__react_mf_2_dom__loadShare__.mjs_commonjs-proxy-Ba_7PYkj.mjs → __mfe_internal__addon_pipeline_orchestrator_widgets__loadShare__react_mf_2_dom__loadShare__.mjs_commonjs-proxy-DF7SvkCe.mjs} +1 -1
  15. package/dist/{__mfe_internal__addon_pipeline_orchestrator_widgets__loadShare__react_mf_2_konva__loadShare__.mjs-DSZIXeAx.mjs → __mfe_internal__addon_pipeline_orchestrator_widgets__loadShare__react_mf_2_konva__loadShare__.mjs-BjxkVuVo.mjs} +5 -4
  16. package/dist/_stub.js +445 -777
  17. package/dist/{_virtual_mf-localSharedImportMap___mfe_internal__addon_pipeline_orchestrator_widgets-iOQz8pwN.mjs → _virtual_mf-localSharedImportMap___mfe_internal__addon_pipeline_orchestrator_widgets-zq9nTFza.mjs} +6 -6
  18. package/dist/{client-CzjQ3uuI.mjs → client-BOhSywdX.mjs} +2 -2
  19. package/dist/{hostInit-HZ0iFEJA.mjs → hostInit-Cn3hiNRr.mjs} +13 -13
  20. package/dist/{index-DaulYonp.mjs → index-3tmcVweY.mjs} +1 -1
  21. package/dist/{index-C1DnrJuR.mjs → index-Bx39JFVr.mjs} +1 -1
  22. package/dist/{index-BmY66bNn.mjs → index-C_khSbT0.mjs} +2 -2
  23. package/dist/{index-DOuehnyb.mjs → index-D4m79gq7.mjs} +1 -1
  24. package/dist/{index-BuYTzV_S.mjs → index-D_QOQy3W.mjs} +7138 -5661
  25. package/dist/{index-CUXiTSWS.mjs → index-Dy2V7VOm.mjs} +3775 -3279
  26. package/dist/{index-Cbqs9uJn.mjs → index-kp_mtnZv.mjs} +1 -1
  27. package/dist/index.js +753 -64
  28. package/dist/index.js.map +1 -1
  29. package/dist/index.mjs +753 -64
  30. package/dist/index.mjs.map +1 -1
  31. package/dist/{jsx-runtime-DACJhJOv.mjs → jsx-runtime-ChcQDQxt.mjs} +1 -1
  32. package/dist/remoteEntry.js +1 -1
  33. package/dist/{schemas-ChN4Ih0h.mjs → schemas-ClCuS4qa.mjs} +151 -141
  34. package/package.json +4 -1
  35. package/dist/@mf-types/compiled-types/widgets/zone-editor/ZoneCanvas.d.ts +0 -60
  36. package/dist/@mf-types/compiled-types/widgets/zone-editor/ZoneCanvas.d.ts.map +0 -1
  37. package/dist/__mfe_internal__addon_pipeline_orchestrator_widgets__loadShare___mf_0_camstack_mf_1_types__loadShare__.mjs-C4HmLg0z.mjs +0 -20
  38. package/dist/__mfe_internal__addon_pipeline_orchestrator_widgets__loadShare___mf_0_camstack_mf_1_ui_mf_2_library__loadShare__.mjs-DEuqbomC.mjs +0 -34
package/dist/index.mjs CHANGED
@@ -4778,6 +4778,16 @@ function record(keyType, valueType, params) {
4778
4778
  ...normalizeParams(params)
4779
4779
  });
4780
4780
  }
4781
+ function partialRecord(keyType, valueType, params) {
4782
+ const k = clone(keyType);
4783
+ k._zod.values = void 0;
4784
+ return new ZodRecord({
4785
+ type: "record",
4786
+ keyType: k,
4787
+ valueType,
4788
+ ...normalizeParams(params)
4789
+ });
4790
+ }
4781
4791
  const ZodEnum = /* @__PURE__ */ $constructor("ZodEnum", (inst, def) => {
4782
4792
  $ZodEnum.init(inst, def);
4783
4793
  ZodType.init(inst, def);
@@ -5054,6 +5064,37 @@ function _instanceof(cls, params = {}) {
5054
5064
  };
5055
5065
  return inst;
5056
5066
  }
5067
+ const wiringProbeKindSchema = _enum(["singleton", "device", "widget"]);
5068
+ const wiringProbeResultSchema = object({
5069
+ capName: string(),
5070
+ kind: wiringProbeKindSchema,
5071
+ deviceId: number().optional(),
5072
+ reachable: boolean(),
5073
+ latencyMs: number(),
5074
+ error: string().optional()
5075
+ });
5076
+ const wiringAddonHealthSchema = object({
5077
+ addonId: string(),
5078
+ caps: array(wiringProbeResultSchema).readonly(),
5079
+ widgets: array(wiringProbeResultSchema).readonly()
5080
+ });
5081
+ const wiringNodeHealthSchema = object({
5082
+ nodeId: string(),
5083
+ addons: array(wiringAddonHealthSchema).readonly()
5084
+ });
5085
+ object({
5086
+ /** True only when every probed target is reachable. */
5087
+ ok: boolean(),
5088
+ /** True when at least one target is unreachable. */
5089
+ degraded: boolean(),
5090
+ checkedAt: string(),
5091
+ nodes: array(wiringNodeHealthSchema).readonly(),
5092
+ summary: object({
5093
+ total: number(),
5094
+ reachable: number(),
5095
+ unreachable: number()
5096
+ })
5097
+ });
5057
5098
  const MODEL_FORMATS = ["onnx", "coreml", "openvino", "tflite", "pt"];
5058
5099
  const WELL_KNOWN_TABS = [
5059
5100
  { id: "overview", label: "Overview", icon: "layout-dashboard", order: -10 },
@@ -6601,7 +6642,7 @@ const SpatialDetectionSchema = object({
6601
6642
  bbox: BoundingBoxSchema
6602
6643
  });
6603
6644
  const AudioChunkInputSchema = object({
6604
- data: _instanceof(Float32Array),
6645
+ data: _instanceof(Uint8Array),
6605
6646
  sampleRate: number(),
6606
6647
  channels: number(),
6607
6648
  timestamp: number(),
@@ -7276,7 +7317,23 @@ const RunnerCameraConfigSchema = object({
7276
7317
  * whenever its `zones` device-state slice changes, so the runner's
7277
7318
  * copy stays in sync. Empty array → no zone filtering.
7278
7319
  */
7279
- zones: array(ZoneSchema).readonly().default([])
7320
+ zones: array(ZoneSchema).readonly().default([]),
7321
+ /**
7322
+ * When true (default) and the camera's `motionSources` contains only
7323
+ * `'onboard'`, the runner dynamically opens the same WASM frame-diff
7324
+ * motion-frames subscription on each `MotionOnMotionChanged
7325
+ * source:'onboard'` event and tears it down after `motionCooldownMs`.
7326
+ * This causes `runMotionAnalysis` to emit `MotionZonesRaw` /
7327
+ * `MotionAnalysis` during active-motion windows without the
7328
+ * substream being held open continuously.
7329
+ *
7330
+ * Set to `false` to disable the dynamic analyzer for this camera
7331
+ * (e.g. very low-bandwidth links where the extra substream is
7332
+ * undesirable). Has no effect when `motionSources` already includes
7333
+ * `'analyzer'` — the analyzer runs continuously in that case and
7334
+ * this gate is bypassed.
7335
+ */
7336
+ onboardMotionDrivesAnalyzer: boolean().default(true)
7280
7337
  });
7281
7338
  const RunnerCameraDeviceUIFields = [
7282
7339
  {
@@ -7336,6 +7393,16 @@ const RunnerCameraDeviceUIFields = [
7336
7393
  showValue: true,
7337
7394
  unit: "s",
7338
7395
  displayScale: 1e3
7396
+ },
7397
+ // Only meaningful for onboard-only cameras — when `motionSources`
7398
+ // contains `'analyzer'` the analyzer runs continuously already.
7399
+ {
7400
+ key: "onboardMotionDrivesAnalyzer",
7401
+ type: "boolean",
7402
+ label: "Run motion analyzer on onboard motion",
7403
+ description: "When onboard motion is detected, temporarily start the frame-diff analyzer to emit zone results. Stops after the motion cooldown window.",
7404
+ default: true,
7405
+ showWhen: { field: "motionSources", includes: "onboard" }
7339
7406
  }
7340
7407
  ];
7341
7408
  const RunnerLocalLoadSchema = object({
@@ -7473,22 +7540,68 @@ MotionTriggerStatusSchema.extend({
7473
7540
  }) }
7474
7541
  }
7475
7542
  });
7543
+ const MaskPointSchema = object({
7544
+ x: number(),
7545
+ y: number()
7546
+ });
7547
+ const MaskRectShapeSchema = object({
7548
+ kind: literal("rect"),
7549
+ x: number(),
7550
+ y: number(),
7551
+ width: number(),
7552
+ height: number()
7553
+ });
7554
+ const MaskPolygonShapeSchema = object({
7555
+ kind: literal("polygon"),
7556
+ points: array(MaskPointSchema)
7557
+ });
7558
+ const MaskGridShapeSchema = object({
7559
+ kind: literal("grid"),
7560
+ gridWidth: number(),
7561
+ gridHeight: number(),
7562
+ cells: array(boolean())
7563
+ });
7564
+ const MaskLineShapeSchema = object({
7565
+ kind: literal("line"),
7566
+ points: array(MaskPointSchema)
7567
+ });
7568
+ discriminatedUnion("kind", [
7569
+ MaskRectShapeSchema,
7570
+ MaskPolygonShapeSchema,
7571
+ MaskGridShapeSchema,
7572
+ MaskLineShapeSchema
7573
+ ]);
7574
+ const MaskShapeKindSchema = _enum(["rect", "polygon", "grid", "line"]);
7575
+ const MaskPolygonVerticesSchema = object({
7576
+ min: number(),
7577
+ max: number()
7578
+ });
7579
+ const MaskGridDimsSchema = object({
7580
+ width: number(),
7581
+ height: number()
7582
+ });
7583
+ const MotionZoneRegionSchema = object({
7584
+ id: number(),
7585
+ enabled: boolean(),
7586
+ shape: MaskGridShapeSchema
7587
+ });
7476
7588
  object({
7477
7589
  enabled: boolean(),
7478
7590
  sensitivity: number(),
7479
- /** Row-major active-cell grid. Length = gridWidth*gridHeight (see getOptions). */
7480
- cells: array(boolean()),
7591
+ /** Grid region(s). Today exactly one `grid` shape. */
7592
+ regions: array(MotionZoneRegionSchema),
7481
7593
  lastFetchedAt: number()
7482
7594
  });
7483
7595
  const MotionZoneOptionsSchema = object({
7484
- gridWidth: number(),
7485
- gridHeight: number(),
7596
+ maxRegions: number(),
7597
+ supportedShapes: array(MaskShapeKindSchema),
7598
+ grid: MaskGridDimsSchema,
7486
7599
  sensitivity: object({ min: number(), max: number(), step: number() })
7487
7600
  });
7488
7601
  const MotionZonePatchSchema = object({
7489
7602
  enabled: boolean().optional(),
7490
7603
  sensitivity: number().optional(),
7491
- cells: array(boolean()).optional()
7604
+ regions: array(MotionZoneRegionSchema).optional()
7492
7605
  });
7493
7606
  ({
7494
7607
  deviceTypes: [DeviceType.Camera],
@@ -7501,6 +7614,102 @@ const MotionZonePatchSchema = object({
7501
7614
  )
7502
7615
  }
7503
7616
  });
7617
+ const NativeObjectClassEnum = _enum([
7618
+ "person",
7619
+ "vehicle",
7620
+ "animal",
7621
+ "face",
7622
+ "package",
7623
+ "other"
7624
+ ]);
7625
+ const NativeDetectionSchema = object({
7626
+ class: NativeObjectClassEnum,
7627
+ timestamp: number(),
7628
+ /** Firmware-provided confidence [0..1]. Reolink pushes don't carry it → undefined. */
7629
+ confidence: number().min(0).max(1).optional()
7630
+ });
7631
+ const NativeObjectDetectionStatusSchema = object({
7632
+ /**
7633
+ * Last observed instance per class. Missing entries mean the class
7634
+ * is supported but nothing has been seen since the provider started.
7635
+ *
7636
+ * MUST be a partial record: providers seed an empty `{}` on cold-start
7637
+ * and write one class at a time as detections arrive. In Zod 4
7638
+ * `z.record(enum, …)` is EXHAUSTIVE (requires every enum key), so a
7639
+ * partial write throws "expected object, received undefined" for every
7640
+ * unseen class. `z.partialRecord` keeps the enum-key narrowing while
7641
+ * allowing the sparse shape the providers actually write.
7642
+ */
7643
+ lastByClass: partialRecord(NativeObjectClassEnum, NativeDetectionSchema.nullable()),
7644
+ /** Classes the firmware is capable of detecting — enumerated at device register. */
7645
+ supportedClasses: array(NativeObjectClassEnum).readonly(),
7646
+ /**
7647
+ * Whether forwarding of onboard AI detections is enabled for this device.
7648
+ * Default true (on cold-start) — detections flow unconditionally before
7649
+ * the toggle is saved, so defaulting true preserves existing behaviour.
7650
+ */
7651
+ enabled: boolean()
7652
+ });
7653
+ NativeObjectDetectionStatusSchema.extend({
7654
+ /** Required by createRuntimeStateBridge — epoch ms of last refresh. */
7655
+ lastFetchedAt: number()
7656
+ });
7657
+ ({
7658
+ deviceTypes: [DeviceType.Camera],
7659
+ methods: {
7660
+ setEnabled: method(
7661
+ object({ deviceId: number(), enabled: boolean() }),
7662
+ _void(),
7663
+ { kind: "mutation", auth: "admin" }
7664
+ )
7665
+ },
7666
+ events: {
7667
+ onDetected: { data: object({
7668
+ deviceId: number(),
7669
+ detection: NativeDetectionSchema
7670
+ }) }
7671
+ }
7672
+ });
7673
+ const PrivacyMaskShapeSchema = discriminatedUnion("kind", [
7674
+ MaskRectShapeSchema,
7675
+ MaskPolygonShapeSchema
7676
+ ]);
7677
+ const PrivacyMaskRegionSchema = object({
7678
+ /** Slot id, 0-based. Stable across read/write. */
7679
+ id: number(),
7680
+ /** Whether this zone is active (blanked out by the camera). */
7681
+ enabled: boolean(),
7682
+ shape: PrivacyMaskShapeSchema
7683
+ });
7684
+ object({
7685
+ enabled: boolean(),
7686
+ /** Active zones (normalized 0..1). Length ≤ maxRegions. */
7687
+ regions: array(PrivacyMaskRegionSchema),
7688
+ lastFetchedAt: number()
7689
+ });
7690
+ const PrivacyMaskOptionsSchema = object({
7691
+ /** Maximum number of supported zones. */
7692
+ maxRegions: number(),
7693
+ /** Shape kinds this camera accepts — Reolink: ['rect']; Hikvision: ['rect','polygon']. */
7694
+ supportedShapes: array(MaskShapeKindSchema),
7695
+ /** Polygon vertex bounds when 'polygon' is supported (Hikvision: {min:4,max:4}). */
7696
+ polygonVertices: MaskPolygonVerticesSchema.optional()
7697
+ });
7698
+ const PrivacyMaskPatchSchema = object({
7699
+ enabled: boolean().optional(),
7700
+ regions: array(PrivacyMaskRegionSchema).optional()
7701
+ });
7702
+ ({
7703
+ deviceTypes: [DeviceType.Camera],
7704
+ methods: {
7705
+ getOptions: method(object({ deviceId: number() }), PrivacyMaskOptionsSchema),
7706
+ setMask: method(
7707
+ object({ deviceId: number(), patch: PrivacyMaskPatchSchema }),
7708
+ _void(),
7709
+ { kind: "mutation", auth: "admin" }
7710
+ )
7711
+ }
7712
+ });
7504
7713
  const AutotrackTargetTypeSchema = string().describe("Vendor target string (people/vehicle/pet); empty = camera default");
7505
7714
  const PtzAutotrackSettingsSchema = object({
7506
7715
  targetType: AutotrackTargetTypeSchema,
@@ -8022,7 +8231,8 @@ const SettingsUpdateResultSchema = object({
8022
8231
  object({
8023
8232
  addonId: string(),
8024
8233
  nodeId: string().optional(),
8025
- overlay: record(string(), unknown()).optional()
8234
+ overlay: record(string(), unknown()).optional(),
8235
+ cap: string().optional()
8026
8236
  }),
8027
8237
  SettingsSchemaWithValuesSchema.nullable()
8028
8238
  ),
@@ -8753,7 +8963,14 @@ const OauthIntegrationDescriptorSchema = object({
8753
8963
  /** Allowed redirect_uri prefixes. /api/oauth2/authorize rejects any
8754
8964
  * redirect_uri that does not start with one of these. Required —
8755
8965
  * an empty list means the integration can never complete linking. */
8756
- allowedRedirectPrefixes: array(string()).min(1)
8966
+ allowedRedirectPrefixes: array(string()).min(1),
8967
+ /** Optional public origin (no trailing slash) that this integration's
8968
+ * issued codes/tokens should carry as the `hubUrl` claim — typically the
8969
+ * operator-selected external-access endpoint resolved by the addon. When
8970
+ * present, /api/oauth2/authorize bakes THIS into the code instead of the
8971
+ * hub-global `publicHubUrl()`, so a forked exporter addon (which can't set
8972
+ * the hub's env) drives the claim that its cloud Lambda routes back on. */
8973
+ hubUrl: string().optional()
8757
8974
  });
8758
8975
  ({
8759
8976
  methods: {
@@ -9371,7 +9588,20 @@ const WebrtcStreamChoiceSchema = object({
9371
9588
  object({
9372
9589
  deviceId: number().int().nonnegative(),
9373
9590
  target: WebrtcStreamTargetSchema,
9374
- hints: webrtcClientHintsSchema.optional()
9591
+ hints: webrtcClientHintsSchema.optional(),
9592
+ /**
9593
+ * SERVER-INJECTED — NOT a client hint. The hub layer that holds
9594
+ * the tRPC request context (and therefore the client IP) sets
9595
+ * this to `true` when the viewer's source IP is non-LAN
9596
+ * (4G/CGNAT/internet). The broker then forces TURN-relay-only
9597
+ * ICE for the session so a CGNAT client (which can only offer a
9598
+ * relay candidate) gets a clean relay↔relay media path instead
9599
+ * of werift nominating a dead host/hairpin-srflx pair. LAN
9600
+ * clients leave this absent/false and keep the low-latency
9601
+ * direct (host/srflx) path. Clients MUST NOT send this — the
9602
+ * server overwrites it from the request context.
9603
+ */
9604
+ relayOnly: boolean().optional()
9375
9605
  }),
9376
9606
  object({ sessionId: string(), sdpOffer: string() }),
9377
9607
  { kind: "mutation" }
@@ -9394,7 +9624,22 @@ const WebrtcStreamChoiceSchema = object({
9394
9624
  deviceId: number().int().nonnegative(),
9395
9625
  target: WebrtcStreamTargetSchema.optional(),
9396
9626
  sdpOffer: string(),
9397
- sessionId: string().optional()
9627
+ sessionId: string().optional(),
9628
+ /**
9629
+ * Force TURN-relay-only ICE for this session. Two kinds of caller
9630
+ * set it:
9631
+ * - A cloud peer like Alexa's RTCSessionController (reachable
9632
+ * only via TURN, never our host/srflx behind NAT) passes
9633
+ * `true` from its own trusted addon context.
9634
+ * - The hub injects it for browser client-offer viewers from the
9635
+ * request's source IP (non-LAN ⇒ true), exactly as it does for
9636
+ * `createSession`.
9637
+ * A LAN/Tailscale browser doing client-offer passthrough leaves it
9638
+ * absent/false so a direct host pair carries full native quality.
9639
+ * Untrusted browser clients MUST NOT send it — the hub overwrites
9640
+ * it from the request context.
9641
+ */
9642
+ relayOnly: boolean().optional()
9398
9643
  }),
9399
9644
  object({ sessionId: string(), sdpAnswer: string() }),
9400
9645
  { kind: "mutation" }
@@ -9408,6 +9653,46 @@ const WebrtcStreamChoiceSchema = object({
9408
9653
  _void(),
9409
9654
  { kind: "mutation" }
9410
9655
  ),
9656
+ /**
9657
+ * Trickle ICE — add a remote (client) ICE candidate to a live session.
9658
+ * Lets the client send its SDP offer/answer IMMEDIATELY (before ICE
9659
+ * gathering finishes) and deliver candidates as they arrive, so the
9660
+ * connection establishes in ~0s instead of waiting for full gathering.
9661
+ * The dual of `getIceCandidates`. Mirrors Scrypted's signaling.
9662
+ */
9663
+ addIceCandidate: method(
9664
+ object({
9665
+ deviceId: number().int().nonnegative(),
9666
+ sessionId: string(),
9667
+ candidate: string(),
9668
+ sdpMid: string().nullable().optional(),
9669
+ sdpMLineIndex: number().int().nullable().optional()
9670
+ }),
9671
+ _void(),
9672
+ { kind: "mutation" }
9673
+ ),
9674
+ /**
9675
+ * Trickle ICE — poll the server's gathered ICE candidates for a session.
9676
+ * The server answers immediately (no gathering wait) and the client polls
9677
+ * this to receive host/srflx/relay candidates as werift gathers them,
9678
+ * adding each to its PeerConnection. Returns all candidates gathered so
9679
+ * far; the client dedupes. `done` flips true once gathering completes.
9680
+ */
9681
+ getIceCandidates: method(
9682
+ object({
9683
+ deviceId: number().int().nonnegative(),
9684
+ sessionId: string()
9685
+ }),
9686
+ object({
9687
+ candidates: array(object({
9688
+ candidate: string(),
9689
+ sdpMid: string().nullable(),
9690
+ sdpMLineIndex: number().int().nullable()
9691
+ })),
9692
+ done: boolean()
9693
+ }),
9694
+ { kind: "query" }
9695
+ ),
9411
9696
  closeSession: method(
9412
9697
  object({
9413
9698
  deviceId: number().int().nonnegative(),
@@ -11083,6 +11368,16 @@ const UpdateConfigInput = object({
11083
11368
  ({
11084
11369
  deviceTypes: [DeviceType.Camera]
11085
11370
  });
11371
+ const cameraPipelineConfigCapability = {
11372
+ name: "camera-pipeline-config",
11373
+ scope: "device",
11374
+ mode: "singleton",
11375
+ kind: "wrapper",
11376
+ defaultActive: true,
11377
+ deviceTypes: [DeviceType.Camera],
11378
+ exposesDeviceSettings: true,
11379
+ methods: {}
11380
+ };
11086
11381
  const TrackStateSchema = _enum(["new", "entered", "left", "moving", "idle"]);
11087
11382
  const EventKindSchema = _enum(["motion", "object", "audio"]);
11088
11383
  const TrackPositionSchema = object({
@@ -11910,36 +12205,28 @@ const IntercomStatusSchema = object({
11910
12205
  }) }
11911
12206
  }
11912
12207
  });
11913
- const NativeObjectClassEnum = _enum([
11914
- "person",
11915
- "vehicle",
11916
- "animal",
11917
- "face",
11918
- "package",
11919
- "other"
11920
- ]);
11921
- const NativeDetectionSchema = object({
11922
- class: NativeObjectClassEnum,
11923
- timestamp: number(),
11924
- /** Firmware-provided confidence [0..1]. Reolink pushes don't carry it → undefined. */
11925
- confidence: number().min(0).max(1).optional()
11926
- });
11927
- object({
11928
- /**
11929
- * Last observed instance per class. Undefined entries mean the class
11930
- * is supported but nothing has been seen since the provider started.
11931
- */
11932
- lastByClass: record(NativeObjectClassEnum, NativeDetectionSchema.nullable()),
11933
- /** Classes the firmware is capable of detecting — enumerated at device register. */
11934
- supportedClasses: array(NativeObjectClassEnum).readonly()
12208
+ const CamStreamDescriptorSchema = object({
12209
+ camStreamId: string().min(1),
12210
+ kind: CamStreamKindSchema,
12211
+ url: string().optional(),
12212
+ codec: string().optional(),
12213
+ resolution: CamStreamResolutionSchema.optional(),
12214
+ fps: number().positive().optional(),
12215
+ label: string().optional(),
12216
+ /** Device-level features (e.g. `battery-operated`) — drives broker policy. */
12217
+ deviceFeatures: array(string()).optional(),
12218
+ /** Eligible for automatic profile assignment. Absent = `true`. */
12219
+ autoEligible: boolean().optional(),
12220
+ /** Transport-specific opaque metadata (e.g. rfc4571 SDP). */
12221
+ metadata: record(string(), unknown()).optional()
11935
12222
  });
11936
12223
  ({
11937
12224
  deviceTypes: [DeviceType.Camera],
11938
- events: {
11939
- onDetected: { data: object({
11940
- deviceId: number(),
11941
- detection: NativeDetectionSchema
11942
- }) }
12225
+ methods: {
12226
+ getCatalog: method(
12227
+ object({ deviceId: number().int().nonnegative() }),
12228
+ array(CamStreamDescriptorSchema).readonly()
12229
+ )
11943
12230
  }
11944
12231
  });
11945
12232
  const ModelFormatSchema = _enum(MODEL_FORMATS);
@@ -13147,7 +13434,8 @@ const PackageUpdateSchema = object({
13147
13434
  currentVersion: string(),
13148
13435
  latestVersion: string(),
13149
13436
  category: _enum(["addon", "core"]),
13150
- requiresRestart: boolean()
13437
+ requiresRestart: boolean(),
13438
+ isSystem: boolean()
13151
13439
  });
13152
13440
  const PackageVersionInfoSchema = object({
13153
13441
  version: string(),
@@ -13180,6 +13468,42 @@ const UpdateFrameworkPackageResultSchema = object({
13180
13468
  /** Ms-epoch the server scheduled its self-restart. */
13181
13469
  restartingAt: number()
13182
13470
  });
13471
+ const BulkUpdateItemStatusSchema = _enum([
13472
+ "queued",
13473
+ "updating",
13474
+ "done",
13475
+ "done-pending-restart",
13476
+ "failed"
13477
+ ]);
13478
+ const BulkUpdateItemSchema = object({
13479
+ name: string(),
13480
+ isSystem: boolean(),
13481
+ fromVersion: string(),
13482
+ toVersion: string(),
13483
+ status: BulkUpdateItemStatusSchema,
13484
+ error: string().optional(),
13485
+ startedAtMs: number().optional(),
13486
+ completedAtMs: number().optional()
13487
+ });
13488
+ const BulkUpdatePhaseSchema = _enum([
13489
+ "regular",
13490
+ "system",
13491
+ "restarting",
13492
+ "finalizing"
13493
+ ]);
13494
+ const BulkUpdateStateSchema = object({
13495
+ id: string(),
13496
+ nodeId: string(),
13497
+ startedAtMs: number(),
13498
+ completedAtMs: number().optional(),
13499
+ total: number(),
13500
+ completed: number(),
13501
+ failed: number(),
13502
+ current: string().nullable(),
13503
+ phase: BulkUpdatePhaseSchema,
13504
+ cancelled: boolean(),
13505
+ items: array(BulkUpdateItemSchema).readonly()
13506
+ });
13183
13507
  const FrameworkPackageStatusSchema = object({
13184
13508
  packageName: string(),
13185
13509
  currentVersion: string(),
@@ -13317,7 +13641,7 @@ const CustomActionInputSchema = object({
13317
13641
  getLastRestart: method(
13318
13642
  _void(),
13319
13643
  object({
13320
- kind: _enum(["framework-update", "manual", "system"]),
13644
+ kind: _enum(["framework-update", "manual", "system", "framework-bulk-update"]),
13321
13645
  packageName: string().optional(),
13322
13646
  fromVersion: string().optional(),
13323
13647
  toVersion: string().optional(),
@@ -13407,11 +13731,70 @@ const CustomActionInputSchema = object({
13407
13731
  updateFrameworkPackage: method(
13408
13732
  object({
13409
13733
  packageName: string().min(1),
13410
- version: string().optional()
13734
+ version: string().optional(),
13735
+ deferRestart: boolean().optional()
13411
13736
  }),
13412
13737
  UpdateFrameworkPackageResultSchema,
13413
13738
  { kind: "mutation", auth: "admin" }
13414
13739
  ),
13740
+ /**
13741
+ * Kicks off a server-side bulk update operation and returns the bulk
13742
+ * id immediately. The operation runs asynchronously; observe progress
13743
+ * via the `AddonsBulkUpdateProgress` event or `getBulkUpdateState`.
13744
+ * Items with `isSystem: true` use `deferRestart` — the hub restarts
13745
+ * ONCE at the end of the system phase, after all system packages are
13746
+ * installed.
13747
+ *
13748
+ * `items[].version` is REQUIRED — callers must pass the resolved
13749
+ * version from `listUpdates`. There is no `'latest'` default here
13750
+ * (unlike `updatePackage`) to guarantee deterministic bulk rolls.
13751
+ */
13752
+ startBulkUpdate: method(
13753
+ object({
13754
+ nodeId: string(),
13755
+ items: array(object({
13756
+ name: string(),
13757
+ version: string(),
13758
+ isSystem: boolean()
13759
+ })).readonly()
13760
+ }),
13761
+ object({ id: string() }),
13762
+ { kind: "mutation", auth: "admin" }
13763
+ ),
13764
+ /**
13765
+ * Returns the current state of a bulk update by id.
13766
+ * Returns `null` if the id is unknown or has been auto-cleaned
13767
+ * (5 minutes after `completedAt` the record is evicted from memory).
13768
+ */
13769
+ getBulkUpdateState: method(
13770
+ object({ id: string() }),
13771
+ BulkUpdateStateSchema.nullable(),
13772
+ { auth: "admin" }
13773
+ ),
13774
+ /**
13775
+ * Cancels an in-flight bulk update. The update loop exits after the
13776
+ * currently-processing item completes — cancellation is not
13777
+ * instantaneous. Has no effect once the `restarting` phase has been
13778
+ * entered (the hub is already shutting down at that point).
13779
+ * Returns `{ cancelled: false }` if the id is unknown, the operation
13780
+ * has already completed, or the `restarting` phase is active.
13781
+ */
13782
+ cancelBulkUpdate: method(
13783
+ object({ id: string() }),
13784
+ object({ cancelled: boolean() }),
13785
+ { kind: "mutation", auth: "admin" }
13786
+ ),
13787
+ /**
13788
+ * Lists all currently active (non-completed) bulk updates.
13789
+ * If `nodeId` is provided, filters to only bulk updates targeting
13790
+ * that node. Useful for restoring an in-progress banner on a fresh
13791
+ * page load when the UI reconnects mid-operation.
13792
+ */
13793
+ listActiveBulkUpdates: method(
13794
+ object({ nodeId: string().optional() }),
13795
+ array(BulkUpdateStateSchema).readonly(),
13796
+ { auth: "admin" }
13797
+ ),
13415
13798
  getVersions: method(
13416
13799
  object({ name: string() }),
13417
13800
  array(PackageVersionInfoSchema).readonly()
@@ -13539,6 +13922,7 @@ var EventCategory = /* @__PURE__ */ ((EventCategory2) => {
13539
13922
  EventCategory2["StreamBrokerOnCamStreamDemand"] = "stream-broker.onCamStreamDemand";
13540
13923
  EventCategory2["StreamBrokerOnCamStreamIdle"] = "stream-broker.onCamStreamIdle";
13541
13924
  EventCategory2["StreamBrokerOnRequestStreamSourceRefresh"] = "stream-broker.onRequestStreamSourceRefresh";
13925
+ EventCategory2["StreamParamsChanged"] = "stream-params.changed";
13542
13926
  EventCategory2["DeviceStateChanged"] = "device.state-changed";
13543
13927
  EventCategory2["BatteryOnStatusChanged"] = "battery.onStatusChanged";
13544
13928
  EventCategory2["DoorbellOnPressed"] = "doorbell.onPressed";
@@ -13586,6 +13970,7 @@ var EventCategory = /* @__PURE__ */ ((EventCategory2) => {
13586
13970
  EventCategory2["NetworkTunnelStarted"] = "network.tunnel.started";
13587
13971
  EventCategory2["NetworkTunnelStopped"] = "network.tunnel.stopped";
13588
13972
  EventCategory2["LocalNetworkChanged"] = "network.local.changed";
13973
+ EventCategory2["MeshNetworkChanged"] = "network.mesh.changed";
13589
13974
  EventCategory2["BackupCompleted"] = "backup.completed";
13590
13975
  EventCategory2["BackupRestored"] = "backup.restored";
13591
13976
  EventCategory2["NotificationDispatched"] = "notification.dispatched";
@@ -13596,6 +13981,7 @@ var EventCategory = /* @__PURE__ */ ((EventCategory2) => {
13596
13981
  EventCategory2["DeviceAwake"] = "device.awake";
13597
13982
  EventCategory2["DeviceSleeping"] = "device.sleeping";
13598
13983
  EventCategory2["RetentionCleanup"] = "retention.cleanup";
13984
+ EventCategory2["AddonsBulkUpdateProgress"] = "addons.bulk-update-progress";
13599
13985
  return EventCategory2;
13600
13986
  })(EventCategory || {});
13601
13987
  function isEvent(event2, category) {
@@ -13748,7 +14134,7 @@ class BaseAddon {
13748
14134
  }
13749
14135
  // ── Settings schemas (override to provide UI) ─────────────────────────
13750
14136
  /** Override to provide global-level settings UI schema. */
13751
- globalSettingsSchema() {
14137
+ globalSettingsSchema(_cap) {
13752
14138
  return null;
13753
14139
  }
13754
14140
  /** Override to provide device-level settings UI schema. */
@@ -13762,8 +14148,8 @@ class BaseAddon {
13762
14148
  // blob and every addon used exactly one of them; the distinction was
13763
14149
  // never semantically load-bearing. `global` won because it was the
13764
14150
  // widely-used one and the name reads naturally (per-node addon config).
13765
- async getGlobalSettings(overlay) {
13766
- const schema = this.globalSettingsSchema();
14151
+ async getGlobalSettings(overlay, cap) {
14152
+ const schema = this.globalSettingsSchema(cap);
13767
14153
  if (!schema) return { sections: [] };
13768
14154
  const raw = await this._ctx?.settings?.readAddonStore() ?? {};
13769
14155
  return hydrateSchema(schema, overlay ? { ...raw, ...overlay } : raw);
@@ -14182,6 +14568,14 @@ class ReadinessRegistry {
14182
14568
  cleanup();
14183
14569
  reject(new ReadinessTimeoutError(capName, scope, this.now() - start));
14184
14570
  }, timeoutMs);
14571
+ const PENDING_LOG_INTERVAL_MS = 3e4;
14572
+ const pendingLogTimer = setInterval(() => {
14573
+ if (settled) return;
14574
+ this.logger?.warn(
14575
+ `readiness: still awaiting ${capName} (${scopeKey(scope)}) after ${this.now() - start}ms`
14576
+ );
14577
+ }, PENDING_LOG_INTERVAL_MS);
14578
+ if (typeof pendingLogTimer.unref === "function") pendingLogTimer.unref();
14185
14579
  const onAbort = () => {
14186
14580
  if (settled) return;
14187
14581
  settled = true;
@@ -14192,6 +14586,7 @@ class ReadinessRegistry {
14192
14586
  function cleanup() {
14193
14587
  unsubscribe();
14194
14588
  if (timer !== null) clearTimeout(timer);
14589
+ clearInterval(pendingLogTimer);
14195
14590
  opts.signal?.removeEventListener("abort", onAbort);
14196
14591
  }
14197
14592
  });
@@ -14745,6 +15140,8 @@ function balanceAudio(input) {
14745
15140
  }
14746
15141
  const POLL_INTERVAL_MS = 200;
14747
15142
  const PULL_MAX_COUNT = 8;
15143
+ const RESUBSCRIBE_AFTER_FAILURES = 2;
15144
+ const RESUBSCRIBE_THROTTLE_TICKS = 5;
14748
15145
  async function startAudioChunkPoller(options) {
14749
15146
  const { api, brokerId, tag, onChunk, logger } = options;
14750
15147
  let subscriptionId;
@@ -14762,6 +15159,19 @@ async function startAudioChunkPoller(options) {
14762
15159
  }
14763
15160
  let stopped = false;
14764
15161
  let timer;
15162
+ let consecutiveFailures = 0;
15163
+ const resubscribe = async () => {
15164
+ try {
15165
+ const result = await api.streamBroker.subscribeAudioChunks.mutate({ brokerId, tag });
15166
+ subscriptionId = result.subscriptionId;
15167
+ logger.info("audio-chunk poller: re-subscribed after broker outage", {
15168
+ meta: { brokerId, tag, subscriptionId, afterFailures: consecutiveFailures }
15169
+ });
15170
+ return true;
15171
+ } catch {
15172
+ return false;
15173
+ }
15174
+ };
14765
15175
  const tick = async () => {
14766
15176
  if (stopped) return;
14767
15177
  try {
@@ -14769,14 +15179,24 @@ async function startAudioChunkPoller(options) {
14769
15179
  subscriptionId,
14770
15180
  maxCount: PULL_MAX_COUNT
14771
15181
  });
15182
+ if (consecutiveFailures > 0) {
15183
+ logger.info("audio-chunk poller: stream recovered", { meta: { brokerId, subscriptionId } });
15184
+ }
15185
+ consecutiveFailures = 0;
14772
15186
  for (const chunk of chunks) {
14773
15187
  if (stopped) break;
14774
15188
  await onChunk(chunk);
14775
15189
  }
14776
15190
  } catch (err) {
14777
- logger.warn("audio-chunk poller: pullAudioChunks failed", {
14778
- meta: { brokerId, subscriptionId, error: errMsg(err) }
14779
- });
15191
+ consecutiveFailures += 1;
15192
+ if (consecutiveFailures === 1) {
15193
+ logger.warn("audio-chunk poller: pullAudioChunks failed — attempting recovery", {
15194
+ meta: { brokerId, subscriptionId, error: errMsg(err) }
15195
+ });
15196
+ }
15197
+ if (!stopped && consecutiveFailures >= RESUBSCRIBE_AFTER_FAILURES && (consecutiveFailures - RESUBSCRIBE_AFTER_FAILURES) % RESUBSCRIBE_THROTTLE_TICKS === 0) {
15198
+ await resubscribe();
15199
+ }
14780
15200
  }
14781
15201
  if (!stopped) {
14782
15202
  timer = setTimeout(() => void tick(), POLL_INTERVAL_MS);
@@ -14797,6 +15217,18 @@ async function startAudioChunkPoller(options) {
14797
15217
  });
14798
15218
  };
14799
15219
  }
15220
+ function desiredDeviceIdsFromSlots(slots) {
15221
+ const result = /* @__PURE__ */ new Set();
15222
+ for (const slot of slots) {
15223
+ if (slot.status !== "unassigned" && slot.sourceCamStreamId !== null) {
15224
+ result.add(slot.deviceId);
15225
+ }
15226
+ }
15227
+ return result;
15228
+ }
15229
+ function computeDispatchGap(desired, known) {
15230
+ return [...desired].filter((id) => !known.has(id));
15231
+ }
14800
15232
  const PHASE_MODE_VALUES = /* @__PURE__ */ new Set([
14801
15233
  "disabled",
14802
15234
  "always-on",
@@ -14808,6 +15240,7 @@ function isPipelinePhaseMode(v) {
14808
15240
  const PREFERRED_AGENT_SETTING = "preferredAgent";
14809
15241
  const AUDIO_NODE_SETTING = "audioNodeId";
14810
15242
  const DEFAULT_BROKER_CALL_TIMEOUT_MS = 5e3;
15243
+ const RECONCILE_DEBOUNCE_MS = 200;
14811
15244
  const AGENT_SETTINGS_KEY = "agentSettings";
14812
15245
  const CAMERA_SETTINGS_KEY = "cameraSettings";
14813
15246
  const TEMPLATES_KEY = "templates";
@@ -14959,6 +15392,12 @@ class PipelineOrchestratorAddon extends BaseAddon {
14959
15392
  loadShedState = /* @__PURE__ */ new Map();
14960
15393
  /** Timer for the auto-resume sweep. */
14961
15394
  loadShedResumeTimer = null;
15395
+ /** Pending `scheduleReconcile` debounce timer. */
15396
+ reconcileTimer = null;
15397
+ /** True while `reconcileDispatch` is awaiting the RPC round-trip. */
15398
+ reconcileInFlight = false;
15399
+ /** Set to true when a reconcile is requested while one is already in-flight; triggers a follow-up pass. */
15400
+ reconcileRerunRequested = false;
14962
15401
  initTimestamp = 0;
14963
15402
  constructor() {
14964
15403
  super({});
@@ -15017,6 +15456,7 @@ class PipelineOrchestratorAddon extends BaseAddon {
15017
15456
  meta: { error: msg }
15018
15457
  });
15019
15458
  });
15459
+ this.scheduleReconcile();
15020
15460
  }
15021
15461
  );
15022
15462
  this.watchCapability(["pipeline-executor", "analysis-pipeline"], {
@@ -15114,6 +15554,7 @@ class PipelineOrchestratorAddon extends BaseAddon {
15114
15554
  });
15115
15555
  }, PROFILE_SLOTS_DEBOUNCE_MS)
15116
15556
  );
15557
+ this.scheduleReconcile();
15117
15558
  }
15118
15559
  );
15119
15560
  this.unsubDeviceUnregistered = this.ctx.eventBus.subscribe(
@@ -15293,9 +15734,15 @@ class PipelineOrchestratorAddon extends BaseAddon {
15293
15734
  }
15294
15735
  ]
15295
15736
  };
15737
+ this.scheduleReconcile();
15296
15738
  return {
15297
15739
  providers: [
15298
15740
  { capability: pipelineOrchestratorCapability, provider: this },
15741
+ // D12 re-home: the per-device settings contribution (motion/pipeline
15742
+ // tabs + zones top-tab) rides this device-scoped defaultActive wrapper
15743
+ // so it auto-binds to every camera and the binding-driven aggregate
15744
+ // invokes it. `this` already implements the three contribution methods.
15745
+ { capability: cameraPipelineConfigCapability, provider: this },
15299
15746
  { capability: zonesCapability, provider: this.zonesProvider },
15300
15747
  { capability: zoneRulesCapability, provider: this.zoneRulesProvider },
15301
15748
  { capability: addonWidgetsSourceCapability, provider: widgetsProvider }
@@ -15420,6 +15867,10 @@ class PipelineOrchestratorAddon extends BaseAddon {
15420
15867
  this.profileSlotTimers.clear();
15421
15868
  this.profileSlotTimers = null;
15422
15869
  }
15870
+ if (this.reconcileTimer !== null) {
15871
+ clearTimeout(this.reconcileTimer);
15872
+ this.reconcileTimer = null;
15873
+ }
15423
15874
  this.unsubDeviceRegistered?.();
15424
15875
  this.unsubDeviceRegistered = null;
15425
15876
  this.unsubDeviceUnregistered?.();
@@ -15495,7 +15946,7 @@ class PipelineOrchestratorAddon extends BaseAddon {
15495
15946
  );
15496
15947
  this.cameraConfigs.set(runnerConfig.deviceId, runnerConfig);
15497
15948
  const cfg = this.globalSettings ?? {};
15498
- const minThreshold = typeof cfg.fpsMinThreshold === "number" ? cfg.fpsMinThreshold : 3;
15949
+ const minThreshold = typeof cfg.fpsMinThreshold === "number" ? cfg.fpsMinThreshold : PipelineOrchestratorAddon.DEFAULT_FPS_MIN_THRESHOLD;
15499
15950
  const windowMs = typeof cfg.fpsLowWindowMs === "number" && cfg.fpsLowWindowMs > 0 ? cfg.fpsLowWindowMs : PipelineOrchestratorAddon.DEFAULT_FPS_LOW_WINDOW_MS;
15500
15951
  if (minThreshold > 0 && this.initTimestamp && Date.now() - this.initTimestamp > 3e4) {
15501
15952
  const now = Date.now();
@@ -15938,7 +16389,7 @@ class PipelineOrchestratorAddon extends BaseAddon {
15938
16389
  const api = ctx?.api;
15939
16390
  if (!api) continue;
15940
16391
  try {
15941
- const load = await api.pipelineRunner.getLocalLoad.query({ nodeId });
16392
+ const load = await this.queryLocalLoadBounded(nodeId);
15942
16393
  loads.push(load);
15943
16394
  } catch (err) {
15944
16395
  const msg = errMsg(err);
@@ -15963,6 +16414,53 @@ class PipelineOrchestratorAddon extends BaseAddon {
15963
16414
  }
15964
16415
  this.cachedAgentLoad = next;
15965
16416
  }
16417
+ /**
16418
+ * Per-node `getLocalLoad` budget (ms). A WEDGED runner — transport up
16419
+ * enough to stay in the service registry, but whose `broker.call` neither
16420
+ * resolves nor rejects (observed live as
16421
+ * "[brokerTransportLink] still awaiting … after 18min") — would otherwise
16422
+ * stall `collectAgentLoad`, and through it `dispatchCamera` →
16423
+ * `startDetection` for EVERY camera, indefinitely: the transport link does
16424
+ * not abort an in-flight `broker.call` (see trpc-links.ts), so neither the
16425
+ * try/catch nor an AbortSignal can unstick it. Tunable via the
16426
+ * `agentLoadTimeoutMs` global setting; the default is generous enough that
16427
+ * a healthy-but-slow node is never falsely skipped (the pinned-discovery
16428
+ * window alone is 2s).
16429
+ */
16430
+ static DEFAULT_AGENT_LOAD_TIMEOUT_MS = 4e3;
16431
+ agentLoadBudgetMs() {
16432
+ const cfg = this.globalSettings ?? {};
16433
+ const value = cfg.agentLoadTimeoutMs;
16434
+ return typeof value === "number" && value > 0 ? value : PipelineOrchestratorAddon.DEFAULT_AGENT_LOAD_TIMEOUT_MS;
16435
+ }
16436
+ /**
16437
+ * `getLocalLoad` for one runner node, bounded by `agentLoadBudgetMs`. On
16438
+ * timeout it rejects so `collectAgentLoad`'s catch treats the node exactly
16439
+ * like an offline one (logged at debug, skipped). The underlying query is
16440
+ * left to settle on its own — Moleculer rejects it when the node
16441
+ * disconnects — rather than leaking an unbounded await into the dispatch
16442
+ * path.
16443
+ */
16444
+ async queryLocalLoadBounded(nodeId) {
16445
+ const api = this.ctx?.api;
16446
+ if (!api) throw new Error("queryLocalLoadBounded: addon not initialized");
16447
+ const budgetMs = this.agentLoadBudgetMs();
16448
+ let timer;
16449
+ const timeout = new Promise((_resolve, reject) => {
16450
+ timer = setTimeout(
16451
+ () => reject(new Error(`getLocalLoad exceeded ${budgetMs}ms budget for ${nodeId}`)),
16452
+ budgetMs
16453
+ );
16454
+ });
16455
+ try {
16456
+ return await Promise.race([
16457
+ api.pipelineRunner.getLocalLoad.query({ nodeId }),
16458
+ timeout
16459
+ ]);
16460
+ } finally {
16461
+ if (timer) clearTimeout(timer);
16462
+ }
16463
+ }
15966
16464
  async readPreferredAgent(deviceId) {
15967
16465
  const ctx = this.ctx;
15968
16466
  if (!ctx?.settings) return null;
@@ -16260,6 +16758,44 @@ class PipelineOrchestratorAddon extends BaseAddon {
16260
16758
  "audio-analyzer ready — node now available for audio dispatch",
16261
16759
  { tags: { nodeId }, meta: { epoch: t.epoch } }
16262
16760
  );
16761
+ await this.reattachAudioForNode(nodeId);
16762
+ this.scheduleReconcile();
16763
+ }
16764
+ /**
16765
+ * Re-establish audio subscriptions for every camera whose audio is routed
16766
+ * to `nodeId`, after that node's audio-analyzer (re)started. Mirrors the
16767
+ * audio block in `startDetection` (drop prior sub, re-subscribe, keep only
16768
+ * if detection is active). Idempotent.
16769
+ */
16770
+ async reattachAudioForNode(nodeId) {
16771
+ for (const [deviceId, audioNode] of this.audioNodeByDevice) {
16772
+ if (audioNode !== nodeId) continue;
16773
+ const config2 = this.cameraConfigs.get(deviceId);
16774
+ if (!config2) continue;
16775
+ const prior = this.audioSubscriptions.get(deviceId);
16776
+ if (prior) {
16777
+ try {
16778
+ prior();
16779
+ } catch {
16780
+ }
16781
+ this.audioSubscriptions.delete(deviceId);
16782
+ }
16783
+ try {
16784
+ const unsub = await this.subscribeAudioStream(deviceId, config2);
16785
+ if (unsub) {
16786
+ if (this.activeDetections.has(deviceId)) {
16787
+ this.audioSubscriptions.set(deviceId, unsub);
16788
+ } else {
16789
+ unsub();
16790
+ }
16791
+ }
16792
+ } catch (err) {
16793
+ this.ctx.logger.error("audio re-attach on analyzer readiness failed", {
16794
+ tags: { deviceId, nodeId },
16795
+ meta: { error: errMsg(err) }
16796
+ });
16797
+ }
16798
+ }
16263
16799
  }
16264
16800
  /**
16265
16801
  * Act on a `detection-pipeline` transition: on `ready` with a new
@@ -16331,6 +16867,7 @@ class PipelineOrchestratorAddon extends BaseAddon {
16331
16867
  });
16332
16868
  });
16333
16869
  }, 3e3);
16870
+ this.scheduleReconcile();
16334
16871
  } catch (err) {
16335
16872
  this.ctx.logger.debug("readiness seed+redispatch failed", {
16336
16873
  tags: { nodeId },
@@ -16375,6 +16912,75 @@ class PipelineOrchestratorAddon extends BaseAddon {
16375
16912
  }
16376
16913
  }
16377
16914
  }
16915
+ // ── Dispatch reconcile ───────────────────────────────────────────────
16916
+ /**
16917
+ * Coalesce bursts of topology/slot-change signals into a single
16918
+ * `reconcileDispatch` pass. Mirrors `scheduleRehydrate` in
16919
+ * `packages/kernel/src/moleculer/readiness-context.ts`.
16920
+ */
16921
+ scheduleReconcile() {
16922
+ if (this.reconcileTimer !== null) clearTimeout(this.reconcileTimer);
16923
+ this.reconcileTimer = setTimeout(() => {
16924
+ this.reconcileTimer = null;
16925
+ void this.reconcileDispatch();
16926
+ }, RECONCILE_DEBOUNCE_MS);
16927
+ }
16928
+ /**
16929
+ * RPC snapshot-reconcile: pull the current broker fleet over
16930
+ * `listAllProfileSlots`, diff against `cameraConfigs`, and dispatch
16931
+ * the gap via `handleDeviceRegistered`.
16932
+ *
16933
+ * Additive only — never retracts or re-dispatches a known device.
16934
+ * Guards against re-entrant calls: a trailing request schedules one
16935
+ * extra pass after the in-flight one completes.
16936
+ */
16937
+ async reconcileDispatch() {
16938
+ if (this.reconcileInFlight) {
16939
+ this.reconcileRerunRequested = true;
16940
+ return;
16941
+ }
16942
+ this.reconcileInFlight = true;
16943
+ try {
16944
+ const api = this.api;
16945
+ if (!api) {
16946
+ this.ctx.logger.debug(
16947
+ "dispatch reconcile skipped — ctx.api not yet available"
16948
+ );
16949
+ return;
16950
+ }
16951
+ let slots;
16952
+ try {
16953
+ slots = await api.streamBroker.listAllProfileSlots.query();
16954
+ } catch (err) {
16955
+ this.ctx.logger.debug("dispatch reconcile skipped — stream-broker not ready", {
16956
+ meta: { error: errMsg(err) }
16957
+ });
16958
+ return;
16959
+ }
16960
+ const desired = desiredDeviceIdsFromSlots(slots);
16961
+ const known = new Set(this.cameraConfigs.keys());
16962
+ const gap = computeDispatchGap(desired, known);
16963
+ this.ctx.logger.info("dispatch reconcile", {
16964
+ meta: { desired: desired.size, known: known.size, gap }
16965
+ });
16966
+ for (const deviceId of gap) {
16967
+ try {
16968
+ await this.handleDeviceRegistered(deviceId);
16969
+ } catch (err) {
16970
+ this.ctx.logger.warn("dispatch reconcile: handleDeviceRegistered failed", {
16971
+ tags: { deviceId },
16972
+ meta: { error: errMsg(err) }
16973
+ });
16974
+ }
16975
+ }
16976
+ } finally {
16977
+ this.reconcileInFlight = false;
16978
+ if (this.reconcileRerunRequested) {
16979
+ this.reconcileRerunRequested = false;
16980
+ this.scheduleReconcile();
16981
+ }
16982
+ }
16983
+ }
16378
16984
  // ── Capability bindings (cap methods) ────────────────────────────────
16379
16985
  async getCapabilityBindings(input) {
16380
16986
  const ctx = this.ctx;
@@ -17156,11 +17762,11 @@ class PipelineOrchestratorAddon extends BaseAddon {
17156
17762
  key: "fpsMinThreshold",
17157
17763
  type: "slider",
17158
17764
  label: "Min FPS Threshold",
17159
- description: "If a camera sustains FPS below this value for the duration set by 'Low FPS Window' below, it is paused (load-shed) and new dispatches are blocked. Auto-resumes with exponential backoff. 0 = disabled.",
17765
+ description: "Per-camera low-FPS load shedding. If a camera sustains FPS below this value for the duration set by 'Low FPS Window' below, it is paused (load-shed) and new dispatches are blocked. Auto-resumes with exponential backoff. 0 = disabled (default) — a single slow camera is never detached; only capacity-based placement applies.",
17160
17766
  min: 0,
17161
17767
  max: 10,
17162
17768
  step: 1,
17163
- default: 3,
17769
+ default: PipelineOrchestratorAddon.DEFAULT_FPS_MIN_THRESHOLD,
17164
17770
  showValue: true,
17165
17771
  unit: "fps"
17166
17772
  },
@@ -17457,8 +18063,41 @@ class PipelineOrchestratorAddon extends BaseAddon {
17457
18063
  }
17458
18064
  ]
17459
18065
  };
18066
+ const hasNativeObjectDetection = await this.deviceHasNativeObjectDetectionCap(input.deviceId);
18067
+ const nativeObjectDetectionSections = [];
18068
+ if (hasNativeObjectDetection) {
18069
+ let nativeObjectDetectionEnabled = true;
18070
+ try {
18071
+ const status = await this.ctx.api?.nativeObjectDetection.getStatus.query({
18072
+ deviceId: input.deviceId
18073
+ });
18074
+ if (status !== void 0 && status !== null && "enabled" in status) {
18075
+ nativeObjectDetectionEnabled = Boolean(
18076
+ status.enabled
18077
+ );
18078
+ }
18079
+ } catch {
18080
+ }
18081
+ nativeObjectDetectionSections.push({
18082
+ id: "onboard-object-detection",
18083
+ title: "Onboard Object Detection",
18084
+ tab: "motion",
18085
+ order: 10,
18086
+ fields: [
18087
+ {
18088
+ type: "boolean",
18089
+ key: "nativeObjectDetectionEnabled",
18090
+ label: "Use onboard object detection",
18091
+ description: "When enabled, AI detections from the camera firmware are forwarded to the system notification and recording pipeline. Onboard motion events are never suppressed.",
18092
+ default: true,
18093
+ immediate: true,
18094
+ value: nativeObjectDetectionEnabled
18095
+ }
18096
+ ]
18097
+ });
18098
+ }
17460
18099
  return {
17461
- sections: [...baseSections, zonesSection]
18100
+ sections: [...baseSections, zonesSection, ...nativeObjectDetectionSections]
17462
18101
  };
17463
18102
  }
17464
18103
  // `getCameraPipelineWithFallback` was a thin wrapper over
@@ -17519,11 +18158,25 @@ class PipelineOrchestratorAddon extends BaseAddon {
17519
18158
  }
17520
18159
  async applyDeviceSettingsPatch(input) {
17521
18160
  const { pipelinePatch, rest } = this.splitPipelinePatch(input.patch);
18161
+ const { nativeObjectDetectionEnabled, ...orchestratorRest } = rest;
18162
+ if (nativeObjectDetectionEnabled !== void 0) {
18163
+ try {
18164
+ await this.ctx.api?.nativeObjectDetection.setEnabled.mutate({
18165
+ deviceId: input.deviceId,
18166
+ enabled: Boolean(nativeObjectDetectionEnabled)
18167
+ });
18168
+ } catch (err) {
18169
+ this.ctx.logger.warn("Failed to route nativeObjectDetectionEnabled to cap", {
18170
+ tags: { deviceId: input.deviceId },
18171
+ meta: { error: err instanceof Error ? err.message : String(err) }
18172
+ });
18173
+ }
18174
+ }
17522
18175
  if (Object.keys(pipelinePatch).length > 0) {
17523
18176
  await this.applyPipelinePatch(input.deviceId, pipelinePatch);
17524
18177
  }
17525
- if (Object.keys(rest).length > 0) {
17526
- await this.writeDeviceOrchestratorSettings(input.deviceId, rest);
18178
+ if (Object.keys(orchestratorRest).length > 0) {
18179
+ await this.writeDeviceOrchestratorSettings(input.deviceId, orchestratorRest);
17527
18180
  }
17528
18181
  return { success: true };
17529
18182
  }
@@ -17698,6 +18351,23 @@ class PipelineOrchestratorAddon extends BaseAddon {
17698
18351
  return false;
17699
18352
  }
17700
18353
  }
18354
+ /**
18355
+ * Returns true when the camera has a native `native-object-detection`
18356
+ * binding (i.e. the driver registered the cap). Used to gate the
18357
+ * "Use onboard object detection" toggle in `getDeviceSettingsContribution`.
18358
+ */
18359
+ async deviceHasNativeObjectDetectionCap(deviceId) {
18360
+ const api = this.api;
18361
+ if (!api) return false;
18362
+ try {
18363
+ const bindings = await api.deviceManager.getBindings.query({ deviceId });
18364
+ return bindings.entries.some(
18365
+ (e) => e.kind === "native" && e.capName === "native-object-detection"
18366
+ );
18367
+ } catch {
18368
+ return false;
18369
+ }
18370
+ }
17701
18371
  async resolveDeviceDetectionSettings(deviceId) {
17702
18372
  const ctx = this.ctx;
17703
18373
  if (!ctx?.settings) {
@@ -17762,6 +18432,8 @@ class PipelineOrchestratorAddon extends BaseAddon {
17762
18432
  const detectionMode = typeof userDetectionMode === "string" && isPipelinePhaseMode(userDetectionMode) ? userDetectionMode : profile?.defaults.detectionMode ?? "on-motion";
17763
18433
  const userAudioMode = raw["audioMode"];
17764
18434
  const audioMode = typeof userAudioMode === "string" && isPipelinePhaseMode(userAudioMode) ? userAudioMode : profile?.defaults.audioMode ?? "always-on";
18435
+ const userOnboardMotionDrivesAnalyzer = raw["onboardMotionDrivesAnalyzer"];
18436
+ const onboardMotionDrivesAnalyzer = typeof userOnboardMotionDrivesAnalyzer === "boolean" ? userOnboardMotionDrivesAnalyzer : true;
17765
18437
  return {
17766
18438
  motionDetectionEnabled,
17767
18439
  pipelineEnabled,
@@ -17772,7 +18444,8 @@ class PipelineOrchestratorAddon extends BaseAddon {
17772
18444
  detectionFps: mustNumber("detectionFps"),
17773
18445
  motionCooldownMs: mustNumber("motionCooldownMs"),
17774
18446
  detectionMode,
17775
- audioMode
18447
+ audioMode,
18448
+ onboardMotionDrivesAnalyzer
17776
18449
  };
17777
18450
  } catch (err) {
17778
18451
  const msg = errMsg(err);
@@ -17880,7 +18553,8 @@ class PipelineOrchestratorAddon extends BaseAddon {
17880
18553
  motionCooldownMs: resolved.motionCooldownMs,
17881
18554
  audioStreamId: void 0,
17882
18555
  detectionMode: resolved.detectionMode,
17883
- audioMode: resolved.audioMode
18556
+ audioMode: resolved.audioMode,
18557
+ onboardMotionDrivesAnalyzer: resolved.onboardMotionDrivesAnalyzer
17884
18558
  };
17885
18559
  }
17886
18560
  /**
@@ -17915,7 +18589,8 @@ class PipelineOrchestratorAddon extends BaseAddon {
17915
18589
  audio: pipelineConfig.audio ?? null,
17916
18590
  zones,
17917
18591
  detectionMode: config2.detectionMode,
17918
- audioMode: config2.audioMode
18592
+ audioMode: config2.audioMode,
18593
+ onboardMotionDrivesAnalyzer: config2.onboardMotionDrivesAnalyzer
17919
18594
  };
17920
18595
  let dispatchedNodeId = null;
17921
18596
  try {
@@ -18100,6 +18775,7 @@ class PipelineOrchestratorAddon extends BaseAddon {
18100
18775
  if (a.detectionFps !== b.detectionFps) return false;
18101
18776
  if (a.motionCooldownMs !== b.motionCooldownMs) return false;
18102
18777
  if (a.audioStreamId !== b.audioStreamId) return false;
18778
+ if (a.onboardMotionDrivesAnalyzer !== b.onboardMotionDrivesAnalyzer) return false;
18103
18779
  if (a.motionSources.length !== b.motionSources.length) return false;
18104
18780
  for (const s of a.motionSources) if (!b.motionSources.includes(s)) return false;
18105
18781
  return true;
@@ -18189,9 +18865,20 @@ class PipelineOrchestratorAddon extends BaseAddon {
18189
18865
  * across recoveries.
18190
18866
  */
18191
18867
  static DEFAULT_FPS_LOW_WINDOW_MS = 6e4;
18868
+ /**
18869
+ * Default min-FPS threshold for the per-camera low-fps load shedder.
18870
+ *
18871
+ * `0` = DISABLED by default: a single slow camera (e.g. an on-motion
18872
+ * camera idling at <1 fps between motion events) must NOT be detached or
18873
+ * block new dispatches. Capacity-based assignment (`collectAgentLoad` +
18874
+ * `balance()`, the "high-water" placement logic) is independent of this
18875
+ * and stays active. Operators can re-enable per-camera shedding by setting
18876
+ * a positive `fpsMinThreshold` in global settings.
18877
+ */
18878
+ static DEFAULT_FPS_MIN_THRESHOLD = 0;
18192
18879
  enforceLoadManagement(deviceId, metrics) {
18193
18880
  const cfg = this.globalSettings ?? {};
18194
- const threshold = typeof cfg.fpsMinThreshold === "number" ? cfg.fpsMinThreshold : 3;
18881
+ const threshold = typeof cfg.fpsMinThreshold === "number" ? cfg.fpsMinThreshold : PipelineOrchestratorAddon.DEFAULT_FPS_MIN_THRESHOLD;
18195
18882
  const windowMs = typeof cfg.fpsLowWindowMs === "number" && cfg.fpsLowWindowMs > 0 ? cfg.fpsLowWindowMs : PipelineOrchestratorAddon.DEFAULT_FPS_LOW_WINDOW_MS;
18196
18883
  if (threshold <= 0) return;
18197
18884
  if (metrics.phase !== "active") return;
@@ -18311,7 +18998,7 @@ class PipelineOrchestratorAddon extends BaseAddon {
18311
18998
  async handleInferenceResult(payload) {
18312
18999
  const ctx = this.ctx;
18313
19000
  if (!ctx) return;
18314
- const { deviceId, frame, frameHandle } = payload;
19001
+ const { deviceId, frame, frameHandle, capturedAt } = payload;
18315
19002
  if (frame.detections.length === 0) return;
18316
19003
  this.ctx.eventBus.emit({
18317
19004
  id: `detection-${deviceId}-${Date.now()}`,
@@ -18319,8 +19006,10 @@ class PipelineOrchestratorAddon extends BaseAddon {
18319
19006
  source: { type: "device", id: deviceId, nodeId: "hub", addonId: "pipeline-orchestrator", deviceId },
18320
19007
  timestamp: /* @__PURE__ */ new Date(),
18321
19008
  // Forward the upstream shm-ring `frameHandle` so post-analysis
18322
- // consumers (Task 8) can resolve the original frame zero-copy.
18323
- data: { frame, analysisResults: [], frameHandle }
19009
+ // consumers (Task 8) can resolve the original frame zero-copy, and
19010
+ // `capturedAt` (shm-commit wall-clock) so a UI/probe consumer can
19011
+ // measure true frame-capture → delivery latency.
19012
+ data: { frame, analysisResults: [], frameHandle, ...typeof capturedAt === "number" ? { capturedAt } : {} }
18324
19013
  });
18325
19014
  }
18326
19015
  /**
@@ -18446,8 +19135,8 @@ class PipelineOrchestratorAddon extends BaseAddon {
18446
19135
  }
18447
19136
  try {
18448
19137
  const byteLength = chunk.data.byteLength;
18449
- const ab = new ArrayBuffer(byteLength);
18450
- new Uint8Array(ab).set(
19138
+ const data = new Uint8Array(byteLength);
19139
+ data.set(
18451
19140
  new Uint8Array(
18452
19141
  chunk.data.buffer,
18453
19142
  chunk.data.byteOffset,
@@ -18455,7 +19144,7 @@ class PipelineOrchestratorAddon extends BaseAddon {
18455
19144
  )
18456
19145
  );
18457
19146
  const audioChunkInput = {
18458
- data: new Float32Array(ab, 0, byteLength / 4),
19147
+ data,
18459
19148
  sampleRate: chunk.sampleRate,
18460
19149
  channels: chunk.channels,
18461
19150
  timestamp: chunk.timestamp,