@camstack/addon-pipeline-orchestrator 0.1.25 → 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-CF16SlpF.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-CVui0qjL.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-BCEx31Mh.mjs → index-Dy2V7VOm.mjs} +3808 -3277
  26. package/dist/{index-Cbqs9uJn.mjs → index-kp_mtnZv.mjs} +1 -1
  27. package/dist/index.js +775 -72
  28. package/dist/index.js.map +1 -1
  29. package/dist/index.mjs +775 -72
  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.js CHANGED
@@ -4780,6 +4780,16 @@ function record(keyType, valueType, params) {
4780
4780
  ...normalizeParams(params)
4781
4781
  });
4782
4782
  }
4783
+ function partialRecord(keyType, valueType, params) {
4784
+ const k = clone(keyType);
4785
+ k._zod.values = void 0;
4786
+ return new ZodRecord({
4787
+ type: "record",
4788
+ keyType: k,
4789
+ valueType,
4790
+ ...normalizeParams(params)
4791
+ });
4792
+ }
4783
4793
  const ZodEnum = /* @__PURE__ */ $constructor("ZodEnum", (inst, def) => {
4784
4794
  $ZodEnum.init(inst, def);
4785
4795
  ZodType.init(inst, def);
@@ -5056,6 +5066,37 @@ function _instanceof(cls, params = {}) {
5056
5066
  };
5057
5067
  return inst;
5058
5068
  }
5069
+ const wiringProbeKindSchema = _enum(["singleton", "device", "widget"]);
5070
+ const wiringProbeResultSchema = object({
5071
+ capName: string(),
5072
+ kind: wiringProbeKindSchema,
5073
+ deviceId: number().optional(),
5074
+ reachable: boolean(),
5075
+ latencyMs: number(),
5076
+ error: string().optional()
5077
+ });
5078
+ const wiringAddonHealthSchema = object({
5079
+ addonId: string(),
5080
+ caps: array(wiringProbeResultSchema).readonly(),
5081
+ widgets: array(wiringProbeResultSchema).readonly()
5082
+ });
5083
+ const wiringNodeHealthSchema = object({
5084
+ nodeId: string(),
5085
+ addons: array(wiringAddonHealthSchema).readonly()
5086
+ });
5087
+ object({
5088
+ /** True only when every probed target is reachable. */
5089
+ ok: boolean(),
5090
+ /** True when at least one target is unreachable. */
5091
+ degraded: boolean(),
5092
+ checkedAt: string(),
5093
+ nodes: array(wiringNodeHealthSchema).readonly(),
5094
+ summary: object({
5095
+ total: number(),
5096
+ reachable: number(),
5097
+ unreachable: number()
5098
+ })
5099
+ });
5059
5100
  const MODEL_FORMATS = ["onnx", "coreml", "openvino", "tflite", "pt"];
5060
5101
  const WELL_KNOWN_TABS = [
5061
5102
  { id: "overview", label: "Overview", icon: "layout-dashboard", order: -10 },
@@ -6603,7 +6644,7 @@ const SpatialDetectionSchema = object({
6603
6644
  bbox: BoundingBoxSchema
6604
6645
  });
6605
6646
  const AudioChunkInputSchema = object({
6606
- data: _instanceof(Float32Array),
6647
+ data: _instanceof(Uint8Array),
6607
6648
  sampleRate: number(),
6608
6649
  channels: number(),
6609
6650
  timestamp: number(),
@@ -7278,7 +7319,23 @@ const RunnerCameraConfigSchema = object({
7278
7319
  * whenever its `zones` device-state slice changes, so the runner's
7279
7320
  * copy stays in sync. Empty array → no zone filtering.
7280
7321
  */
7281
- zones: array(ZoneSchema).readonly().default([])
7322
+ zones: array(ZoneSchema).readonly().default([]),
7323
+ /**
7324
+ * When true (default) and the camera's `motionSources` contains only
7325
+ * `'onboard'`, the runner dynamically opens the same WASM frame-diff
7326
+ * motion-frames subscription on each `MotionOnMotionChanged
7327
+ * source:'onboard'` event and tears it down after `motionCooldownMs`.
7328
+ * This causes `runMotionAnalysis` to emit `MotionZonesRaw` /
7329
+ * `MotionAnalysis` during active-motion windows without the
7330
+ * substream being held open continuously.
7331
+ *
7332
+ * Set to `false` to disable the dynamic analyzer for this camera
7333
+ * (e.g. very low-bandwidth links where the extra substream is
7334
+ * undesirable). Has no effect when `motionSources` already includes
7335
+ * `'analyzer'` — the analyzer runs continuously in that case and
7336
+ * this gate is bypassed.
7337
+ */
7338
+ onboardMotionDrivesAnalyzer: boolean().default(true)
7282
7339
  });
7283
7340
  const RunnerCameraDeviceUIFields = [
7284
7341
  {
@@ -7338,6 +7395,16 @@ const RunnerCameraDeviceUIFields = [
7338
7395
  showValue: true,
7339
7396
  unit: "s",
7340
7397
  displayScale: 1e3
7398
+ },
7399
+ // Only meaningful for onboard-only cameras — when `motionSources`
7400
+ // contains `'analyzer'` the analyzer runs continuously already.
7401
+ {
7402
+ key: "onboardMotionDrivesAnalyzer",
7403
+ type: "boolean",
7404
+ label: "Run motion analyzer on onboard motion",
7405
+ description: "When onboard motion is detected, temporarily start the frame-diff analyzer to emit zone results. Stops after the motion cooldown window.",
7406
+ default: true,
7407
+ showWhen: { field: "motionSources", includes: "onboard" }
7341
7408
  }
7342
7409
  ];
7343
7410
  const RunnerLocalLoadSchema = object({
@@ -7475,22 +7542,68 @@ MotionTriggerStatusSchema.extend({
7475
7542
  }) }
7476
7543
  }
7477
7544
  });
7545
+ const MaskPointSchema = object({
7546
+ x: number(),
7547
+ y: number()
7548
+ });
7549
+ const MaskRectShapeSchema = object({
7550
+ kind: literal("rect"),
7551
+ x: number(),
7552
+ y: number(),
7553
+ width: number(),
7554
+ height: number()
7555
+ });
7556
+ const MaskPolygonShapeSchema = object({
7557
+ kind: literal("polygon"),
7558
+ points: array(MaskPointSchema)
7559
+ });
7560
+ const MaskGridShapeSchema = object({
7561
+ kind: literal("grid"),
7562
+ gridWidth: number(),
7563
+ gridHeight: number(),
7564
+ cells: array(boolean())
7565
+ });
7566
+ const MaskLineShapeSchema = object({
7567
+ kind: literal("line"),
7568
+ points: array(MaskPointSchema)
7569
+ });
7570
+ discriminatedUnion("kind", [
7571
+ MaskRectShapeSchema,
7572
+ MaskPolygonShapeSchema,
7573
+ MaskGridShapeSchema,
7574
+ MaskLineShapeSchema
7575
+ ]);
7576
+ const MaskShapeKindSchema = _enum(["rect", "polygon", "grid", "line"]);
7577
+ const MaskPolygonVerticesSchema = object({
7578
+ min: number(),
7579
+ max: number()
7580
+ });
7581
+ const MaskGridDimsSchema = object({
7582
+ width: number(),
7583
+ height: number()
7584
+ });
7585
+ const MotionZoneRegionSchema = object({
7586
+ id: number(),
7587
+ enabled: boolean(),
7588
+ shape: MaskGridShapeSchema
7589
+ });
7478
7590
  object({
7479
7591
  enabled: boolean(),
7480
7592
  sensitivity: number(),
7481
- /** Row-major active-cell grid. Length = gridWidth*gridHeight (see getOptions). */
7482
- cells: array(boolean()),
7593
+ /** Grid region(s). Today exactly one `grid` shape. */
7594
+ regions: array(MotionZoneRegionSchema),
7483
7595
  lastFetchedAt: number()
7484
7596
  });
7485
7597
  const MotionZoneOptionsSchema = object({
7486
- gridWidth: number(),
7487
- gridHeight: number(),
7598
+ maxRegions: number(),
7599
+ supportedShapes: array(MaskShapeKindSchema),
7600
+ grid: MaskGridDimsSchema,
7488
7601
  sensitivity: object({ min: number(), max: number(), step: number() })
7489
7602
  });
7490
7603
  const MotionZonePatchSchema = object({
7491
7604
  enabled: boolean().optional(),
7492
7605
  sensitivity: number().optional(),
7493
- cells: array(boolean()).optional()
7606
+ regions: array(MotionZoneRegionSchema).optional()
7494
7607
  });
7495
7608
  ({
7496
7609
  deviceTypes: [DeviceType.Camera],
@@ -7503,6 +7616,102 @@ const MotionZonePatchSchema = object({
7503
7616
  )
7504
7617
  }
7505
7618
  });
7619
+ const NativeObjectClassEnum = _enum([
7620
+ "person",
7621
+ "vehicle",
7622
+ "animal",
7623
+ "face",
7624
+ "package",
7625
+ "other"
7626
+ ]);
7627
+ const NativeDetectionSchema = object({
7628
+ class: NativeObjectClassEnum,
7629
+ timestamp: number(),
7630
+ /** Firmware-provided confidence [0..1]. Reolink pushes don't carry it → undefined. */
7631
+ confidence: number().min(0).max(1).optional()
7632
+ });
7633
+ const NativeObjectDetectionStatusSchema = object({
7634
+ /**
7635
+ * Last observed instance per class. Missing entries mean the class
7636
+ * is supported but nothing has been seen since the provider started.
7637
+ *
7638
+ * MUST be a partial record: providers seed an empty `{}` on cold-start
7639
+ * and write one class at a time as detections arrive. In Zod 4
7640
+ * `z.record(enum, …)` is EXHAUSTIVE (requires every enum key), so a
7641
+ * partial write throws "expected object, received undefined" for every
7642
+ * unseen class. `z.partialRecord` keeps the enum-key narrowing while
7643
+ * allowing the sparse shape the providers actually write.
7644
+ */
7645
+ lastByClass: partialRecord(NativeObjectClassEnum, NativeDetectionSchema.nullable()),
7646
+ /** Classes the firmware is capable of detecting — enumerated at device register. */
7647
+ supportedClasses: array(NativeObjectClassEnum).readonly(),
7648
+ /**
7649
+ * Whether forwarding of onboard AI detections is enabled for this device.
7650
+ * Default true (on cold-start) — detections flow unconditionally before
7651
+ * the toggle is saved, so defaulting true preserves existing behaviour.
7652
+ */
7653
+ enabled: boolean()
7654
+ });
7655
+ NativeObjectDetectionStatusSchema.extend({
7656
+ /** Required by createRuntimeStateBridge — epoch ms of last refresh. */
7657
+ lastFetchedAt: number()
7658
+ });
7659
+ ({
7660
+ deviceTypes: [DeviceType.Camera],
7661
+ methods: {
7662
+ setEnabled: method(
7663
+ object({ deviceId: number(), enabled: boolean() }),
7664
+ _void(),
7665
+ { kind: "mutation", auth: "admin" }
7666
+ )
7667
+ },
7668
+ events: {
7669
+ onDetected: { data: object({
7670
+ deviceId: number(),
7671
+ detection: NativeDetectionSchema
7672
+ }) }
7673
+ }
7674
+ });
7675
+ const PrivacyMaskShapeSchema = discriminatedUnion("kind", [
7676
+ MaskRectShapeSchema,
7677
+ MaskPolygonShapeSchema
7678
+ ]);
7679
+ const PrivacyMaskRegionSchema = object({
7680
+ /** Slot id, 0-based. Stable across read/write. */
7681
+ id: number(),
7682
+ /** Whether this zone is active (blanked out by the camera). */
7683
+ enabled: boolean(),
7684
+ shape: PrivacyMaskShapeSchema
7685
+ });
7686
+ object({
7687
+ enabled: boolean(),
7688
+ /** Active zones (normalized 0..1). Length ≤ maxRegions. */
7689
+ regions: array(PrivacyMaskRegionSchema),
7690
+ lastFetchedAt: number()
7691
+ });
7692
+ const PrivacyMaskOptionsSchema = object({
7693
+ /** Maximum number of supported zones. */
7694
+ maxRegions: number(),
7695
+ /** Shape kinds this camera accepts — Reolink: ['rect']; Hikvision: ['rect','polygon']. */
7696
+ supportedShapes: array(MaskShapeKindSchema),
7697
+ /** Polygon vertex bounds when 'polygon' is supported (Hikvision: {min:4,max:4}). */
7698
+ polygonVertices: MaskPolygonVerticesSchema.optional()
7699
+ });
7700
+ const PrivacyMaskPatchSchema = object({
7701
+ enabled: boolean().optional(),
7702
+ regions: array(PrivacyMaskRegionSchema).optional()
7703
+ });
7704
+ ({
7705
+ deviceTypes: [DeviceType.Camera],
7706
+ methods: {
7707
+ getOptions: method(object({ deviceId: number() }), PrivacyMaskOptionsSchema),
7708
+ setMask: method(
7709
+ object({ deviceId: number(), patch: PrivacyMaskPatchSchema }),
7710
+ _void(),
7711
+ { kind: "mutation", auth: "admin" }
7712
+ )
7713
+ }
7714
+ });
7506
7715
  const AutotrackTargetTypeSchema = string().describe("Vendor target string (people/vehicle/pet); empty = camera default");
7507
7716
  const PtzAutotrackSettingsSchema = object({
7508
7717
  targetType: AutotrackTargetTypeSchema,
@@ -8024,7 +8233,8 @@ const SettingsUpdateResultSchema = object({
8024
8233
  object({
8025
8234
  addonId: string(),
8026
8235
  nodeId: string().optional(),
8027
- overlay: record(string(), unknown()).optional()
8236
+ overlay: record(string(), unknown()).optional(),
8237
+ cap: string().optional()
8028
8238
  }),
8029
8239
  SettingsSchemaWithValuesSchema.nullable()
8030
8240
  ),
@@ -8755,7 +8965,14 @@ const OauthIntegrationDescriptorSchema = object({
8755
8965
  /** Allowed redirect_uri prefixes. /api/oauth2/authorize rejects any
8756
8966
  * redirect_uri that does not start with one of these. Required —
8757
8967
  * an empty list means the integration can never complete linking. */
8758
- allowedRedirectPrefixes: array(string()).min(1)
8968
+ allowedRedirectPrefixes: array(string()).min(1),
8969
+ /** Optional public origin (no trailing slash) that this integration's
8970
+ * issued codes/tokens should carry as the `hubUrl` claim — typically the
8971
+ * operator-selected external-access endpoint resolved by the addon. When
8972
+ * present, /api/oauth2/authorize bakes THIS into the code instead of the
8973
+ * hub-global `publicHubUrl()`, so a forked exporter addon (which can't set
8974
+ * the hub's env) drives the claim that its cloud Lambda routes back on. */
8975
+ hubUrl: string().optional()
8759
8976
  });
8760
8977
  ({
8761
8978
  methods: {
@@ -9373,7 +9590,20 @@ const WebrtcStreamChoiceSchema = object({
9373
9590
  object({
9374
9591
  deviceId: number().int().nonnegative(),
9375
9592
  target: WebrtcStreamTargetSchema,
9376
- hints: webrtcClientHintsSchema.optional()
9593
+ hints: webrtcClientHintsSchema.optional(),
9594
+ /**
9595
+ * SERVER-INJECTED — NOT a client hint. The hub layer that holds
9596
+ * the tRPC request context (and therefore the client IP) sets
9597
+ * this to `true` when the viewer's source IP is non-LAN
9598
+ * (4G/CGNAT/internet). The broker then forces TURN-relay-only
9599
+ * ICE for the session so a CGNAT client (which can only offer a
9600
+ * relay candidate) gets a clean relay↔relay media path instead
9601
+ * of werift nominating a dead host/hairpin-srflx pair. LAN
9602
+ * clients leave this absent/false and keep the low-latency
9603
+ * direct (host/srflx) path. Clients MUST NOT send this — the
9604
+ * server overwrites it from the request context.
9605
+ */
9606
+ relayOnly: boolean().optional()
9377
9607
  }),
9378
9608
  object({ sessionId: string(), sdpOffer: string() }),
9379
9609
  { kind: "mutation" }
@@ -9396,7 +9626,22 @@ const WebrtcStreamChoiceSchema = object({
9396
9626
  deviceId: number().int().nonnegative(),
9397
9627
  target: WebrtcStreamTargetSchema.optional(),
9398
9628
  sdpOffer: string(),
9399
- sessionId: string().optional()
9629
+ sessionId: string().optional(),
9630
+ /**
9631
+ * Force TURN-relay-only ICE for this session. Two kinds of caller
9632
+ * set it:
9633
+ * - A cloud peer like Alexa's RTCSessionController (reachable
9634
+ * only via TURN, never our host/srflx behind NAT) passes
9635
+ * `true` from its own trusted addon context.
9636
+ * - The hub injects it for browser client-offer viewers from the
9637
+ * request's source IP (non-LAN ⇒ true), exactly as it does for
9638
+ * `createSession`.
9639
+ * A LAN/Tailscale browser doing client-offer passthrough leaves it
9640
+ * absent/false so a direct host pair carries full native quality.
9641
+ * Untrusted browser clients MUST NOT send it — the hub overwrites
9642
+ * it from the request context.
9643
+ */
9644
+ relayOnly: boolean().optional()
9400
9645
  }),
9401
9646
  object({ sessionId: string(), sdpAnswer: string() }),
9402
9647
  { kind: "mutation" }
@@ -9410,6 +9655,46 @@ const WebrtcStreamChoiceSchema = object({
9410
9655
  _void(),
9411
9656
  { kind: "mutation" }
9412
9657
  ),
9658
+ /**
9659
+ * Trickle ICE — add a remote (client) ICE candidate to a live session.
9660
+ * Lets the client send its SDP offer/answer IMMEDIATELY (before ICE
9661
+ * gathering finishes) and deliver candidates as they arrive, so the
9662
+ * connection establishes in ~0s instead of waiting for full gathering.
9663
+ * The dual of `getIceCandidates`. Mirrors Scrypted's signaling.
9664
+ */
9665
+ addIceCandidate: method(
9666
+ object({
9667
+ deviceId: number().int().nonnegative(),
9668
+ sessionId: string(),
9669
+ candidate: string(),
9670
+ sdpMid: string().nullable().optional(),
9671
+ sdpMLineIndex: number().int().nullable().optional()
9672
+ }),
9673
+ _void(),
9674
+ { kind: "mutation" }
9675
+ ),
9676
+ /**
9677
+ * Trickle ICE — poll the server's gathered ICE candidates for a session.
9678
+ * The server answers immediately (no gathering wait) and the client polls
9679
+ * this to receive host/srflx/relay candidates as werift gathers them,
9680
+ * adding each to its PeerConnection. Returns all candidates gathered so
9681
+ * far; the client dedupes. `done` flips true once gathering completes.
9682
+ */
9683
+ getIceCandidates: method(
9684
+ object({
9685
+ deviceId: number().int().nonnegative(),
9686
+ sessionId: string()
9687
+ }),
9688
+ object({
9689
+ candidates: array(object({
9690
+ candidate: string(),
9691
+ sdpMid: string().nullable(),
9692
+ sdpMLineIndex: number().int().nullable()
9693
+ })),
9694
+ done: boolean()
9695
+ }),
9696
+ { kind: "query" }
9697
+ ),
9413
9698
  closeSession: method(
9414
9699
  object({
9415
9700
  deviceId: number().int().nonnegative(),
@@ -11085,6 +11370,16 @@ const UpdateConfigInput = object({
11085
11370
  ({
11086
11371
  deviceTypes: [DeviceType.Camera]
11087
11372
  });
11373
+ const cameraPipelineConfigCapability = {
11374
+ name: "camera-pipeline-config",
11375
+ scope: "device",
11376
+ mode: "singleton",
11377
+ kind: "wrapper",
11378
+ defaultActive: true,
11379
+ deviceTypes: [DeviceType.Camera],
11380
+ exposesDeviceSettings: true,
11381
+ methods: {}
11382
+ };
11088
11383
  const TrackStateSchema = _enum(["new", "entered", "left", "moving", "idle"]);
11089
11384
  const EventKindSchema = _enum(["motion", "object", "audio"]);
11090
11385
  const TrackPositionSchema = object({
@@ -11912,36 +12207,28 @@ const IntercomStatusSchema = object({
11912
12207
  }) }
11913
12208
  }
11914
12209
  });
11915
- const NativeObjectClassEnum = _enum([
11916
- "person",
11917
- "vehicle",
11918
- "animal",
11919
- "face",
11920
- "package",
11921
- "other"
11922
- ]);
11923
- const NativeDetectionSchema = object({
11924
- class: NativeObjectClassEnum,
11925
- timestamp: number(),
11926
- /** Firmware-provided confidence [0..1]. Reolink pushes don't carry it → undefined. */
11927
- confidence: number().min(0).max(1).optional()
11928
- });
11929
- object({
11930
- /**
11931
- * Last observed instance per class. Undefined entries mean the class
11932
- * is supported but nothing has been seen since the provider started.
11933
- */
11934
- lastByClass: record(NativeObjectClassEnum, NativeDetectionSchema.nullable()),
11935
- /** Classes the firmware is capable of detecting — enumerated at device register. */
11936
- supportedClasses: array(NativeObjectClassEnum).readonly()
12210
+ const CamStreamDescriptorSchema = object({
12211
+ camStreamId: string().min(1),
12212
+ kind: CamStreamKindSchema,
12213
+ url: string().optional(),
12214
+ codec: string().optional(),
12215
+ resolution: CamStreamResolutionSchema.optional(),
12216
+ fps: number().positive().optional(),
12217
+ label: string().optional(),
12218
+ /** Device-level features (e.g. `battery-operated`) — drives broker policy. */
12219
+ deviceFeatures: array(string()).optional(),
12220
+ /** Eligible for automatic profile assignment. Absent = `true`. */
12221
+ autoEligible: boolean().optional(),
12222
+ /** Transport-specific opaque metadata (e.g. rfc4571 SDP). */
12223
+ metadata: record(string(), unknown()).optional()
11937
12224
  });
11938
12225
  ({
11939
12226
  deviceTypes: [DeviceType.Camera],
11940
- events: {
11941
- onDetected: { data: object({
11942
- deviceId: number(),
11943
- detection: NativeDetectionSchema
11944
- }) }
12227
+ methods: {
12228
+ getCatalog: method(
12229
+ object({ deviceId: number().int().nonnegative() }),
12230
+ array(CamStreamDescriptorSchema).readonly()
12231
+ )
11945
12232
  }
11946
12233
  });
11947
12234
  const ModelFormatSchema = _enum(MODEL_FORMATS);
@@ -12809,6 +13096,18 @@ const TopologyProcessSchema = object({
12809
13096
  services: array(TopologyServiceSchema).readonly(),
12810
13097
  groupId: string().optional()
12811
13098
  });
13099
+ const TopologyCategoryAddonSchema = object({
13100
+ id: string(),
13101
+ status: string(),
13102
+ cpuPercent: number(),
13103
+ memoryRss: number()
13104
+ });
13105
+ const TopologyCategorySchema = object({
13106
+ category: string(),
13107
+ total: number(),
13108
+ healthy: number(),
13109
+ addons: array(TopologyCategoryAddonSchema).readonly()
13110
+ });
12812
13111
  const TopologyNodeSchema = object({
12813
13112
  id: string(),
12814
13113
  name: string(),
@@ -12833,7 +13132,15 @@ const TopologyNodeSchema = object({
12833
13132
  status: string()
12834
13133
  })
12835
13134
  ).readonly(),
12836
- processes: array(TopologyProcessSchema).readonly()
13135
+ processes: array(TopologyProcessSchema).readonly(),
13136
+ categories: array(TopologyCategorySchema).readonly()
13137
+ });
13138
+ const CapUsageEdgeSchema = object({
13139
+ callerAddonId: string(),
13140
+ providerAddonId: string(),
13141
+ capName: string(),
13142
+ callsPerMin: number(),
13143
+ lastCallAtMs: number()
12837
13144
  });
12838
13145
  const ClusterAddonNodeDeploymentSchema = object({
12839
13146
  nodeId: string(),
@@ -12917,13 +13224,7 @@ const RenameNodeResultSchema = object({
12917
13224
  object({
12918
13225
  windowSeconds: number().int().positive().max(300).default(60)
12919
13226
  }),
12920
- array(object({
12921
- callerAddonId: string(),
12922
- providerAddonId: string(),
12923
- capName: string(),
12924
- callsPerMin: number(),
12925
- lastCallAtMs: number()
12926
- })).readonly(),
13227
+ array(CapUsageEdgeSchema).readonly(),
12927
13228
  { auth: "admin" }
12928
13229
  ),
12929
13230
  /**
@@ -13135,7 +13436,8 @@ const PackageUpdateSchema = object({
13135
13436
  currentVersion: string(),
13136
13437
  latestVersion: string(),
13137
13438
  category: _enum(["addon", "core"]),
13138
- requiresRestart: boolean()
13439
+ requiresRestart: boolean(),
13440
+ isSystem: boolean()
13139
13441
  });
13140
13442
  const PackageVersionInfoSchema = object({
13141
13443
  version: string(),
@@ -13168,6 +13470,42 @@ const UpdateFrameworkPackageResultSchema = object({
13168
13470
  /** Ms-epoch the server scheduled its self-restart. */
13169
13471
  restartingAt: number()
13170
13472
  });
13473
+ const BulkUpdateItemStatusSchema = _enum([
13474
+ "queued",
13475
+ "updating",
13476
+ "done",
13477
+ "done-pending-restart",
13478
+ "failed"
13479
+ ]);
13480
+ const BulkUpdateItemSchema = object({
13481
+ name: string(),
13482
+ isSystem: boolean(),
13483
+ fromVersion: string(),
13484
+ toVersion: string(),
13485
+ status: BulkUpdateItemStatusSchema,
13486
+ error: string().optional(),
13487
+ startedAtMs: number().optional(),
13488
+ completedAtMs: number().optional()
13489
+ });
13490
+ const BulkUpdatePhaseSchema = _enum([
13491
+ "regular",
13492
+ "system",
13493
+ "restarting",
13494
+ "finalizing"
13495
+ ]);
13496
+ const BulkUpdateStateSchema = object({
13497
+ id: string(),
13498
+ nodeId: string(),
13499
+ startedAtMs: number(),
13500
+ completedAtMs: number().optional(),
13501
+ total: number(),
13502
+ completed: number(),
13503
+ failed: number(),
13504
+ current: string().nullable(),
13505
+ phase: BulkUpdatePhaseSchema,
13506
+ cancelled: boolean(),
13507
+ items: array(BulkUpdateItemSchema).readonly()
13508
+ });
13171
13509
  const FrameworkPackageStatusSchema = object({
13172
13510
  packageName: string(),
13173
13511
  currentVersion: string(),
@@ -13305,7 +13643,7 @@ const CustomActionInputSchema = object({
13305
13643
  getLastRestart: method(
13306
13644
  _void(),
13307
13645
  object({
13308
- kind: _enum(["framework-update", "manual", "system"]),
13646
+ kind: _enum(["framework-update", "manual", "system", "framework-bulk-update"]),
13309
13647
  packageName: string().optional(),
13310
13648
  fromVersion: string().optional(),
13311
13649
  toVersion: string().optional(),
@@ -13395,11 +13733,70 @@ const CustomActionInputSchema = object({
13395
13733
  updateFrameworkPackage: method(
13396
13734
  object({
13397
13735
  packageName: string().min(1),
13398
- version: string().optional()
13736
+ version: string().optional(),
13737
+ deferRestart: boolean().optional()
13399
13738
  }),
13400
13739
  UpdateFrameworkPackageResultSchema,
13401
13740
  { kind: "mutation", auth: "admin" }
13402
13741
  ),
13742
+ /**
13743
+ * Kicks off a server-side bulk update operation and returns the bulk
13744
+ * id immediately. The operation runs asynchronously; observe progress
13745
+ * via the `AddonsBulkUpdateProgress` event or `getBulkUpdateState`.
13746
+ * Items with `isSystem: true` use `deferRestart` — the hub restarts
13747
+ * ONCE at the end of the system phase, after all system packages are
13748
+ * installed.
13749
+ *
13750
+ * `items[].version` is REQUIRED — callers must pass the resolved
13751
+ * version from `listUpdates`. There is no `'latest'` default here
13752
+ * (unlike `updatePackage`) to guarantee deterministic bulk rolls.
13753
+ */
13754
+ startBulkUpdate: method(
13755
+ object({
13756
+ nodeId: string(),
13757
+ items: array(object({
13758
+ name: string(),
13759
+ version: string(),
13760
+ isSystem: boolean()
13761
+ })).readonly()
13762
+ }),
13763
+ object({ id: string() }),
13764
+ { kind: "mutation", auth: "admin" }
13765
+ ),
13766
+ /**
13767
+ * Returns the current state of a bulk update by id.
13768
+ * Returns `null` if the id is unknown or has been auto-cleaned
13769
+ * (5 minutes after `completedAt` the record is evicted from memory).
13770
+ */
13771
+ getBulkUpdateState: method(
13772
+ object({ id: string() }),
13773
+ BulkUpdateStateSchema.nullable(),
13774
+ { auth: "admin" }
13775
+ ),
13776
+ /**
13777
+ * Cancels an in-flight bulk update. The update loop exits after the
13778
+ * currently-processing item completes — cancellation is not
13779
+ * instantaneous. Has no effect once the `restarting` phase has been
13780
+ * entered (the hub is already shutting down at that point).
13781
+ * Returns `{ cancelled: false }` if the id is unknown, the operation
13782
+ * has already completed, or the `restarting` phase is active.
13783
+ */
13784
+ cancelBulkUpdate: method(
13785
+ object({ id: string() }),
13786
+ object({ cancelled: boolean() }),
13787
+ { kind: "mutation", auth: "admin" }
13788
+ ),
13789
+ /**
13790
+ * Lists all currently active (non-completed) bulk updates.
13791
+ * If `nodeId` is provided, filters to only bulk updates targeting
13792
+ * that node. Useful for restoring an in-progress banner on a fresh
13793
+ * page load when the UI reconnects mid-operation.
13794
+ */
13795
+ listActiveBulkUpdates: method(
13796
+ object({ nodeId: string().optional() }),
13797
+ array(BulkUpdateStateSchema).readonly(),
13798
+ { auth: "admin" }
13799
+ ),
13403
13800
  getVersions: method(
13404
13801
  object({ name: string() }),
13405
13802
  array(PackageVersionInfoSchema).readonly()
@@ -13527,6 +13924,7 @@ var EventCategory = /* @__PURE__ */ ((EventCategory2) => {
13527
13924
  EventCategory2["StreamBrokerOnCamStreamDemand"] = "stream-broker.onCamStreamDemand";
13528
13925
  EventCategory2["StreamBrokerOnCamStreamIdle"] = "stream-broker.onCamStreamIdle";
13529
13926
  EventCategory2["StreamBrokerOnRequestStreamSourceRefresh"] = "stream-broker.onRequestStreamSourceRefresh";
13927
+ EventCategory2["StreamParamsChanged"] = "stream-params.changed";
13530
13928
  EventCategory2["DeviceStateChanged"] = "device.state-changed";
13531
13929
  EventCategory2["BatteryOnStatusChanged"] = "battery.onStatusChanged";
13532
13930
  EventCategory2["DoorbellOnPressed"] = "doorbell.onPressed";
@@ -13574,6 +13972,7 @@ var EventCategory = /* @__PURE__ */ ((EventCategory2) => {
13574
13972
  EventCategory2["NetworkTunnelStarted"] = "network.tunnel.started";
13575
13973
  EventCategory2["NetworkTunnelStopped"] = "network.tunnel.stopped";
13576
13974
  EventCategory2["LocalNetworkChanged"] = "network.local.changed";
13975
+ EventCategory2["MeshNetworkChanged"] = "network.mesh.changed";
13577
13976
  EventCategory2["BackupCompleted"] = "backup.completed";
13578
13977
  EventCategory2["BackupRestored"] = "backup.restored";
13579
13978
  EventCategory2["NotificationDispatched"] = "notification.dispatched";
@@ -13584,6 +13983,7 @@ var EventCategory = /* @__PURE__ */ ((EventCategory2) => {
13584
13983
  EventCategory2["DeviceAwake"] = "device.awake";
13585
13984
  EventCategory2["DeviceSleeping"] = "device.sleeping";
13586
13985
  EventCategory2["RetentionCleanup"] = "retention.cleanup";
13986
+ EventCategory2["AddonsBulkUpdateProgress"] = "addons.bulk-update-progress";
13587
13987
  return EventCategory2;
13588
13988
  })(EventCategory || {});
13589
13989
  function isEvent(event2, category) {
@@ -13736,7 +14136,7 @@ class BaseAddon {
13736
14136
  }
13737
14137
  // ── Settings schemas (override to provide UI) ─────────────────────────
13738
14138
  /** Override to provide global-level settings UI schema. */
13739
- globalSettingsSchema() {
14139
+ globalSettingsSchema(_cap) {
13740
14140
  return null;
13741
14141
  }
13742
14142
  /** Override to provide device-level settings UI schema. */
@@ -13750,8 +14150,8 @@ class BaseAddon {
13750
14150
  // blob and every addon used exactly one of them; the distinction was
13751
14151
  // never semantically load-bearing. `global` won because it was the
13752
14152
  // widely-used one and the name reads naturally (per-node addon config).
13753
- async getGlobalSettings(overlay) {
13754
- const schema = this.globalSettingsSchema();
14153
+ async getGlobalSettings(overlay, cap) {
14154
+ const schema = this.globalSettingsSchema(cap);
13755
14155
  if (!schema) return { sections: [] };
13756
14156
  const raw = await this._ctx?.settings?.readAddonStore() ?? {};
13757
14157
  return hydrateSchema(schema, overlay ? { ...raw, ...overlay } : raw);
@@ -14170,6 +14570,14 @@ class ReadinessRegistry {
14170
14570
  cleanup();
14171
14571
  reject(new ReadinessTimeoutError(capName, scope, this.now() - start));
14172
14572
  }, timeoutMs);
14573
+ const PENDING_LOG_INTERVAL_MS = 3e4;
14574
+ const pendingLogTimer = setInterval(() => {
14575
+ if (settled) return;
14576
+ this.logger?.warn(
14577
+ `readiness: still awaiting ${capName} (${scopeKey(scope)}) after ${this.now() - start}ms`
14578
+ );
14579
+ }, PENDING_LOG_INTERVAL_MS);
14580
+ if (typeof pendingLogTimer.unref === "function") pendingLogTimer.unref();
14173
14581
  const onAbort = () => {
14174
14582
  if (settled) return;
14175
14583
  settled = true;
@@ -14180,6 +14588,7 @@ class ReadinessRegistry {
14180
14588
  function cleanup() {
14181
14589
  unsubscribe();
14182
14590
  if (timer !== null) clearTimeout(timer);
14591
+ clearInterval(pendingLogTimer);
14183
14592
  opts.signal?.removeEventListener("abort", onAbort);
14184
14593
  }
14185
14594
  });
@@ -14733,6 +15142,8 @@ function balanceAudio(input) {
14733
15142
  }
14734
15143
  const POLL_INTERVAL_MS = 200;
14735
15144
  const PULL_MAX_COUNT = 8;
15145
+ const RESUBSCRIBE_AFTER_FAILURES = 2;
15146
+ const RESUBSCRIBE_THROTTLE_TICKS = 5;
14736
15147
  async function startAudioChunkPoller(options) {
14737
15148
  const { api, brokerId, tag, onChunk, logger } = options;
14738
15149
  let subscriptionId;
@@ -14750,6 +15161,19 @@ async function startAudioChunkPoller(options) {
14750
15161
  }
14751
15162
  let stopped = false;
14752
15163
  let timer;
15164
+ let consecutiveFailures = 0;
15165
+ const resubscribe = async () => {
15166
+ try {
15167
+ const result = await api.streamBroker.subscribeAudioChunks.mutate({ brokerId, tag });
15168
+ subscriptionId = result.subscriptionId;
15169
+ logger.info("audio-chunk poller: re-subscribed after broker outage", {
15170
+ meta: { brokerId, tag, subscriptionId, afterFailures: consecutiveFailures }
15171
+ });
15172
+ return true;
15173
+ } catch {
15174
+ return false;
15175
+ }
15176
+ };
14753
15177
  const tick = async () => {
14754
15178
  if (stopped) return;
14755
15179
  try {
@@ -14757,14 +15181,24 @@ async function startAudioChunkPoller(options) {
14757
15181
  subscriptionId,
14758
15182
  maxCount: PULL_MAX_COUNT
14759
15183
  });
15184
+ if (consecutiveFailures > 0) {
15185
+ logger.info("audio-chunk poller: stream recovered", { meta: { brokerId, subscriptionId } });
15186
+ }
15187
+ consecutiveFailures = 0;
14760
15188
  for (const chunk of chunks) {
14761
15189
  if (stopped) break;
14762
15190
  await onChunk(chunk);
14763
15191
  }
14764
15192
  } catch (err) {
14765
- logger.warn("audio-chunk poller: pullAudioChunks failed", {
14766
- meta: { brokerId, subscriptionId, error: errMsg(err) }
14767
- });
15193
+ consecutiveFailures += 1;
15194
+ if (consecutiveFailures === 1) {
15195
+ logger.warn("audio-chunk poller: pullAudioChunks failed — attempting recovery", {
15196
+ meta: { brokerId, subscriptionId, error: errMsg(err) }
15197
+ });
15198
+ }
15199
+ if (!stopped && consecutiveFailures >= RESUBSCRIBE_AFTER_FAILURES && (consecutiveFailures - RESUBSCRIBE_AFTER_FAILURES) % RESUBSCRIBE_THROTTLE_TICKS === 0) {
15200
+ await resubscribe();
15201
+ }
14768
15202
  }
14769
15203
  if (!stopped) {
14770
15204
  timer = setTimeout(() => void tick(), POLL_INTERVAL_MS);
@@ -14785,6 +15219,18 @@ async function startAudioChunkPoller(options) {
14785
15219
  });
14786
15220
  };
14787
15221
  }
15222
+ function desiredDeviceIdsFromSlots(slots) {
15223
+ const result = /* @__PURE__ */ new Set();
15224
+ for (const slot of slots) {
15225
+ if (slot.status !== "unassigned" && slot.sourceCamStreamId !== null) {
15226
+ result.add(slot.deviceId);
15227
+ }
15228
+ }
15229
+ return result;
15230
+ }
15231
+ function computeDispatchGap(desired, known) {
15232
+ return [...desired].filter((id) => !known.has(id));
15233
+ }
14788
15234
  const PHASE_MODE_VALUES = /* @__PURE__ */ new Set([
14789
15235
  "disabled",
14790
15236
  "always-on",
@@ -14796,6 +15242,7 @@ function isPipelinePhaseMode(v) {
14796
15242
  const PREFERRED_AGENT_SETTING = "preferredAgent";
14797
15243
  const AUDIO_NODE_SETTING = "audioNodeId";
14798
15244
  const DEFAULT_BROKER_CALL_TIMEOUT_MS = 5e3;
15245
+ const RECONCILE_DEBOUNCE_MS = 200;
14799
15246
  const AGENT_SETTINGS_KEY = "agentSettings";
14800
15247
  const CAMERA_SETTINGS_KEY = "cameraSettings";
14801
15248
  const TEMPLATES_KEY = "templates";
@@ -14947,6 +15394,12 @@ class PipelineOrchestratorAddon extends BaseAddon {
14947
15394
  loadShedState = /* @__PURE__ */ new Map();
14948
15395
  /** Timer for the auto-resume sweep. */
14949
15396
  loadShedResumeTimer = null;
15397
+ /** Pending `scheduleReconcile` debounce timer. */
15398
+ reconcileTimer = null;
15399
+ /** True while `reconcileDispatch` is awaiting the RPC round-trip. */
15400
+ reconcileInFlight = false;
15401
+ /** Set to true when a reconcile is requested while one is already in-flight; triggers a follow-up pass. */
15402
+ reconcileRerunRequested = false;
14950
15403
  initTimestamp = 0;
14951
15404
  constructor() {
14952
15405
  super({});
@@ -15005,6 +15458,7 @@ class PipelineOrchestratorAddon extends BaseAddon {
15005
15458
  meta: { error: msg }
15006
15459
  });
15007
15460
  });
15461
+ this.scheduleReconcile();
15008
15462
  }
15009
15463
  );
15010
15464
  this.watchCapability(["pipeline-executor", "analysis-pipeline"], {
@@ -15102,6 +15556,7 @@ class PipelineOrchestratorAddon extends BaseAddon {
15102
15556
  });
15103
15557
  }, PROFILE_SLOTS_DEBOUNCE_MS)
15104
15558
  );
15559
+ this.scheduleReconcile();
15105
15560
  }
15106
15561
  );
15107
15562
  this.unsubDeviceUnregistered = this.ctx.eventBus.subscribe(
@@ -15281,9 +15736,15 @@ class PipelineOrchestratorAddon extends BaseAddon {
15281
15736
  }
15282
15737
  ]
15283
15738
  };
15739
+ this.scheduleReconcile();
15284
15740
  return {
15285
15741
  providers: [
15286
15742
  { capability: pipelineOrchestratorCapability, provider: this },
15743
+ // D12 re-home: the per-device settings contribution (motion/pipeline
15744
+ // tabs + zones top-tab) rides this device-scoped defaultActive wrapper
15745
+ // so it auto-binds to every camera and the binding-driven aggregate
15746
+ // invokes it. `this` already implements the three contribution methods.
15747
+ { capability: cameraPipelineConfigCapability, provider: this },
15287
15748
  { capability: zonesCapability, provider: this.zonesProvider },
15288
15749
  { capability: zoneRulesCapability, provider: this.zoneRulesProvider },
15289
15750
  { capability: addonWidgetsSourceCapability, provider: widgetsProvider }
@@ -15408,6 +15869,10 @@ class PipelineOrchestratorAddon extends BaseAddon {
15408
15869
  this.profileSlotTimers.clear();
15409
15870
  this.profileSlotTimers = null;
15410
15871
  }
15872
+ if (this.reconcileTimer !== null) {
15873
+ clearTimeout(this.reconcileTimer);
15874
+ this.reconcileTimer = null;
15875
+ }
15411
15876
  this.unsubDeviceRegistered?.();
15412
15877
  this.unsubDeviceRegistered = null;
15413
15878
  this.unsubDeviceUnregistered?.();
@@ -15483,7 +15948,7 @@ class PipelineOrchestratorAddon extends BaseAddon {
15483
15948
  );
15484
15949
  this.cameraConfigs.set(runnerConfig.deviceId, runnerConfig);
15485
15950
  const cfg = this.globalSettings ?? {};
15486
- const minThreshold = typeof cfg.fpsMinThreshold === "number" ? cfg.fpsMinThreshold : 3;
15951
+ const minThreshold = typeof cfg.fpsMinThreshold === "number" ? cfg.fpsMinThreshold : PipelineOrchestratorAddon.DEFAULT_FPS_MIN_THRESHOLD;
15487
15952
  const windowMs = typeof cfg.fpsLowWindowMs === "number" && cfg.fpsLowWindowMs > 0 ? cfg.fpsLowWindowMs : PipelineOrchestratorAddon.DEFAULT_FPS_LOW_WINDOW_MS;
15488
15953
  if (minThreshold > 0 && this.initTimestamp && Date.now() - this.initTimestamp > 3e4) {
15489
15954
  const now = Date.now();
@@ -15926,7 +16391,7 @@ class PipelineOrchestratorAddon extends BaseAddon {
15926
16391
  const api = ctx?.api;
15927
16392
  if (!api) continue;
15928
16393
  try {
15929
- const load = await api.pipelineRunner.getLocalLoad.query({ nodeId });
16394
+ const load = await this.queryLocalLoadBounded(nodeId);
15930
16395
  loads.push(load);
15931
16396
  } catch (err) {
15932
16397
  const msg = errMsg(err);
@@ -15951,6 +16416,53 @@ class PipelineOrchestratorAddon extends BaseAddon {
15951
16416
  }
15952
16417
  this.cachedAgentLoad = next;
15953
16418
  }
16419
+ /**
16420
+ * Per-node `getLocalLoad` budget (ms). A WEDGED runner — transport up
16421
+ * enough to stay in the service registry, but whose `broker.call` neither
16422
+ * resolves nor rejects (observed live as
16423
+ * "[brokerTransportLink] still awaiting … after 18min") — would otherwise
16424
+ * stall `collectAgentLoad`, and through it `dispatchCamera` →
16425
+ * `startDetection` for EVERY camera, indefinitely: the transport link does
16426
+ * not abort an in-flight `broker.call` (see trpc-links.ts), so neither the
16427
+ * try/catch nor an AbortSignal can unstick it. Tunable via the
16428
+ * `agentLoadTimeoutMs` global setting; the default is generous enough that
16429
+ * a healthy-but-slow node is never falsely skipped (the pinned-discovery
16430
+ * window alone is 2s).
16431
+ */
16432
+ static DEFAULT_AGENT_LOAD_TIMEOUT_MS = 4e3;
16433
+ agentLoadBudgetMs() {
16434
+ const cfg = this.globalSettings ?? {};
16435
+ const value = cfg.agentLoadTimeoutMs;
16436
+ return typeof value === "number" && value > 0 ? value : PipelineOrchestratorAddon.DEFAULT_AGENT_LOAD_TIMEOUT_MS;
16437
+ }
16438
+ /**
16439
+ * `getLocalLoad` for one runner node, bounded by `agentLoadBudgetMs`. On
16440
+ * timeout it rejects so `collectAgentLoad`'s catch treats the node exactly
16441
+ * like an offline one (logged at debug, skipped). The underlying query is
16442
+ * left to settle on its own — Moleculer rejects it when the node
16443
+ * disconnects — rather than leaking an unbounded await into the dispatch
16444
+ * path.
16445
+ */
16446
+ async queryLocalLoadBounded(nodeId) {
16447
+ const api = this.ctx?.api;
16448
+ if (!api) throw new Error("queryLocalLoadBounded: addon not initialized");
16449
+ const budgetMs = this.agentLoadBudgetMs();
16450
+ let timer;
16451
+ const timeout = new Promise((_resolve, reject) => {
16452
+ timer = setTimeout(
16453
+ () => reject(new Error(`getLocalLoad exceeded ${budgetMs}ms budget for ${nodeId}`)),
16454
+ budgetMs
16455
+ );
16456
+ });
16457
+ try {
16458
+ return await Promise.race([
16459
+ api.pipelineRunner.getLocalLoad.query({ nodeId }),
16460
+ timeout
16461
+ ]);
16462
+ } finally {
16463
+ if (timer) clearTimeout(timer);
16464
+ }
16465
+ }
15954
16466
  async readPreferredAgent(deviceId) {
15955
16467
  const ctx = this.ctx;
15956
16468
  if (!ctx?.settings) return null;
@@ -16248,6 +16760,44 @@ class PipelineOrchestratorAddon extends BaseAddon {
16248
16760
  "audio-analyzer ready — node now available for audio dispatch",
16249
16761
  { tags: { nodeId }, meta: { epoch: t.epoch } }
16250
16762
  );
16763
+ await this.reattachAudioForNode(nodeId);
16764
+ this.scheduleReconcile();
16765
+ }
16766
+ /**
16767
+ * Re-establish audio subscriptions for every camera whose audio is routed
16768
+ * to `nodeId`, after that node's audio-analyzer (re)started. Mirrors the
16769
+ * audio block in `startDetection` (drop prior sub, re-subscribe, keep only
16770
+ * if detection is active). Idempotent.
16771
+ */
16772
+ async reattachAudioForNode(nodeId) {
16773
+ for (const [deviceId, audioNode] of this.audioNodeByDevice) {
16774
+ if (audioNode !== nodeId) continue;
16775
+ const config2 = this.cameraConfigs.get(deviceId);
16776
+ if (!config2) continue;
16777
+ const prior = this.audioSubscriptions.get(deviceId);
16778
+ if (prior) {
16779
+ try {
16780
+ prior();
16781
+ } catch {
16782
+ }
16783
+ this.audioSubscriptions.delete(deviceId);
16784
+ }
16785
+ try {
16786
+ const unsub = await this.subscribeAudioStream(deviceId, config2);
16787
+ if (unsub) {
16788
+ if (this.activeDetections.has(deviceId)) {
16789
+ this.audioSubscriptions.set(deviceId, unsub);
16790
+ } else {
16791
+ unsub();
16792
+ }
16793
+ }
16794
+ } catch (err) {
16795
+ this.ctx.logger.error("audio re-attach on analyzer readiness failed", {
16796
+ tags: { deviceId, nodeId },
16797
+ meta: { error: errMsg(err) }
16798
+ });
16799
+ }
16800
+ }
16251
16801
  }
16252
16802
  /**
16253
16803
  * Act on a `detection-pipeline` transition: on `ready` with a new
@@ -16319,6 +16869,7 @@ class PipelineOrchestratorAddon extends BaseAddon {
16319
16869
  });
16320
16870
  });
16321
16871
  }, 3e3);
16872
+ this.scheduleReconcile();
16322
16873
  } catch (err) {
16323
16874
  this.ctx.logger.debug("readiness seed+redispatch failed", {
16324
16875
  tags: { nodeId },
@@ -16363,6 +16914,75 @@ class PipelineOrchestratorAddon extends BaseAddon {
16363
16914
  }
16364
16915
  }
16365
16916
  }
16917
+ // ── Dispatch reconcile ───────────────────────────────────────────────
16918
+ /**
16919
+ * Coalesce bursts of topology/slot-change signals into a single
16920
+ * `reconcileDispatch` pass. Mirrors `scheduleRehydrate` in
16921
+ * `packages/kernel/src/moleculer/readiness-context.ts`.
16922
+ */
16923
+ scheduleReconcile() {
16924
+ if (this.reconcileTimer !== null) clearTimeout(this.reconcileTimer);
16925
+ this.reconcileTimer = setTimeout(() => {
16926
+ this.reconcileTimer = null;
16927
+ void this.reconcileDispatch();
16928
+ }, RECONCILE_DEBOUNCE_MS);
16929
+ }
16930
+ /**
16931
+ * RPC snapshot-reconcile: pull the current broker fleet over
16932
+ * `listAllProfileSlots`, diff against `cameraConfigs`, and dispatch
16933
+ * the gap via `handleDeviceRegistered`.
16934
+ *
16935
+ * Additive only — never retracts or re-dispatches a known device.
16936
+ * Guards against re-entrant calls: a trailing request schedules one
16937
+ * extra pass after the in-flight one completes.
16938
+ */
16939
+ async reconcileDispatch() {
16940
+ if (this.reconcileInFlight) {
16941
+ this.reconcileRerunRequested = true;
16942
+ return;
16943
+ }
16944
+ this.reconcileInFlight = true;
16945
+ try {
16946
+ const api = this.api;
16947
+ if (!api) {
16948
+ this.ctx.logger.debug(
16949
+ "dispatch reconcile skipped — ctx.api not yet available"
16950
+ );
16951
+ return;
16952
+ }
16953
+ let slots;
16954
+ try {
16955
+ slots = await api.streamBroker.listAllProfileSlots.query();
16956
+ } catch (err) {
16957
+ this.ctx.logger.debug("dispatch reconcile skipped — stream-broker not ready", {
16958
+ meta: { error: errMsg(err) }
16959
+ });
16960
+ return;
16961
+ }
16962
+ const desired = desiredDeviceIdsFromSlots(slots);
16963
+ const known = new Set(this.cameraConfigs.keys());
16964
+ const gap = computeDispatchGap(desired, known);
16965
+ this.ctx.logger.info("dispatch reconcile", {
16966
+ meta: { desired: desired.size, known: known.size, gap }
16967
+ });
16968
+ for (const deviceId of gap) {
16969
+ try {
16970
+ await this.handleDeviceRegistered(deviceId);
16971
+ } catch (err) {
16972
+ this.ctx.logger.warn("dispatch reconcile: handleDeviceRegistered failed", {
16973
+ tags: { deviceId },
16974
+ meta: { error: errMsg(err) }
16975
+ });
16976
+ }
16977
+ }
16978
+ } finally {
16979
+ this.reconcileInFlight = false;
16980
+ if (this.reconcileRerunRequested) {
16981
+ this.reconcileRerunRequested = false;
16982
+ this.scheduleReconcile();
16983
+ }
16984
+ }
16985
+ }
16366
16986
  // ── Capability bindings (cap methods) ────────────────────────────────
16367
16987
  async getCapabilityBindings(input) {
16368
16988
  const ctx = this.ctx;
@@ -17144,11 +17764,11 @@ class PipelineOrchestratorAddon extends BaseAddon {
17144
17764
  key: "fpsMinThreshold",
17145
17765
  type: "slider",
17146
17766
  label: "Min FPS Threshold",
17147
- 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.",
17767
+ 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.",
17148
17768
  min: 0,
17149
17769
  max: 10,
17150
17770
  step: 1,
17151
- default: 3,
17771
+ default: PipelineOrchestratorAddon.DEFAULT_FPS_MIN_THRESHOLD,
17152
17772
  showValue: true,
17153
17773
  unit: "fps"
17154
17774
  },
@@ -17445,8 +18065,41 @@ class PipelineOrchestratorAddon extends BaseAddon {
17445
18065
  }
17446
18066
  ]
17447
18067
  };
18068
+ const hasNativeObjectDetection = await this.deviceHasNativeObjectDetectionCap(input.deviceId);
18069
+ const nativeObjectDetectionSections = [];
18070
+ if (hasNativeObjectDetection) {
18071
+ let nativeObjectDetectionEnabled = true;
18072
+ try {
18073
+ const status = await this.ctx.api?.nativeObjectDetection.getStatus.query({
18074
+ deviceId: input.deviceId
18075
+ });
18076
+ if (status !== void 0 && status !== null && "enabled" in status) {
18077
+ nativeObjectDetectionEnabled = Boolean(
18078
+ status.enabled
18079
+ );
18080
+ }
18081
+ } catch {
18082
+ }
18083
+ nativeObjectDetectionSections.push({
18084
+ id: "onboard-object-detection",
18085
+ title: "Onboard Object Detection",
18086
+ tab: "motion",
18087
+ order: 10,
18088
+ fields: [
18089
+ {
18090
+ type: "boolean",
18091
+ key: "nativeObjectDetectionEnabled",
18092
+ label: "Use onboard object detection",
18093
+ description: "When enabled, AI detections from the camera firmware are forwarded to the system notification and recording pipeline. Onboard motion events are never suppressed.",
18094
+ default: true,
18095
+ immediate: true,
18096
+ value: nativeObjectDetectionEnabled
18097
+ }
18098
+ ]
18099
+ });
18100
+ }
17448
18101
  return {
17449
- sections: [...baseSections, zonesSection]
18102
+ sections: [...baseSections, zonesSection, ...nativeObjectDetectionSections]
17450
18103
  };
17451
18104
  }
17452
18105
  // `getCameraPipelineWithFallback` was a thin wrapper over
@@ -17507,11 +18160,25 @@ class PipelineOrchestratorAddon extends BaseAddon {
17507
18160
  }
17508
18161
  async applyDeviceSettingsPatch(input) {
17509
18162
  const { pipelinePatch, rest } = this.splitPipelinePatch(input.patch);
18163
+ const { nativeObjectDetectionEnabled, ...orchestratorRest } = rest;
18164
+ if (nativeObjectDetectionEnabled !== void 0) {
18165
+ try {
18166
+ await this.ctx.api?.nativeObjectDetection.setEnabled.mutate({
18167
+ deviceId: input.deviceId,
18168
+ enabled: Boolean(nativeObjectDetectionEnabled)
18169
+ });
18170
+ } catch (err) {
18171
+ this.ctx.logger.warn("Failed to route nativeObjectDetectionEnabled to cap", {
18172
+ tags: { deviceId: input.deviceId },
18173
+ meta: { error: err instanceof Error ? err.message : String(err) }
18174
+ });
18175
+ }
18176
+ }
17510
18177
  if (Object.keys(pipelinePatch).length > 0) {
17511
18178
  await this.applyPipelinePatch(input.deviceId, pipelinePatch);
17512
18179
  }
17513
- if (Object.keys(rest).length > 0) {
17514
- await this.writeDeviceOrchestratorSettings(input.deviceId, rest);
18180
+ if (Object.keys(orchestratorRest).length > 0) {
18181
+ await this.writeDeviceOrchestratorSettings(input.deviceId, orchestratorRest);
17515
18182
  }
17516
18183
  return { success: true };
17517
18184
  }
@@ -17686,6 +18353,23 @@ class PipelineOrchestratorAddon extends BaseAddon {
17686
18353
  return false;
17687
18354
  }
17688
18355
  }
18356
+ /**
18357
+ * Returns true when the camera has a native `native-object-detection`
18358
+ * binding (i.e. the driver registered the cap). Used to gate the
18359
+ * "Use onboard object detection" toggle in `getDeviceSettingsContribution`.
18360
+ */
18361
+ async deviceHasNativeObjectDetectionCap(deviceId) {
18362
+ const api = this.api;
18363
+ if (!api) return false;
18364
+ try {
18365
+ const bindings = await api.deviceManager.getBindings.query({ deviceId });
18366
+ return bindings.entries.some(
18367
+ (e) => e.kind === "native" && e.capName === "native-object-detection"
18368
+ );
18369
+ } catch {
18370
+ return false;
18371
+ }
18372
+ }
17689
18373
  async resolveDeviceDetectionSettings(deviceId) {
17690
18374
  const ctx = this.ctx;
17691
18375
  if (!ctx?.settings) {
@@ -17750,6 +18434,8 @@ class PipelineOrchestratorAddon extends BaseAddon {
17750
18434
  const detectionMode = typeof userDetectionMode === "string" && isPipelinePhaseMode(userDetectionMode) ? userDetectionMode : profile?.defaults.detectionMode ?? "on-motion";
17751
18435
  const userAudioMode = raw["audioMode"];
17752
18436
  const audioMode = typeof userAudioMode === "string" && isPipelinePhaseMode(userAudioMode) ? userAudioMode : profile?.defaults.audioMode ?? "always-on";
18437
+ const userOnboardMotionDrivesAnalyzer = raw["onboardMotionDrivesAnalyzer"];
18438
+ const onboardMotionDrivesAnalyzer = typeof userOnboardMotionDrivesAnalyzer === "boolean" ? userOnboardMotionDrivesAnalyzer : true;
17753
18439
  return {
17754
18440
  motionDetectionEnabled,
17755
18441
  pipelineEnabled,
@@ -17760,7 +18446,8 @@ class PipelineOrchestratorAddon extends BaseAddon {
17760
18446
  detectionFps: mustNumber("detectionFps"),
17761
18447
  motionCooldownMs: mustNumber("motionCooldownMs"),
17762
18448
  detectionMode,
17763
- audioMode
18449
+ audioMode,
18450
+ onboardMotionDrivesAnalyzer
17764
18451
  };
17765
18452
  } catch (err) {
17766
18453
  const msg = errMsg(err);
@@ -17868,7 +18555,8 @@ class PipelineOrchestratorAddon extends BaseAddon {
17868
18555
  motionCooldownMs: resolved.motionCooldownMs,
17869
18556
  audioStreamId: void 0,
17870
18557
  detectionMode: resolved.detectionMode,
17871
- audioMode: resolved.audioMode
18558
+ audioMode: resolved.audioMode,
18559
+ onboardMotionDrivesAnalyzer: resolved.onboardMotionDrivesAnalyzer
17872
18560
  };
17873
18561
  }
17874
18562
  /**
@@ -17903,7 +18591,8 @@ class PipelineOrchestratorAddon extends BaseAddon {
17903
18591
  audio: pipelineConfig.audio ?? null,
17904
18592
  zones,
17905
18593
  detectionMode: config2.detectionMode,
17906
- audioMode: config2.audioMode
18594
+ audioMode: config2.audioMode,
18595
+ onboardMotionDrivesAnalyzer: config2.onboardMotionDrivesAnalyzer
17907
18596
  };
17908
18597
  let dispatchedNodeId = null;
17909
18598
  try {
@@ -18088,6 +18777,7 @@ class PipelineOrchestratorAddon extends BaseAddon {
18088
18777
  if (a.detectionFps !== b.detectionFps) return false;
18089
18778
  if (a.motionCooldownMs !== b.motionCooldownMs) return false;
18090
18779
  if (a.audioStreamId !== b.audioStreamId) return false;
18780
+ if (a.onboardMotionDrivesAnalyzer !== b.onboardMotionDrivesAnalyzer) return false;
18091
18781
  if (a.motionSources.length !== b.motionSources.length) return false;
18092
18782
  for (const s of a.motionSources) if (!b.motionSources.includes(s)) return false;
18093
18783
  return true;
@@ -18177,9 +18867,20 @@ class PipelineOrchestratorAddon extends BaseAddon {
18177
18867
  * across recoveries.
18178
18868
  */
18179
18869
  static DEFAULT_FPS_LOW_WINDOW_MS = 6e4;
18870
+ /**
18871
+ * Default min-FPS threshold for the per-camera low-fps load shedder.
18872
+ *
18873
+ * `0` = DISABLED by default: a single slow camera (e.g. an on-motion
18874
+ * camera idling at <1 fps between motion events) must NOT be detached or
18875
+ * block new dispatches. Capacity-based assignment (`collectAgentLoad` +
18876
+ * `balance()`, the "high-water" placement logic) is independent of this
18877
+ * and stays active. Operators can re-enable per-camera shedding by setting
18878
+ * a positive `fpsMinThreshold` in global settings.
18879
+ */
18880
+ static DEFAULT_FPS_MIN_THRESHOLD = 0;
18180
18881
  enforceLoadManagement(deviceId, metrics) {
18181
18882
  const cfg = this.globalSettings ?? {};
18182
- const threshold = typeof cfg.fpsMinThreshold === "number" ? cfg.fpsMinThreshold : 3;
18883
+ const threshold = typeof cfg.fpsMinThreshold === "number" ? cfg.fpsMinThreshold : PipelineOrchestratorAddon.DEFAULT_FPS_MIN_THRESHOLD;
18183
18884
  const windowMs = typeof cfg.fpsLowWindowMs === "number" && cfg.fpsLowWindowMs > 0 ? cfg.fpsLowWindowMs : PipelineOrchestratorAddon.DEFAULT_FPS_LOW_WINDOW_MS;
18184
18885
  if (threshold <= 0) return;
18185
18886
  if (metrics.phase !== "active") return;
@@ -18299,7 +19000,7 @@ class PipelineOrchestratorAddon extends BaseAddon {
18299
19000
  async handleInferenceResult(payload) {
18300
19001
  const ctx = this.ctx;
18301
19002
  if (!ctx) return;
18302
- const { deviceId, frame, frameHandle } = payload;
19003
+ const { deviceId, frame, frameHandle, capturedAt } = payload;
18303
19004
  if (frame.detections.length === 0) return;
18304
19005
  this.ctx.eventBus.emit({
18305
19006
  id: `detection-${deviceId}-${Date.now()}`,
@@ -18307,8 +19008,10 @@ class PipelineOrchestratorAddon extends BaseAddon {
18307
19008
  source: { type: "device", id: deviceId, nodeId: "hub", addonId: "pipeline-orchestrator", deviceId },
18308
19009
  timestamp: /* @__PURE__ */ new Date(),
18309
19010
  // Forward the upstream shm-ring `frameHandle` so post-analysis
18310
- // consumers (Task 8) can resolve the original frame zero-copy.
18311
- data: { frame, analysisResults: [], frameHandle }
19011
+ // consumers (Task 8) can resolve the original frame zero-copy, and
19012
+ // `capturedAt` (shm-commit wall-clock) so a UI/probe consumer can
19013
+ // measure true frame-capture → delivery latency.
19014
+ data: { frame, analysisResults: [], frameHandle, ...typeof capturedAt === "number" ? { capturedAt } : {} }
18312
19015
  });
18313
19016
  }
18314
19017
  /**
@@ -18434,8 +19137,8 @@ class PipelineOrchestratorAddon extends BaseAddon {
18434
19137
  }
18435
19138
  try {
18436
19139
  const byteLength = chunk.data.byteLength;
18437
- const ab = new ArrayBuffer(byteLength);
18438
- new Uint8Array(ab).set(
19140
+ const data = new Uint8Array(byteLength);
19141
+ data.set(
18439
19142
  new Uint8Array(
18440
19143
  chunk.data.buffer,
18441
19144
  chunk.data.byteOffset,
@@ -18443,7 +19146,7 @@ class PipelineOrchestratorAddon extends BaseAddon {
18443
19146
  )
18444
19147
  );
18445
19148
  const audioChunkInput = {
18446
- data: new Float32Array(ab, 0, byteLength / 4),
19149
+ data,
18447
19150
  sampleRate: chunk.sampleRate,
18448
19151
  channels: chunk.channels,
18449
19152
  timestamp: chunk.timestamp,