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