@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.
- package/dist/composites/cap-settings/camera-grid-layout.d.ts +21 -1
- package/dist/index.cjs +223 -163
- package/dist/index.js +223 -163
- package/package.json +1 -1
|
@@ -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
|
-
/**
|
|
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
|
-
|
|
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 (
|
|
16452
|
-
|
|
16453
|
-
|
|
16454
|
-
if (!
|
|
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
|
|
16549
|
-
|
|
16550
|
-
|
|
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
|
-
}, [
|
|
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
|
-
|
|
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
|
|
16567
|
-
|
|
16568
|
-
|
|
16569
|
-
|
|
16570
|
-
|
|
16571
|
-
|
|
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
|
-
|
|
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 (
|
|
16428
|
-
|
|
16429
|
-
|
|
16430
|
-
if (!
|
|
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
|
|
16525
|
-
|
|
16526
|
-
|
|
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
|
-
}, [
|
|
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
|
-
|
|
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
|
|
16543
|
-
|
|
16544
|
-
|
|
16545
|
-
|
|
16546
|
-
|
|
16547
|
-
|
|
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);
|