@camstack/ui-library 1.2.175 → 1.2.176

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.
@@ -21,6 +21,18 @@ export interface GridLayoutCell {
21
21
  /** Where it lands, normalized against the CANVAS. */
22
22
  readonly cell: GridNormalizedRect;
23
23
  }
24
+ /**
25
+ * Which profiles this grid can compose, and which sources stop it.
26
+ *
27
+ * Read-only in the editor. The operator does not choose what is on offer — the
28
+ * source cameras do, by what they have assigned — and a checkbox that pretended
29
+ * otherwise would publish a composition with a hole in it.
30
+ */
31
+ export interface GridProfileOffer {
32
+ readonly profile: 'high' | 'mid' | 'low';
33
+ readonly offered: boolean;
34
+ readonly missingSources: readonly number[];
35
+ }
24
36
  export interface GridLayoutView {
25
37
  readonly instanceId: string;
26
38
  readonly deviceId: number;
@@ -29,6 +41,7 @@ export interface GridLayoutView {
29
41
  readonly height: number;
30
42
  readonly fps: number;
31
43
  readonly cells: readonly GridLayoutCell[];
44
+ readonly profiles: readonly GridProfileOffer[];
32
45
  }
33
46
  /**
34
47
  * What a read of the grid can be.
@@ -39,7 +52,14 @@ export interface GridLayoutView {
39
52
  * which is exactly the failure D315 exists to stop.
40
53
  */
41
54
  export type GridLayoutReadOutcome = GridLayoutView | null | 'unreadable';
42
- /** Parse the `getGridForDevice` reply. See {@link GridLayoutReadOutcome}. */
55
+ /**
56
+ * Parse the `camera-grid-layout.getLayout` reply. See {@link GridLayoutReadOutcome}.
57
+ *
58
+ * The cap answers the view itself, or `null` — there is no envelope. It used to
59
+ * come back as `{ grid }` from `addons.custom`, which was the mechanism the
60
+ * widget reached the addon through before the geometry had a capability of its
61
+ * own; the widget now asks THE CAMERA and the envelope went with the bridge.
62
+ */
43
63
  export declare function parseGridView(raw: unknown): GridLayoutReadOutcome;
44
64
  /**
45
65
  * Keep a rectangle inside the unit square.
package/dist/index.cjs CHANGED
@@ -16261,6 +16261,136 @@ function AutotrackSection({ deviceId }) {
16261
16261
  });
16262
16262
  }
16263
16263
  //#endregion
16264
+ //#region src/hooks/use-device-proxy.ts
16265
+ /**
16266
+ * useDeviceProxy — canonical React entry point for a device.
16267
+ *
16268
+ * Symmetric with the server's `ctx.fetchDevice(id)` and the SDK's
16269
+ * `BackendClient.fetchDevice(id)` — same `DeviceProxy` shape, same
16270
+ * routing semantics (cap-method calls go through the wrapper chain;
16271
+ * `state.<cap>.value` reads the kernel's mirrored snapshot, never
16272
+ * the provider).
16273
+ *
16274
+ * What you can do with the returned proxy:
16275
+ *
16276
+ * const dev = useDeviceProxy(trpc, deviceId)
16277
+ *
16278
+ * // Reactive runtime state — re-renders when the slice changes.
16279
+ * const battery = useDeviceStateSlice(dev?.state.battery)
16280
+ *
16281
+ * // Imperative cap-method calls — through the wrapper chain.
16282
+ * await dev?.snapshot?.getSnapshot({ streamId: 'high' })
16283
+ * await dev?.ptz?.move({ direction: 'left', steps: 1 })
16284
+ *
16285
+ * Lifecycle: the hook resolves bindings via `deviceManager.getBindings`
16286
+ * the first time `deviceId` becomes valid, and refreshes them on
16287
+ * `capability.binding-changed` events for that device. Returns `null`
16288
+ * until the first resolution completes (the proxy is not constructible
16289
+ * without a binding).
16290
+ *
16291
+ * Works in both admin-ui (`BackendClient.trpc`) and addon pages
16292
+ * (`AddonPageProps.trpc`) — parametrized by a minimal vanilla tRPC
16293
+ * proxy shape (matches both at runtime).
16294
+ */
16295
+ var BINDING_CHANGED_CATEGORY = "capability.binding-changed";
16296
+ /**
16297
+ * @returns the proxy, or `null` while bindings are still loading or
16298
+ * when `deviceId` is null. Re-rendered when bindings change.
16299
+ */
16300
+ function useDeviceProxy(trpc, deviceId) {
16301
+ const [binding, setBinding] = (0, react$1.useState)(null);
16302
+ (0, react$1.useEffect)(() => {
16303
+ if (deviceId === null) {
16304
+ setBinding(null);
16305
+ return;
16306
+ }
16307
+ let cancelled = false;
16308
+ const refresh = async () => {
16309
+ try {
16310
+ const next = await trpc.deviceManager.getBindings.query({ deviceId });
16311
+ if (!cancelled) setBinding(next);
16312
+ } catch {
16313
+ if (!cancelled) setBinding((prev) => prev);
16314
+ }
16315
+ };
16316
+ refresh();
16317
+ const sub = trpc.live?.onEvent.subscribe({ category: BINDING_CHANGED_CATEGORY }, { onData: (evt) => {
16318
+ const src = evt.data?.source;
16319
+ if (!src) {
16320
+ refresh();
16321
+ return;
16322
+ }
16323
+ if (src.type === "device" && src.id === deviceId) refresh();
16324
+ } });
16325
+ return () => {
16326
+ cancelled = true;
16327
+ sub?.unsubscribe();
16328
+ };
16329
+ }, [trpc, deviceId]);
16330
+ return (0, react$1.useMemo)(() => {
16331
+ if (!binding) return null;
16332
+ return (0, _camstack_types.createDeviceProxy)(trpc, binding);
16333
+ }, [trpc, binding]);
16334
+ }
16335
+ /**
16336
+ * Companion hook — reads a `SliceHandle` reactively, returning the
16337
+ * current value and re-rendering on every push from the kernel
16338
+ * mirror. Pass `dev?.state.battery` etc. as the handle.
16339
+ *
16340
+ * const dev = useDeviceProxy(trpc, deviceId)
16341
+ * const battery = useDeviceStateSlice(dev?.state.battery)
16342
+ *
16343
+ * Why a hook (and not just `dev.state.battery.subscribe(...)` inline)?
16344
+ * React doesn't observe external mutations on its own — a bare
16345
+ * subscribe call inside a render would fire on every render (memory
16346
+ * leak), wouldn't trigger a re-render on slice change, and wouldn't
16347
+ * clean up at unmount. The hook bridges the imperative
16348
+ * `(value, subscribe)` pair into React's declarative lifecycle via
16349
+ * `useSyncExternalStore` (the canonical React 18 primitive for
16350
+ * external stores — handles tearing in concurrent rendering and
16351
+ * de-dups identical pushes).
16352
+ */
16353
+ function useDeviceStateSlice(handle) {
16354
+ const subscribe = (0, react$1.useCallback)((notify) => {
16355
+ if (!handle) return () => void 0;
16356
+ return handle.subscribe(() => notify());
16357
+ }, [handle]);
16358
+ const getSnapshot = (0, react$1.useCallback)(() => handle?.value, [handle]);
16359
+ return (0, react$1.useSyncExternalStore)(subscribe, getSnapshot, getSnapshot);
16360
+ }
16361
+ /**
16362
+ * One-line combinator — collapse the common
16363
+ * `useDeviceProxy + useDeviceStateSlice(dev?.state.<cap>)` pair into a
16364
+ * single hook. The selector receives the live `DeviceProxy['state']`
16365
+ * bag (typed against the codegen, so `s.battery` etc. autocomplete)
16366
+ * and returns one slice handle. Returns the slice value, undefined
16367
+ * while the binding resolves OR when the slice has no cached push yet.
16368
+ *
16369
+ * Idiomatic call-site:
16370
+ *
16371
+ * const battery = useDeviceState(trpc, deviceId, (s) => s.battery)
16372
+ * const zones = useDeviceState(trpc, deviceId, (s) => s.zones)
16373
+ *
16374
+ * vs. the manual pair:
16375
+ *
16376
+ * const dev = useDeviceProxy(trpc, deviceId)
16377
+ * const battery = useDeviceStateSlice(dev?.state.battery)
16378
+ * const zones = useDeviceStateSlice(dev?.state.zones)
16379
+ *
16380
+ * Use the manual pair when:
16381
+ * - you also need the proxy's cap method bag (`dev.zones?.addZone(...)`)
16382
+ * - you want to share the proxy across multiple slice reads with a
16383
+ * single binding fetch (the combinator calls useDeviceProxy
16384
+ * internally, which dedups bindings via React Query in practice
16385
+ * but spawns one extra subscription per call site)
16386
+ *
16387
+ * Use the combinator when reading just one slice — most common case.
16388
+ */
16389
+ function useDeviceState(trpc, deviceId, select) {
16390
+ const dev = useDeviceProxy(trpc, deviceId);
16391
+ return useDeviceStateSlice(dev ? select(dev.state) : void 0);
16392
+ }
16393
+ //#endregion
16264
16394
  //#region src/hooks/use-devices.ts
16265
16395
  /**
16266
16396
  * useDevices / useDevice — reactive sync-mirror reads.
@@ -16446,20 +16576,49 @@ function readCell(value) {
16446
16576
  cell
16447
16577
  };
16448
16578
  }
16449
- /** Parse the `getGridForDevice` reply. See {@link GridLayoutReadOutcome}. */
16579
+ function readProfileOffer(value) {
16580
+ if (!isRecord(value)) return null;
16581
+ const profile = value["profile"];
16582
+ const offered = value["offered"];
16583
+ const missing = value["missingSources"];
16584
+ if (profile !== "high" && profile !== "mid" && profile !== "low") return null;
16585
+ if (typeof offered !== "boolean" || !Array.isArray(missing)) return null;
16586
+ const missingSources = [];
16587
+ for (const entry of missing) {
16588
+ if (typeof entry !== "number" || !Number.isInteger(entry)) return null;
16589
+ missingSources.push(entry);
16590
+ }
16591
+ return {
16592
+ profile,
16593
+ offered,
16594
+ missingSources
16595
+ };
16596
+ }
16597
+ /**
16598
+ * Parse the `camera-grid-layout.getLayout` reply. See {@link GridLayoutReadOutcome}.
16599
+ *
16600
+ * The cap answers the view itself, or `null` — there is no envelope. It used to
16601
+ * come back as `{ grid }` from `addons.custom`, which was the mechanism the
16602
+ * widget reached the addon through before the geometry had a capability of its
16603
+ * own; the widget now asks THE CAMERA and the envelope went with the bridge.
16604
+ */
16450
16605
  function parseGridView(raw) {
16451
- if (!isRecord(raw) || !("grid" in raw)) return "unreadable";
16452
- const grid = raw["grid"];
16453
- if (grid === null) return null;
16454
- if (!isRecord(grid)) return "unreadable";
16455
- const { instanceId, deviceId, name, width, height, fps, cells } = grid;
16456
- if (typeof instanceId !== "string" || typeof deviceId !== "number" || typeof name !== "string" || typeof width !== "number" || typeof height !== "number" || typeof fps !== "number" || !Array.isArray(cells)) return "unreadable";
16606
+ if (raw === null) return null;
16607
+ if (!isRecord(raw)) return "unreadable";
16608
+ const { instanceId, deviceId, name, width, height, fps, cells, profiles } = raw;
16609
+ if (typeof instanceId !== "string" || typeof deviceId !== "number" || typeof name !== "string" || typeof width !== "number" || typeof height !== "number" || typeof fps !== "number" || !Array.isArray(cells) || !Array.isArray(profiles)) return "unreadable";
16457
16610
  const parsed = [];
16458
16611
  for (const entry of cells) {
16459
16612
  const cell = readCell(entry);
16460
16613
  if (cell === null) return "unreadable";
16461
16614
  parsed.push(cell);
16462
16615
  }
16616
+ const offers = [];
16617
+ for (const entry of profiles) {
16618
+ const offer = readProfileOffer(entry);
16619
+ if (offer === null) return "unreadable";
16620
+ offers.push(offer);
16621
+ }
16463
16622
  return {
16464
16623
  instanceId,
16465
16624
  deviceId,
@@ -16467,7 +16626,8 @@ function parseGridView(raw) {
16467
16626
  width,
16468
16627
  height,
16469
16628
  fps,
16470
- cells: parsed
16629
+ cells: parsed,
16630
+ profiles: offers
16471
16631
  };
16472
16632
  }
16473
16633
  /**
@@ -16525,7 +16685,6 @@ function resizeNormalizedRect(rect, dw, dh) {
16525
16685
  * the server nothing and it is wrong in exactly the ways the real composition
16526
16686
  * would be wrong.
16527
16687
  */
16528
- var ADDON_ID = "camera-grid";
16529
16688
  var FULL_FRAME = {
16530
16689
  x: 0,
16531
16690
  y: 0,
@@ -16544,36 +16703,36 @@ function CameraGridLayoutEditor({ deviceId }) {
16544
16703
  const [saveError, setSaveError] = (0, react$1.useState)(null);
16545
16704
  const [selected, setSelected] = (0, react$1.useState)(0);
16546
16705
  const [drag, setDrag] = (0, react$1.useState)(null);
16706
+ const dev = useDeviceProxy(system.trpcClient, deviceId);
16547
16707
  const reload = (0, react$1.useCallback)(async () => {
16548
- const outcome = parseGridView(await system.trpcClient.addons.custom.mutate({
16549
- addonId: ADDON_ID,
16550
- action: "getGridForDevice",
16551
- input: { deviceId }
16552
- }));
16708
+ const layout = dev?.cameraGridLayout;
16709
+ if (!layout) return;
16710
+ const outcome = parseGridView(await layout.getLayout({}));
16553
16711
  setLoaded(outcome);
16554
16712
  setDraft(outcome === null || outcome === "unreadable" ? null : outcome);
16555
- }, [system, deviceId]);
16713
+ }, [dev]);
16556
16714
  (0, react$1.useEffect)(() => {
16715
+ if (!dev) return;
16716
+ if (!dev.cameraGridLayout) {
16717
+ setLoaded(null);
16718
+ return;
16719
+ }
16557
16720
  reload().catch(() => {
16558
16721
  setLoaded("unreadable");
16559
16722
  });
16560
- }, [reload]);
16723
+ }, [dev, reload]);
16561
16724
  const save = (0, react$1.useCallback)(async () => {
16562
- if (draft === null) return;
16725
+ const layout = dev?.cameraGridLayout;
16726
+ if (draft === null || !layout) return;
16563
16727
  setSaving(true);
16564
16728
  setSaveError(null);
16565
16729
  try {
16566
- const outcome = parseGridView(await system.trpcClient.addons.custom.mutate({
16567
- addonId: ADDON_ID,
16568
- action: "saveGridLayout",
16569
- input: {
16570
- deviceId,
16571
- name: draft.name,
16572
- width: draft.width,
16573
- height: draft.height,
16574
- fps: draft.fps,
16575
- cells: draft.cells.map((cell) => ({ ...cell }))
16576
- }
16730
+ const outcome = parseGridView(await layout.saveLayout({
16731
+ name: draft.name,
16732
+ width: draft.width,
16733
+ height: draft.height,
16734
+ fps: draft.fps,
16735
+ cells: draft.cells.map((cell) => ({ ...cell }))
16577
16736
  }));
16578
16737
  if (outcome !== null && outcome !== "unreadable") {
16579
16738
  setLoaded(outcome);
@@ -16584,11 +16743,7 @@ function CameraGridLayoutEditor({ deviceId }) {
16584
16743
  } finally {
16585
16744
  setSaving(false);
16586
16745
  }
16587
- }, [
16588
- system,
16589
- deviceId,
16590
- draft
16591
- ]);
16746
+ }, [dev, draft]);
16592
16747
  const patchCell = (0, react$1.useCallback)((index, patch) => {
16593
16748
  setDraft((prev) => prev === null ? prev : {
16594
16749
  ...prev,
@@ -16742,6 +16897,10 @@ function CameraGridLayoutEditor({ deviceId }) {
16742
16897
  })
16743
16898
  ]
16744
16899
  }),
16900
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)(GridProfileReport, {
16901
+ offers: loaded.profiles,
16902
+ cameras
16903
+ }),
16745
16904
  draft.cells[selected] !== void 0 && /* @__PURE__ */ (0, react_jsx_runtime.jsx)(CellInspector, {
16746
16905
  cell: draft.cells[selected],
16747
16906
  index: selected,
@@ -16761,6 +16920,37 @@ function CameraGridLayoutEditor({ deviceId }) {
16761
16920
  })
16762
16921
  });
16763
16922
  }
16923
+ /**
16924
+ * The honest report. A grid's `high` composes its sources' `high` and its `low`
16925
+ * their `low`, so a profile is on offer only when EVERY source can serve it —
16926
+ * and the refusal names the source, because "this grid has no low" is not
16927
+ * something an operator can act on and "615 has no low" is.
16928
+ *
16929
+ * Read-only on purpose: what is on offer is decided by the source cameras'
16930
+ * assignments, and a control here would be a switch that writes nothing (D62).
16931
+ */
16932
+ function GridProfileReport({ offers, cameras }) {
16933
+ const nameOf = (deviceId) => cameras.find((camera) => camera.id === deviceId)?.name ?? `#${String(deviceId)}`;
16934
+ return /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
16935
+ className: "flex flex-col gap-1 rounded border border-border p-2 text-[10.5px]",
16936
+ children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
16937
+ className: "uppercase text-foreground-subtle",
16938
+ children: "Published profiles"
16939
+ }), offers.map((offer) => /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
16940
+ className: "flex items-baseline gap-2",
16941
+ children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
16942
+ className: "w-10 uppercase",
16943
+ children: offer.profile
16944
+ }), offer.offered ? /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("span", {
16945
+ className: "text-foreground-subtle",
16946
+ children: ["composed from every source’s ", offer.profile]
16947
+ }) : /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
16948
+ className: "text-danger",
16949
+ children: offer.missingSources.length === 0 ? "not offered — this grid has no cells yet" : `not offered — no ${offer.profile} on ${offer.missingSources.map(nameOf).join(", ")}`
16950
+ })]
16951
+ }, offer.profile))]
16952
+ });
16953
+ }
16764
16954
  function GridCellBox({ cell, selected, onSelect, onDragStart }) {
16765
16955
  const { src } = useDeviceSnapshotImage(cell.deviceId, { width: 640 });
16766
16956
  const backgroundSize = `${String(100 / cell.source.width)}% ${String(100 / cell.source.height)}%`;
@@ -16866,136 +17056,6 @@ function LabelledNumber({ label, value, step = 1, onChange }) {
16866
17056
  });
16867
17057
  }
16868
17058
  //#endregion
16869
- //#region src/hooks/use-device-proxy.ts
16870
- /**
16871
- * useDeviceProxy — canonical React entry point for a device.
16872
- *
16873
- * Symmetric with the server's `ctx.fetchDevice(id)` and the SDK's
16874
- * `BackendClient.fetchDevice(id)` — same `DeviceProxy` shape, same
16875
- * routing semantics (cap-method calls go through the wrapper chain;
16876
- * `state.<cap>.value` reads the kernel's mirrored snapshot, never
16877
- * the provider).
16878
- *
16879
- * What you can do with the returned proxy:
16880
- *
16881
- * const dev = useDeviceProxy(trpc, deviceId)
16882
- *
16883
- * // Reactive runtime state — re-renders when the slice changes.
16884
- * const battery = useDeviceStateSlice(dev?.state.battery)
16885
- *
16886
- * // Imperative cap-method calls — through the wrapper chain.
16887
- * await dev?.snapshot?.getSnapshot({ streamId: 'high' })
16888
- * await dev?.ptz?.move({ direction: 'left', steps: 1 })
16889
- *
16890
- * Lifecycle: the hook resolves bindings via `deviceManager.getBindings`
16891
- * the first time `deviceId` becomes valid, and refreshes them on
16892
- * `capability.binding-changed` events for that device. Returns `null`
16893
- * until the first resolution completes (the proxy is not constructible
16894
- * without a binding).
16895
- *
16896
- * Works in both admin-ui (`BackendClient.trpc`) and addon pages
16897
- * (`AddonPageProps.trpc`) — parametrized by a minimal vanilla tRPC
16898
- * proxy shape (matches both at runtime).
16899
- */
16900
- var BINDING_CHANGED_CATEGORY = "capability.binding-changed";
16901
- /**
16902
- * @returns the proxy, or `null` while bindings are still loading or
16903
- * when `deviceId` is null. Re-rendered when bindings change.
16904
- */
16905
- function useDeviceProxy(trpc, deviceId) {
16906
- const [binding, setBinding] = (0, react$1.useState)(null);
16907
- (0, react$1.useEffect)(() => {
16908
- if (deviceId === null) {
16909
- setBinding(null);
16910
- return;
16911
- }
16912
- let cancelled = false;
16913
- const refresh = async () => {
16914
- try {
16915
- const next = await trpc.deviceManager.getBindings.query({ deviceId });
16916
- if (!cancelled) setBinding(next);
16917
- } catch {
16918
- if (!cancelled) setBinding((prev) => prev);
16919
- }
16920
- };
16921
- refresh();
16922
- const sub = trpc.live?.onEvent.subscribe({ category: BINDING_CHANGED_CATEGORY }, { onData: (evt) => {
16923
- const src = evt.data?.source;
16924
- if (!src) {
16925
- refresh();
16926
- return;
16927
- }
16928
- if (src.type === "device" && src.id === deviceId) refresh();
16929
- } });
16930
- return () => {
16931
- cancelled = true;
16932
- sub?.unsubscribe();
16933
- };
16934
- }, [trpc, deviceId]);
16935
- return (0, react$1.useMemo)(() => {
16936
- if (!binding) return null;
16937
- return (0, _camstack_types.createDeviceProxy)(trpc, binding);
16938
- }, [trpc, binding]);
16939
- }
16940
- /**
16941
- * Companion hook — reads a `SliceHandle` reactively, returning the
16942
- * current value and re-rendering on every push from the kernel
16943
- * mirror. Pass `dev?.state.battery` etc. as the handle.
16944
- *
16945
- * const dev = useDeviceProxy(trpc, deviceId)
16946
- * const battery = useDeviceStateSlice(dev?.state.battery)
16947
- *
16948
- * Why a hook (and not just `dev.state.battery.subscribe(...)` inline)?
16949
- * React doesn't observe external mutations on its own — a bare
16950
- * subscribe call inside a render would fire on every render (memory
16951
- * leak), wouldn't trigger a re-render on slice change, and wouldn't
16952
- * clean up at unmount. The hook bridges the imperative
16953
- * `(value, subscribe)` pair into React's declarative lifecycle via
16954
- * `useSyncExternalStore` (the canonical React 18 primitive for
16955
- * external stores — handles tearing in concurrent rendering and
16956
- * de-dups identical pushes).
16957
- */
16958
- function useDeviceStateSlice(handle) {
16959
- const subscribe = (0, react$1.useCallback)((notify) => {
16960
- if (!handle) return () => void 0;
16961
- return handle.subscribe(() => notify());
16962
- }, [handle]);
16963
- const getSnapshot = (0, react$1.useCallback)(() => handle?.value, [handle]);
16964
- return (0, react$1.useSyncExternalStore)(subscribe, getSnapshot, getSnapshot);
16965
- }
16966
- /**
16967
- * One-line combinator — collapse the common
16968
- * `useDeviceProxy + useDeviceStateSlice(dev?.state.<cap>)` pair into a
16969
- * single hook. The selector receives the live `DeviceProxy['state']`
16970
- * bag (typed against the codegen, so `s.battery` etc. autocomplete)
16971
- * and returns one slice handle. Returns the slice value, undefined
16972
- * while the binding resolves OR when the slice has no cached push yet.
16973
- *
16974
- * Idiomatic call-site:
16975
- *
16976
- * const battery = useDeviceState(trpc, deviceId, (s) => s.battery)
16977
- * const zones = useDeviceState(trpc, deviceId, (s) => s.zones)
16978
- *
16979
- * vs. the manual pair:
16980
- *
16981
- * const dev = useDeviceProxy(trpc, deviceId)
16982
- * const battery = useDeviceStateSlice(dev?.state.battery)
16983
- * const zones = useDeviceStateSlice(dev?.state.zones)
16984
- *
16985
- * Use the manual pair when:
16986
- * - you also need the proxy's cap method bag (`dev.zones?.addZone(...)`)
16987
- * - you want to share the proxy across multiple slice reads with a
16988
- * single binding fetch (the combinator calls useDeviceProxy
16989
- * internally, which dedups bindings via React Query in practice
16990
- * but spawns one extra subscription per call site)
16991
- *
16992
- * Use the combinator when reading just one slice — most common case.
16993
- */
16994
- function useDeviceState(trpc, deviceId, select) {
16995
- const dev = useDeviceProxy(trpc, deviceId);
16996
- return useDeviceStateSlice(dev ? select(dev.state) : void 0);
16997
- }
16998
- //#endregion
16999
17059
  //#region src/hooks/use-device-capability.ts
17000
17060
  function useDeviceCapability(options) {
17001
17061
  const dev = useDeviceProxy(options.trpc, options.deviceId);
package/dist/index.js CHANGED
@@ -16237,6 +16237,136 @@ function AutotrackSection({ deviceId }) {
16237
16237
  });
16238
16238
  }
16239
16239
  //#endregion
16240
+ //#region src/hooks/use-device-proxy.ts
16241
+ /**
16242
+ * useDeviceProxy — canonical React entry point for a device.
16243
+ *
16244
+ * Symmetric with the server's `ctx.fetchDevice(id)` and the SDK's
16245
+ * `BackendClient.fetchDevice(id)` — same `DeviceProxy` shape, same
16246
+ * routing semantics (cap-method calls go through the wrapper chain;
16247
+ * `state.<cap>.value` reads the kernel's mirrored snapshot, never
16248
+ * the provider).
16249
+ *
16250
+ * What you can do with the returned proxy:
16251
+ *
16252
+ * const dev = useDeviceProxy(trpc, deviceId)
16253
+ *
16254
+ * // Reactive runtime state — re-renders when the slice changes.
16255
+ * const battery = useDeviceStateSlice(dev?.state.battery)
16256
+ *
16257
+ * // Imperative cap-method calls — through the wrapper chain.
16258
+ * await dev?.snapshot?.getSnapshot({ streamId: 'high' })
16259
+ * await dev?.ptz?.move({ direction: 'left', steps: 1 })
16260
+ *
16261
+ * Lifecycle: the hook resolves bindings via `deviceManager.getBindings`
16262
+ * the first time `deviceId` becomes valid, and refreshes them on
16263
+ * `capability.binding-changed` events for that device. Returns `null`
16264
+ * until the first resolution completes (the proxy is not constructible
16265
+ * without a binding).
16266
+ *
16267
+ * Works in both admin-ui (`BackendClient.trpc`) and addon pages
16268
+ * (`AddonPageProps.trpc`) — parametrized by a minimal vanilla tRPC
16269
+ * proxy shape (matches both at runtime).
16270
+ */
16271
+ var BINDING_CHANGED_CATEGORY = "capability.binding-changed";
16272
+ /**
16273
+ * @returns the proxy, or `null` while bindings are still loading or
16274
+ * when `deviceId` is null. Re-rendered when bindings change.
16275
+ */
16276
+ function useDeviceProxy(trpc, deviceId) {
16277
+ const [binding, setBinding] = useState(null);
16278
+ useEffect(() => {
16279
+ if (deviceId === null) {
16280
+ setBinding(null);
16281
+ return;
16282
+ }
16283
+ let cancelled = false;
16284
+ const refresh = async () => {
16285
+ try {
16286
+ const next = await trpc.deviceManager.getBindings.query({ deviceId });
16287
+ if (!cancelled) setBinding(next);
16288
+ } catch {
16289
+ if (!cancelled) setBinding((prev) => prev);
16290
+ }
16291
+ };
16292
+ refresh();
16293
+ const sub = trpc.live?.onEvent.subscribe({ category: BINDING_CHANGED_CATEGORY }, { onData: (evt) => {
16294
+ const src = evt.data?.source;
16295
+ if (!src) {
16296
+ refresh();
16297
+ return;
16298
+ }
16299
+ if (src.type === "device" && src.id === deviceId) refresh();
16300
+ } });
16301
+ return () => {
16302
+ cancelled = true;
16303
+ sub?.unsubscribe();
16304
+ };
16305
+ }, [trpc, deviceId]);
16306
+ return useMemo(() => {
16307
+ if (!binding) return null;
16308
+ return createDeviceProxy(trpc, binding);
16309
+ }, [trpc, binding]);
16310
+ }
16311
+ /**
16312
+ * Companion hook — reads a `SliceHandle` reactively, returning the
16313
+ * current value and re-rendering on every push from the kernel
16314
+ * mirror. Pass `dev?.state.battery` etc. as the handle.
16315
+ *
16316
+ * const dev = useDeviceProxy(trpc, deviceId)
16317
+ * const battery = useDeviceStateSlice(dev?.state.battery)
16318
+ *
16319
+ * Why a hook (and not just `dev.state.battery.subscribe(...)` inline)?
16320
+ * React doesn't observe external mutations on its own — a bare
16321
+ * subscribe call inside a render would fire on every render (memory
16322
+ * leak), wouldn't trigger a re-render on slice change, and wouldn't
16323
+ * clean up at unmount. The hook bridges the imperative
16324
+ * `(value, subscribe)` pair into React's declarative lifecycle via
16325
+ * `useSyncExternalStore` (the canonical React 18 primitive for
16326
+ * external stores — handles tearing in concurrent rendering and
16327
+ * de-dups identical pushes).
16328
+ */
16329
+ function useDeviceStateSlice(handle) {
16330
+ const subscribe = useCallback((notify) => {
16331
+ if (!handle) return () => void 0;
16332
+ return handle.subscribe(() => notify());
16333
+ }, [handle]);
16334
+ const getSnapshot = useCallback(() => handle?.value, [handle]);
16335
+ return useSyncExternalStore(subscribe, getSnapshot, getSnapshot);
16336
+ }
16337
+ /**
16338
+ * One-line combinator — collapse the common
16339
+ * `useDeviceProxy + useDeviceStateSlice(dev?.state.<cap>)` pair into a
16340
+ * single hook. The selector receives the live `DeviceProxy['state']`
16341
+ * bag (typed against the codegen, so `s.battery` etc. autocomplete)
16342
+ * and returns one slice handle. Returns the slice value, undefined
16343
+ * while the binding resolves OR when the slice has no cached push yet.
16344
+ *
16345
+ * Idiomatic call-site:
16346
+ *
16347
+ * const battery = useDeviceState(trpc, deviceId, (s) => s.battery)
16348
+ * const zones = useDeviceState(trpc, deviceId, (s) => s.zones)
16349
+ *
16350
+ * vs. the manual pair:
16351
+ *
16352
+ * const dev = useDeviceProxy(trpc, deviceId)
16353
+ * const battery = useDeviceStateSlice(dev?.state.battery)
16354
+ * const zones = useDeviceStateSlice(dev?.state.zones)
16355
+ *
16356
+ * Use the manual pair when:
16357
+ * - you also need the proxy's cap method bag (`dev.zones?.addZone(...)`)
16358
+ * - you want to share the proxy across multiple slice reads with a
16359
+ * single binding fetch (the combinator calls useDeviceProxy
16360
+ * internally, which dedups bindings via React Query in practice
16361
+ * but spawns one extra subscription per call site)
16362
+ *
16363
+ * Use the combinator when reading just one slice — most common case.
16364
+ */
16365
+ function useDeviceState(trpc, deviceId, select) {
16366
+ const dev = useDeviceProxy(trpc, deviceId);
16367
+ return useDeviceStateSlice(dev ? select(dev.state) : void 0);
16368
+ }
16369
+ //#endregion
16240
16370
  //#region src/hooks/use-devices.ts
16241
16371
  /**
16242
16372
  * useDevices / useDevice — reactive sync-mirror reads.
@@ -16422,20 +16552,49 @@ function readCell(value) {
16422
16552
  cell
16423
16553
  };
16424
16554
  }
16425
- /** Parse the `getGridForDevice` reply. See {@link GridLayoutReadOutcome}. */
16555
+ function readProfileOffer(value) {
16556
+ if (!isRecord(value)) return null;
16557
+ const profile = value["profile"];
16558
+ const offered = value["offered"];
16559
+ const missing = value["missingSources"];
16560
+ if (profile !== "high" && profile !== "mid" && profile !== "low") return null;
16561
+ if (typeof offered !== "boolean" || !Array.isArray(missing)) return null;
16562
+ const missingSources = [];
16563
+ for (const entry of missing) {
16564
+ if (typeof entry !== "number" || !Number.isInteger(entry)) return null;
16565
+ missingSources.push(entry);
16566
+ }
16567
+ return {
16568
+ profile,
16569
+ offered,
16570
+ missingSources
16571
+ };
16572
+ }
16573
+ /**
16574
+ * Parse the `camera-grid-layout.getLayout` reply. See {@link GridLayoutReadOutcome}.
16575
+ *
16576
+ * The cap answers the view itself, or `null` — there is no envelope. It used to
16577
+ * come back as `{ grid }` from `addons.custom`, which was the mechanism the
16578
+ * widget reached the addon through before the geometry had a capability of its
16579
+ * own; the widget now asks THE CAMERA and the envelope went with the bridge.
16580
+ */
16426
16581
  function parseGridView(raw) {
16427
- if (!isRecord(raw) || !("grid" in raw)) return "unreadable";
16428
- const grid = raw["grid"];
16429
- if (grid === null) return null;
16430
- if (!isRecord(grid)) return "unreadable";
16431
- const { instanceId, deviceId, name, width, height, fps, cells } = grid;
16432
- if (typeof instanceId !== "string" || typeof deviceId !== "number" || typeof name !== "string" || typeof width !== "number" || typeof height !== "number" || typeof fps !== "number" || !Array.isArray(cells)) return "unreadable";
16582
+ if (raw === null) return null;
16583
+ if (!isRecord(raw)) return "unreadable";
16584
+ const { instanceId, deviceId, name, width, height, fps, cells, profiles } = raw;
16585
+ if (typeof instanceId !== "string" || typeof deviceId !== "number" || typeof name !== "string" || typeof width !== "number" || typeof height !== "number" || typeof fps !== "number" || !Array.isArray(cells) || !Array.isArray(profiles)) return "unreadable";
16433
16586
  const parsed = [];
16434
16587
  for (const entry of cells) {
16435
16588
  const cell = readCell(entry);
16436
16589
  if (cell === null) return "unreadable";
16437
16590
  parsed.push(cell);
16438
16591
  }
16592
+ const offers = [];
16593
+ for (const entry of profiles) {
16594
+ const offer = readProfileOffer(entry);
16595
+ if (offer === null) return "unreadable";
16596
+ offers.push(offer);
16597
+ }
16439
16598
  return {
16440
16599
  instanceId,
16441
16600
  deviceId,
@@ -16443,7 +16602,8 @@ function parseGridView(raw) {
16443
16602
  width,
16444
16603
  height,
16445
16604
  fps,
16446
- cells: parsed
16605
+ cells: parsed,
16606
+ profiles: offers
16447
16607
  };
16448
16608
  }
16449
16609
  /**
@@ -16501,7 +16661,6 @@ function resizeNormalizedRect(rect, dw, dh) {
16501
16661
  * the server nothing and it is wrong in exactly the ways the real composition
16502
16662
  * would be wrong.
16503
16663
  */
16504
- var ADDON_ID = "camera-grid";
16505
16664
  var FULL_FRAME = {
16506
16665
  x: 0,
16507
16666
  y: 0,
@@ -16520,36 +16679,36 @@ function CameraGridLayoutEditor({ deviceId }) {
16520
16679
  const [saveError, setSaveError] = useState(null);
16521
16680
  const [selected, setSelected] = useState(0);
16522
16681
  const [drag, setDrag] = useState(null);
16682
+ const dev = useDeviceProxy(system.trpcClient, deviceId);
16523
16683
  const reload = useCallback(async () => {
16524
- const outcome = parseGridView(await system.trpcClient.addons.custom.mutate({
16525
- addonId: ADDON_ID,
16526
- action: "getGridForDevice",
16527
- input: { deviceId }
16528
- }));
16684
+ const layout = dev?.cameraGridLayout;
16685
+ if (!layout) return;
16686
+ const outcome = parseGridView(await layout.getLayout({}));
16529
16687
  setLoaded(outcome);
16530
16688
  setDraft(outcome === null || outcome === "unreadable" ? null : outcome);
16531
- }, [system, deviceId]);
16689
+ }, [dev]);
16532
16690
  useEffect(() => {
16691
+ if (!dev) return;
16692
+ if (!dev.cameraGridLayout) {
16693
+ setLoaded(null);
16694
+ return;
16695
+ }
16533
16696
  reload().catch(() => {
16534
16697
  setLoaded("unreadable");
16535
16698
  });
16536
- }, [reload]);
16699
+ }, [dev, reload]);
16537
16700
  const save = useCallback(async () => {
16538
- if (draft === null) return;
16701
+ const layout = dev?.cameraGridLayout;
16702
+ if (draft === null || !layout) return;
16539
16703
  setSaving(true);
16540
16704
  setSaveError(null);
16541
16705
  try {
16542
- const outcome = parseGridView(await system.trpcClient.addons.custom.mutate({
16543
- addonId: ADDON_ID,
16544
- action: "saveGridLayout",
16545
- input: {
16546
- deviceId,
16547
- name: draft.name,
16548
- width: draft.width,
16549
- height: draft.height,
16550
- fps: draft.fps,
16551
- cells: draft.cells.map((cell) => ({ ...cell }))
16552
- }
16706
+ const outcome = parseGridView(await layout.saveLayout({
16707
+ name: draft.name,
16708
+ width: draft.width,
16709
+ height: draft.height,
16710
+ fps: draft.fps,
16711
+ cells: draft.cells.map((cell) => ({ ...cell }))
16553
16712
  }));
16554
16713
  if (outcome !== null && outcome !== "unreadable") {
16555
16714
  setLoaded(outcome);
@@ -16560,11 +16719,7 @@ function CameraGridLayoutEditor({ deviceId }) {
16560
16719
  } finally {
16561
16720
  setSaving(false);
16562
16721
  }
16563
- }, [
16564
- system,
16565
- deviceId,
16566
- draft
16567
- ]);
16722
+ }, [dev, draft]);
16568
16723
  const patchCell = useCallback((index, patch) => {
16569
16724
  setDraft((prev) => prev === null ? prev : {
16570
16725
  ...prev,
@@ -16718,6 +16873,10 @@ function CameraGridLayoutEditor({ deviceId }) {
16718
16873
  })
16719
16874
  ]
16720
16875
  }),
16876
+ /* @__PURE__ */ jsx(GridProfileReport, {
16877
+ offers: loaded.profiles,
16878
+ cameras
16879
+ }),
16721
16880
  draft.cells[selected] !== void 0 && /* @__PURE__ */ jsx(CellInspector, {
16722
16881
  cell: draft.cells[selected],
16723
16882
  index: selected,
@@ -16737,6 +16896,37 @@ function CameraGridLayoutEditor({ deviceId }) {
16737
16896
  })
16738
16897
  });
16739
16898
  }
16899
+ /**
16900
+ * The honest report. A grid's `high` composes its sources' `high` and its `low`
16901
+ * their `low`, so a profile is on offer only when EVERY source can serve it —
16902
+ * and the refusal names the source, because "this grid has no low" is not
16903
+ * something an operator can act on and "615 has no low" is.
16904
+ *
16905
+ * Read-only on purpose: what is on offer is decided by the source cameras'
16906
+ * assignments, and a control here would be a switch that writes nothing (D62).
16907
+ */
16908
+ function GridProfileReport({ offers, cameras }) {
16909
+ const nameOf = (deviceId) => cameras.find((camera) => camera.id === deviceId)?.name ?? `#${String(deviceId)}`;
16910
+ return /* @__PURE__ */ jsxs("div", {
16911
+ className: "flex flex-col gap-1 rounded border border-border p-2 text-[10.5px]",
16912
+ children: [/* @__PURE__ */ jsx("span", {
16913
+ className: "uppercase text-foreground-subtle",
16914
+ children: "Published profiles"
16915
+ }), offers.map((offer) => /* @__PURE__ */ jsxs("div", {
16916
+ className: "flex items-baseline gap-2",
16917
+ children: [/* @__PURE__ */ jsx("span", {
16918
+ className: "w-10 uppercase",
16919
+ children: offer.profile
16920
+ }), offer.offered ? /* @__PURE__ */ jsxs("span", {
16921
+ className: "text-foreground-subtle",
16922
+ children: ["composed from every source’s ", offer.profile]
16923
+ }) : /* @__PURE__ */ jsx("span", {
16924
+ className: "text-danger",
16925
+ children: offer.missingSources.length === 0 ? "not offered — this grid has no cells yet" : `not offered — no ${offer.profile} on ${offer.missingSources.map(nameOf).join(", ")}`
16926
+ })]
16927
+ }, offer.profile))]
16928
+ });
16929
+ }
16740
16930
  function GridCellBox({ cell, selected, onSelect, onDragStart }) {
16741
16931
  const { src } = useDeviceSnapshotImage(cell.deviceId, { width: 640 });
16742
16932
  const backgroundSize = `${String(100 / cell.source.width)}% ${String(100 / cell.source.height)}%`;
@@ -16842,136 +17032,6 @@ function LabelledNumber({ label, value, step = 1, onChange }) {
16842
17032
  });
16843
17033
  }
16844
17034
  //#endregion
16845
- //#region src/hooks/use-device-proxy.ts
16846
- /**
16847
- * useDeviceProxy — canonical React entry point for a device.
16848
- *
16849
- * Symmetric with the server's `ctx.fetchDevice(id)` and the SDK's
16850
- * `BackendClient.fetchDevice(id)` — same `DeviceProxy` shape, same
16851
- * routing semantics (cap-method calls go through the wrapper chain;
16852
- * `state.<cap>.value` reads the kernel's mirrored snapshot, never
16853
- * the provider).
16854
- *
16855
- * What you can do with the returned proxy:
16856
- *
16857
- * const dev = useDeviceProxy(trpc, deviceId)
16858
- *
16859
- * // Reactive runtime state — re-renders when the slice changes.
16860
- * const battery = useDeviceStateSlice(dev?.state.battery)
16861
- *
16862
- * // Imperative cap-method calls — through the wrapper chain.
16863
- * await dev?.snapshot?.getSnapshot({ streamId: 'high' })
16864
- * await dev?.ptz?.move({ direction: 'left', steps: 1 })
16865
- *
16866
- * Lifecycle: the hook resolves bindings via `deviceManager.getBindings`
16867
- * the first time `deviceId` becomes valid, and refreshes them on
16868
- * `capability.binding-changed` events for that device. Returns `null`
16869
- * until the first resolution completes (the proxy is not constructible
16870
- * without a binding).
16871
- *
16872
- * Works in both admin-ui (`BackendClient.trpc`) and addon pages
16873
- * (`AddonPageProps.trpc`) — parametrized by a minimal vanilla tRPC
16874
- * proxy shape (matches both at runtime).
16875
- */
16876
- var BINDING_CHANGED_CATEGORY = "capability.binding-changed";
16877
- /**
16878
- * @returns the proxy, or `null` while bindings are still loading or
16879
- * when `deviceId` is null. Re-rendered when bindings change.
16880
- */
16881
- function useDeviceProxy(trpc, deviceId) {
16882
- const [binding, setBinding] = useState(null);
16883
- useEffect(() => {
16884
- if (deviceId === null) {
16885
- setBinding(null);
16886
- return;
16887
- }
16888
- let cancelled = false;
16889
- const refresh = async () => {
16890
- try {
16891
- const next = await trpc.deviceManager.getBindings.query({ deviceId });
16892
- if (!cancelled) setBinding(next);
16893
- } catch {
16894
- if (!cancelled) setBinding((prev) => prev);
16895
- }
16896
- };
16897
- refresh();
16898
- const sub = trpc.live?.onEvent.subscribe({ category: BINDING_CHANGED_CATEGORY }, { onData: (evt) => {
16899
- const src = evt.data?.source;
16900
- if (!src) {
16901
- refresh();
16902
- return;
16903
- }
16904
- if (src.type === "device" && src.id === deviceId) refresh();
16905
- } });
16906
- return () => {
16907
- cancelled = true;
16908
- sub?.unsubscribe();
16909
- };
16910
- }, [trpc, deviceId]);
16911
- return useMemo(() => {
16912
- if (!binding) return null;
16913
- return createDeviceProxy(trpc, binding);
16914
- }, [trpc, binding]);
16915
- }
16916
- /**
16917
- * Companion hook — reads a `SliceHandle` reactively, returning the
16918
- * current value and re-rendering on every push from the kernel
16919
- * mirror. Pass `dev?.state.battery` etc. as the handle.
16920
- *
16921
- * const dev = useDeviceProxy(trpc, deviceId)
16922
- * const battery = useDeviceStateSlice(dev?.state.battery)
16923
- *
16924
- * Why a hook (and not just `dev.state.battery.subscribe(...)` inline)?
16925
- * React doesn't observe external mutations on its own — a bare
16926
- * subscribe call inside a render would fire on every render (memory
16927
- * leak), wouldn't trigger a re-render on slice change, and wouldn't
16928
- * clean up at unmount. The hook bridges the imperative
16929
- * `(value, subscribe)` pair into React's declarative lifecycle via
16930
- * `useSyncExternalStore` (the canonical React 18 primitive for
16931
- * external stores — handles tearing in concurrent rendering and
16932
- * de-dups identical pushes).
16933
- */
16934
- function useDeviceStateSlice(handle) {
16935
- const subscribe = useCallback((notify) => {
16936
- if (!handle) return () => void 0;
16937
- return handle.subscribe(() => notify());
16938
- }, [handle]);
16939
- const getSnapshot = useCallback(() => handle?.value, [handle]);
16940
- return useSyncExternalStore(subscribe, getSnapshot, getSnapshot);
16941
- }
16942
- /**
16943
- * One-line combinator — collapse the common
16944
- * `useDeviceProxy + useDeviceStateSlice(dev?.state.<cap>)` pair into a
16945
- * single hook. The selector receives the live `DeviceProxy['state']`
16946
- * bag (typed against the codegen, so `s.battery` etc. autocomplete)
16947
- * and returns one slice handle. Returns the slice value, undefined
16948
- * while the binding resolves OR when the slice has no cached push yet.
16949
- *
16950
- * Idiomatic call-site:
16951
- *
16952
- * const battery = useDeviceState(trpc, deviceId, (s) => s.battery)
16953
- * const zones = useDeviceState(trpc, deviceId, (s) => s.zones)
16954
- *
16955
- * vs. the manual pair:
16956
- *
16957
- * const dev = useDeviceProxy(trpc, deviceId)
16958
- * const battery = useDeviceStateSlice(dev?.state.battery)
16959
- * const zones = useDeviceStateSlice(dev?.state.zones)
16960
- *
16961
- * Use the manual pair when:
16962
- * - you also need the proxy's cap method bag (`dev.zones?.addZone(...)`)
16963
- * - you want to share the proxy across multiple slice reads with a
16964
- * single binding fetch (the combinator calls useDeviceProxy
16965
- * internally, which dedups bindings via React Query in practice
16966
- * but spawns one extra subscription per call site)
16967
- *
16968
- * Use the combinator when reading just one slice — most common case.
16969
- */
16970
- function useDeviceState(trpc, deviceId, select) {
16971
- const dev = useDeviceProxy(trpc, deviceId);
16972
- return useDeviceStateSlice(dev ? select(dev.state) : void 0);
16973
- }
16974
- //#endregion
16975
17035
  //#region src/hooks/use-device-capability.ts
16976
17036
  function useDeviceCapability(options) {
16977
17037
  const dev = useDeviceProxy(options.trpc, options.deviceId);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@camstack/ui-library",
3
- "version": "1.2.175",
3
+ "version": "1.2.176",
4
4
  "type": "module",
5
5
  "main": "dist/index.cjs",
6
6
  "module": "dist/index.js",