@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.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);
@@ -13149,7 +13436,8 @@ const PackageUpdateSchema = object({
13149
13436
  currentVersion: string(),
13150
13437
  latestVersion: string(),
13151
13438
  category: _enum(["addon", "core"]),
13152
- requiresRestart: boolean()
13439
+ requiresRestart: boolean(),
13440
+ isSystem: boolean()
13153
13441
  });
13154
13442
  const PackageVersionInfoSchema = object({
13155
13443
  version: string(),
@@ -13182,6 +13470,42 @@ const UpdateFrameworkPackageResultSchema = object({
13182
13470
  /** Ms-epoch the server scheduled its self-restart. */
13183
13471
  restartingAt: number()
13184
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
+ });
13185
13509
  const FrameworkPackageStatusSchema = object({
13186
13510
  packageName: string(),
13187
13511
  currentVersion: string(),
@@ -13319,7 +13643,7 @@ const CustomActionInputSchema = object({
13319
13643
  getLastRestart: method(
13320
13644
  _void(),
13321
13645
  object({
13322
- kind: _enum(["framework-update", "manual", "system"]),
13646
+ kind: _enum(["framework-update", "manual", "system", "framework-bulk-update"]),
13323
13647
  packageName: string().optional(),
13324
13648
  fromVersion: string().optional(),
13325
13649
  toVersion: string().optional(),
@@ -13409,11 +13733,70 @@ const CustomActionInputSchema = object({
13409
13733
  updateFrameworkPackage: method(
13410
13734
  object({
13411
13735
  packageName: string().min(1),
13412
- version: string().optional()
13736
+ version: string().optional(),
13737
+ deferRestart: boolean().optional()
13413
13738
  }),
13414
13739
  UpdateFrameworkPackageResultSchema,
13415
13740
  { kind: "mutation", auth: "admin" }
13416
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
+ ),
13417
13800
  getVersions: method(
13418
13801
  object({ name: string() }),
13419
13802
  array(PackageVersionInfoSchema).readonly()
@@ -13541,6 +13924,7 @@ var EventCategory = /* @__PURE__ */ ((EventCategory2) => {
13541
13924
  EventCategory2["StreamBrokerOnCamStreamDemand"] = "stream-broker.onCamStreamDemand";
13542
13925
  EventCategory2["StreamBrokerOnCamStreamIdle"] = "stream-broker.onCamStreamIdle";
13543
13926
  EventCategory2["StreamBrokerOnRequestStreamSourceRefresh"] = "stream-broker.onRequestStreamSourceRefresh";
13927
+ EventCategory2["StreamParamsChanged"] = "stream-params.changed";
13544
13928
  EventCategory2["DeviceStateChanged"] = "device.state-changed";
13545
13929
  EventCategory2["BatteryOnStatusChanged"] = "battery.onStatusChanged";
13546
13930
  EventCategory2["DoorbellOnPressed"] = "doorbell.onPressed";
@@ -13588,6 +13972,7 @@ var EventCategory = /* @__PURE__ */ ((EventCategory2) => {
13588
13972
  EventCategory2["NetworkTunnelStarted"] = "network.tunnel.started";
13589
13973
  EventCategory2["NetworkTunnelStopped"] = "network.tunnel.stopped";
13590
13974
  EventCategory2["LocalNetworkChanged"] = "network.local.changed";
13975
+ EventCategory2["MeshNetworkChanged"] = "network.mesh.changed";
13591
13976
  EventCategory2["BackupCompleted"] = "backup.completed";
13592
13977
  EventCategory2["BackupRestored"] = "backup.restored";
13593
13978
  EventCategory2["NotificationDispatched"] = "notification.dispatched";
@@ -13598,6 +13983,7 @@ var EventCategory = /* @__PURE__ */ ((EventCategory2) => {
13598
13983
  EventCategory2["DeviceAwake"] = "device.awake";
13599
13984
  EventCategory2["DeviceSleeping"] = "device.sleeping";
13600
13985
  EventCategory2["RetentionCleanup"] = "retention.cleanup";
13986
+ EventCategory2["AddonsBulkUpdateProgress"] = "addons.bulk-update-progress";
13601
13987
  return EventCategory2;
13602
13988
  })(EventCategory || {});
13603
13989
  function isEvent(event2, category) {
@@ -13750,7 +14136,7 @@ class BaseAddon {
13750
14136
  }
13751
14137
  // ── Settings schemas (override to provide UI) ─────────────────────────
13752
14138
  /** Override to provide global-level settings UI schema. */
13753
- globalSettingsSchema() {
14139
+ globalSettingsSchema(_cap) {
13754
14140
  return null;
13755
14141
  }
13756
14142
  /** Override to provide device-level settings UI schema. */
@@ -13764,8 +14150,8 @@ class BaseAddon {
13764
14150
  // blob and every addon used exactly one of them; the distinction was
13765
14151
  // never semantically load-bearing. `global` won because it was the
13766
14152
  // widely-used one and the name reads naturally (per-node addon config).
13767
- async getGlobalSettings(overlay) {
13768
- const schema = this.globalSettingsSchema();
14153
+ async getGlobalSettings(overlay, cap) {
14154
+ const schema = this.globalSettingsSchema(cap);
13769
14155
  if (!schema) return { sections: [] };
13770
14156
  const raw = await this._ctx?.settings?.readAddonStore() ?? {};
13771
14157
  return hydrateSchema(schema, overlay ? { ...raw, ...overlay } : raw);
@@ -14184,6 +14570,14 @@ class ReadinessRegistry {
14184
14570
  cleanup();
14185
14571
  reject(new ReadinessTimeoutError(capName, scope, this.now() - start));
14186
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();
14187
14581
  const onAbort = () => {
14188
14582
  if (settled) return;
14189
14583
  settled = true;
@@ -14194,6 +14588,7 @@ class ReadinessRegistry {
14194
14588
  function cleanup() {
14195
14589
  unsubscribe();
14196
14590
  if (timer !== null) clearTimeout(timer);
14591
+ clearInterval(pendingLogTimer);
14197
14592
  opts.signal?.removeEventListener("abort", onAbort);
14198
14593
  }
14199
14594
  });
@@ -14747,6 +15142,8 @@ function balanceAudio(input) {
14747
15142
  }
14748
15143
  const POLL_INTERVAL_MS = 200;
14749
15144
  const PULL_MAX_COUNT = 8;
15145
+ const RESUBSCRIBE_AFTER_FAILURES = 2;
15146
+ const RESUBSCRIBE_THROTTLE_TICKS = 5;
14750
15147
  async function startAudioChunkPoller(options) {
14751
15148
  const { api, brokerId, tag, onChunk, logger } = options;
14752
15149
  let subscriptionId;
@@ -14764,6 +15161,19 @@ async function startAudioChunkPoller(options) {
14764
15161
  }
14765
15162
  let stopped = false;
14766
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
+ };
14767
15177
  const tick = async () => {
14768
15178
  if (stopped) return;
14769
15179
  try {
@@ -14771,14 +15181,24 @@ async function startAudioChunkPoller(options) {
14771
15181
  subscriptionId,
14772
15182
  maxCount: PULL_MAX_COUNT
14773
15183
  });
15184
+ if (consecutiveFailures > 0) {
15185
+ logger.info("audio-chunk poller: stream recovered", { meta: { brokerId, subscriptionId } });
15186
+ }
15187
+ consecutiveFailures = 0;
14774
15188
  for (const chunk of chunks) {
14775
15189
  if (stopped) break;
14776
15190
  await onChunk(chunk);
14777
15191
  }
14778
15192
  } catch (err) {
14779
- logger.warn("audio-chunk poller: pullAudioChunks failed", {
14780
- meta: { brokerId, subscriptionId, error: errMsg(err) }
14781
- });
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
+ }
14782
15202
  }
14783
15203
  if (!stopped) {
14784
15204
  timer = setTimeout(() => void tick(), POLL_INTERVAL_MS);
@@ -14799,6 +15219,18 @@ async function startAudioChunkPoller(options) {
14799
15219
  });
14800
15220
  };
14801
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
+ }
14802
15234
  const PHASE_MODE_VALUES = /* @__PURE__ */ new Set([
14803
15235
  "disabled",
14804
15236
  "always-on",
@@ -14810,6 +15242,7 @@ function isPipelinePhaseMode(v) {
14810
15242
  const PREFERRED_AGENT_SETTING = "preferredAgent";
14811
15243
  const AUDIO_NODE_SETTING = "audioNodeId";
14812
15244
  const DEFAULT_BROKER_CALL_TIMEOUT_MS = 5e3;
15245
+ const RECONCILE_DEBOUNCE_MS = 200;
14813
15246
  const AGENT_SETTINGS_KEY = "agentSettings";
14814
15247
  const CAMERA_SETTINGS_KEY = "cameraSettings";
14815
15248
  const TEMPLATES_KEY = "templates";
@@ -14961,6 +15394,12 @@ class PipelineOrchestratorAddon extends BaseAddon {
14961
15394
  loadShedState = /* @__PURE__ */ new Map();
14962
15395
  /** Timer for the auto-resume sweep. */
14963
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;
14964
15403
  initTimestamp = 0;
14965
15404
  constructor() {
14966
15405
  super({});
@@ -15019,6 +15458,7 @@ class PipelineOrchestratorAddon extends BaseAddon {
15019
15458
  meta: { error: msg }
15020
15459
  });
15021
15460
  });
15461
+ this.scheduleReconcile();
15022
15462
  }
15023
15463
  );
15024
15464
  this.watchCapability(["pipeline-executor", "analysis-pipeline"], {
@@ -15116,6 +15556,7 @@ class PipelineOrchestratorAddon extends BaseAddon {
15116
15556
  });
15117
15557
  }, PROFILE_SLOTS_DEBOUNCE_MS)
15118
15558
  );
15559
+ this.scheduleReconcile();
15119
15560
  }
15120
15561
  );
15121
15562
  this.unsubDeviceUnregistered = this.ctx.eventBus.subscribe(
@@ -15295,9 +15736,15 @@ class PipelineOrchestratorAddon extends BaseAddon {
15295
15736
  }
15296
15737
  ]
15297
15738
  };
15739
+ this.scheduleReconcile();
15298
15740
  return {
15299
15741
  providers: [
15300
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 },
15301
15748
  { capability: zonesCapability, provider: this.zonesProvider },
15302
15749
  { capability: zoneRulesCapability, provider: this.zoneRulesProvider },
15303
15750
  { capability: addonWidgetsSourceCapability, provider: widgetsProvider }
@@ -15422,6 +15869,10 @@ class PipelineOrchestratorAddon extends BaseAddon {
15422
15869
  this.profileSlotTimers.clear();
15423
15870
  this.profileSlotTimers = null;
15424
15871
  }
15872
+ if (this.reconcileTimer !== null) {
15873
+ clearTimeout(this.reconcileTimer);
15874
+ this.reconcileTimer = null;
15875
+ }
15425
15876
  this.unsubDeviceRegistered?.();
15426
15877
  this.unsubDeviceRegistered = null;
15427
15878
  this.unsubDeviceUnregistered?.();
@@ -15497,7 +15948,7 @@ class PipelineOrchestratorAddon extends BaseAddon {
15497
15948
  );
15498
15949
  this.cameraConfigs.set(runnerConfig.deviceId, runnerConfig);
15499
15950
  const cfg = this.globalSettings ?? {};
15500
- const minThreshold = typeof cfg.fpsMinThreshold === "number" ? cfg.fpsMinThreshold : 3;
15951
+ const minThreshold = typeof cfg.fpsMinThreshold === "number" ? cfg.fpsMinThreshold : PipelineOrchestratorAddon.DEFAULT_FPS_MIN_THRESHOLD;
15501
15952
  const windowMs = typeof cfg.fpsLowWindowMs === "number" && cfg.fpsLowWindowMs > 0 ? cfg.fpsLowWindowMs : PipelineOrchestratorAddon.DEFAULT_FPS_LOW_WINDOW_MS;
15502
15953
  if (minThreshold > 0 && this.initTimestamp && Date.now() - this.initTimestamp > 3e4) {
15503
15954
  const now = Date.now();
@@ -15940,7 +16391,7 @@ class PipelineOrchestratorAddon extends BaseAddon {
15940
16391
  const api = ctx?.api;
15941
16392
  if (!api) continue;
15942
16393
  try {
15943
- const load = await api.pipelineRunner.getLocalLoad.query({ nodeId });
16394
+ const load = await this.queryLocalLoadBounded(nodeId);
15944
16395
  loads.push(load);
15945
16396
  } catch (err) {
15946
16397
  const msg = errMsg(err);
@@ -15965,6 +16416,53 @@ class PipelineOrchestratorAddon extends BaseAddon {
15965
16416
  }
15966
16417
  this.cachedAgentLoad = next;
15967
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
+ }
15968
16466
  async readPreferredAgent(deviceId) {
15969
16467
  const ctx = this.ctx;
15970
16468
  if (!ctx?.settings) return null;
@@ -16262,6 +16760,44 @@ class PipelineOrchestratorAddon extends BaseAddon {
16262
16760
  "audio-analyzer ready — node now available for audio dispatch",
16263
16761
  { tags: { nodeId }, meta: { epoch: t.epoch } }
16264
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
+ }
16265
16801
  }
16266
16802
  /**
16267
16803
  * Act on a `detection-pipeline` transition: on `ready` with a new
@@ -16333,6 +16869,7 @@ class PipelineOrchestratorAddon extends BaseAddon {
16333
16869
  });
16334
16870
  });
16335
16871
  }, 3e3);
16872
+ this.scheduleReconcile();
16336
16873
  } catch (err) {
16337
16874
  this.ctx.logger.debug("readiness seed+redispatch failed", {
16338
16875
  tags: { nodeId },
@@ -16377,6 +16914,75 @@ class PipelineOrchestratorAddon extends BaseAddon {
16377
16914
  }
16378
16915
  }
16379
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
+ }
16380
16986
  // ── Capability bindings (cap methods) ────────────────────────────────
16381
16987
  async getCapabilityBindings(input) {
16382
16988
  const ctx = this.ctx;
@@ -17158,11 +17764,11 @@ class PipelineOrchestratorAddon extends BaseAddon {
17158
17764
  key: "fpsMinThreshold",
17159
17765
  type: "slider",
17160
17766
  label: "Min FPS Threshold",
17161
- 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.",
17162
17768
  min: 0,
17163
17769
  max: 10,
17164
17770
  step: 1,
17165
- default: 3,
17771
+ default: PipelineOrchestratorAddon.DEFAULT_FPS_MIN_THRESHOLD,
17166
17772
  showValue: true,
17167
17773
  unit: "fps"
17168
17774
  },
@@ -17459,8 +18065,41 @@ class PipelineOrchestratorAddon extends BaseAddon {
17459
18065
  }
17460
18066
  ]
17461
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
+ }
17462
18101
  return {
17463
- sections: [...baseSections, zonesSection]
18102
+ sections: [...baseSections, zonesSection, ...nativeObjectDetectionSections]
17464
18103
  };
17465
18104
  }
17466
18105
  // `getCameraPipelineWithFallback` was a thin wrapper over
@@ -17521,11 +18160,25 @@ class PipelineOrchestratorAddon extends BaseAddon {
17521
18160
  }
17522
18161
  async applyDeviceSettingsPatch(input) {
17523
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
+ }
17524
18177
  if (Object.keys(pipelinePatch).length > 0) {
17525
18178
  await this.applyPipelinePatch(input.deviceId, pipelinePatch);
17526
18179
  }
17527
- if (Object.keys(rest).length > 0) {
17528
- await this.writeDeviceOrchestratorSettings(input.deviceId, rest);
18180
+ if (Object.keys(orchestratorRest).length > 0) {
18181
+ await this.writeDeviceOrchestratorSettings(input.deviceId, orchestratorRest);
17529
18182
  }
17530
18183
  return { success: true };
17531
18184
  }
@@ -17700,6 +18353,23 @@ class PipelineOrchestratorAddon extends BaseAddon {
17700
18353
  return false;
17701
18354
  }
17702
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
+ }
17703
18373
  async resolveDeviceDetectionSettings(deviceId) {
17704
18374
  const ctx = this.ctx;
17705
18375
  if (!ctx?.settings) {
@@ -17764,6 +18434,8 @@ class PipelineOrchestratorAddon extends BaseAddon {
17764
18434
  const detectionMode = typeof userDetectionMode === "string" && isPipelinePhaseMode(userDetectionMode) ? userDetectionMode : profile?.defaults.detectionMode ?? "on-motion";
17765
18435
  const userAudioMode = raw["audioMode"];
17766
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;
17767
18439
  return {
17768
18440
  motionDetectionEnabled,
17769
18441
  pipelineEnabled,
@@ -17774,7 +18446,8 @@ class PipelineOrchestratorAddon extends BaseAddon {
17774
18446
  detectionFps: mustNumber("detectionFps"),
17775
18447
  motionCooldownMs: mustNumber("motionCooldownMs"),
17776
18448
  detectionMode,
17777
- audioMode
18449
+ audioMode,
18450
+ onboardMotionDrivesAnalyzer
17778
18451
  };
17779
18452
  } catch (err) {
17780
18453
  const msg = errMsg(err);
@@ -17882,7 +18555,8 @@ class PipelineOrchestratorAddon extends BaseAddon {
17882
18555
  motionCooldownMs: resolved.motionCooldownMs,
17883
18556
  audioStreamId: void 0,
17884
18557
  detectionMode: resolved.detectionMode,
17885
- audioMode: resolved.audioMode
18558
+ audioMode: resolved.audioMode,
18559
+ onboardMotionDrivesAnalyzer: resolved.onboardMotionDrivesAnalyzer
17886
18560
  };
17887
18561
  }
17888
18562
  /**
@@ -17917,7 +18591,8 @@ class PipelineOrchestratorAddon extends BaseAddon {
17917
18591
  audio: pipelineConfig.audio ?? null,
17918
18592
  zones,
17919
18593
  detectionMode: config2.detectionMode,
17920
- audioMode: config2.audioMode
18594
+ audioMode: config2.audioMode,
18595
+ onboardMotionDrivesAnalyzer: config2.onboardMotionDrivesAnalyzer
17921
18596
  };
17922
18597
  let dispatchedNodeId = null;
17923
18598
  try {
@@ -18102,6 +18777,7 @@ class PipelineOrchestratorAddon extends BaseAddon {
18102
18777
  if (a.detectionFps !== b.detectionFps) return false;
18103
18778
  if (a.motionCooldownMs !== b.motionCooldownMs) return false;
18104
18779
  if (a.audioStreamId !== b.audioStreamId) return false;
18780
+ if (a.onboardMotionDrivesAnalyzer !== b.onboardMotionDrivesAnalyzer) return false;
18105
18781
  if (a.motionSources.length !== b.motionSources.length) return false;
18106
18782
  for (const s of a.motionSources) if (!b.motionSources.includes(s)) return false;
18107
18783
  return true;
@@ -18191,9 +18867,20 @@ class PipelineOrchestratorAddon extends BaseAddon {
18191
18867
  * across recoveries.
18192
18868
  */
18193
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;
18194
18881
  enforceLoadManagement(deviceId, metrics) {
18195
18882
  const cfg = this.globalSettings ?? {};
18196
- const threshold = typeof cfg.fpsMinThreshold === "number" ? cfg.fpsMinThreshold : 3;
18883
+ const threshold = typeof cfg.fpsMinThreshold === "number" ? cfg.fpsMinThreshold : PipelineOrchestratorAddon.DEFAULT_FPS_MIN_THRESHOLD;
18197
18884
  const windowMs = typeof cfg.fpsLowWindowMs === "number" && cfg.fpsLowWindowMs > 0 ? cfg.fpsLowWindowMs : PipelineOrchestratorAddon.DEFAULT_FPS_LOW_WINDOW_MS;
18198
18885
  if (threshold <= 0) return;
18199
18886
  if (metrics.phase !== "active") return;
@@ -18313,7 +19000,7 @@ class PipelineOrchestratorAddon extends BaseAddon {
18313
19000
  async handleInferenceResult(payload) {
18314
19001
  const ctx = this.ctx;
18315
19002
  if (!ctx) return;
18316
- const { deviceId, frame, frameHandle } = payload;
19003
+ const { deviceId, frame, frameHandle, capturedAt } = payload;
18317
19004
  if (frame.detections.length === 0) return;
18318
19005
  this.ctx.eventBus.emit({
18319
19006
  id: `detection-${deviceId}-${Date.now()}`,
@@ -18321,8 +19008,10 @@ class PipelineOrchestratorAddon extends BaseAddon {
18321
19008
  source: { type: "device", id: deviceId, nodeId: "hub", addonId: "pipeline-orchestrator", deviceId },
18322
19009
  timestamp: /* @__PURE__ */ new Date(),
18323
19010
  // Forward the upstream shm-ring `frameHandle` so post-analysis
18324
- // consumers (Task 8) can resolve the original frame zero-copy.
18325
- 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 } : {} }
18326
19015
  });
18327
19016
  }
18328
19017
  /**
@@ -18448,8 +19137,8 @@ class PipelineOrchestratorAddon extends BaseAddon {
18448
19137
  }
18449
19138
  try {
18450
19139
  const byteLength = chunk.data.byteLength;
18451
- const ab = new ArrayBuffer(byteLength);
18452
- new Uint8Array(ab).set(
19140
+ const data = new Uint8Array(byteLength);
19141
+ data.set(
18453
19142
  new Uint8Array(
18454
19143
  chunk.data.buffer,
18455
19144
  chunk.data.byteOffset,
@@ -18457,7 +19146,7 @@ class PipelineOrchestratorAddon extends BaseAddon {
18457
19146
  )
18458
19147
  );
18459
19148
  const audioChunkInput = {
18460
- data: new Float32Array(ab, 0, byteLength / 4),
19149
+ data,
18461
19150
  sampleRate: chunk.sampleRate,
18462
19151
  channels: chunk.channels,
18463
19152
  timestamp: chunk.timestamp,