@7365admin1/layer-common 3.1.4-staging.53 → 3.1.4-staging.55

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.
@@ -0,0 +1,281 @@
1
+ /**
2
+ * THE CAMERA WALL'S RULES, WITH NO MARKUP IN THEM.
3
+ *
4
+ * `CameraWall.vue` draws the wall; this file decides what it is allowed to draw.
5
+ * Keeping the two apart is not tidiness — every rule below is one somebody can
6
+ * get wrong in a way nobody notices until a supervisor is looking at the wrong
7
+ * picture, so each one is stated once, here, and pinned by `camera-wall.test.ts`.
8
+ *
9
+ * ## The four rules
10
+ *
11
+ * 1. **Only `type: "ip"` cameras go on a wall.** An ANPR unit belongs to visitor
12
+ * and vehicle management: it is pointed at a number plate, it is wired to a
13
+ * barrier, and its record carries a real device address and a real credential.
14
+ * Putting one on a monitoring wall is a defect, not a feature. The request
15
+ * already asks the server for `type=ip`; `wallCameras()` filters again on the
16
+ * way in, because "the caller passed the right query" is an assumption and
17
+ * this is the rule.
18
+ * 2. **A capability we have not been told about is NOT available.** An absent or
19
+ * malformed descriptor reads as `unknown`, and `unknown` counts as no — so a
20
+ * browser talking to an older API cannot draw a control that would do nothing.
21
+ * 3. **A control that is off is drawn, dimmed, and says why** — never hidden. The
22
+ * sentence shown is the SERVER'S own sentence, never rewritten here, so a
23
+ * camera cannot explain itself two different ways on two screens.
24
+ * 4. **"Live" means a picture is arriving**, not "we asked for one". The tile
25
+ * says `Connecting…` until the player page is actually up, and `No signal`
26
+ * when it never comes.
27
+ *
28
+ * These match the iSecure365 mobile monitoring build one for one, deliberately:
29
+ * the same product should not answer the same question differently on a phone
30
+ * and on a laptop.
31
+ */
32
+
33
+ /** One capability's answer, as the API returns it (core's `CameraCapabilityEntry`). */
34
+ export type TWallCapability = {
35
+ state?: "supported" | "unsupported" | "unknown";
36
+ transport?: string | null;
37
+ reason?: string | null;
38
+ /** The server's own sentence. Displayed verbatim; never rewritten here. */
39
+ detail?: string | null;
40
+ };
41
+
42
+ /** A camera as the wall needs it. Extra fields on the record are ignored. */
43
+ export type TWallCamera = {
44
+ _id: string;
45
+ name?: string;
46
+ /**
47
+ * The stored address. For a `type: "ip"` camera this is a complete, directly
48
+ * renderable live-video page — it is the picture, not a fragment to resolve.
49
+ */
50
+ host?: string;
51
+ type?: string;
52
+ status?: string;
53
+ capabilities?: Record<string, TWallCapability>;
54
+ };
55
+
56
+ /* -------------------------------------------------------------------------- */
57
+ /* Layouts */
58
+ /* -------------------------------------------------------------------------- */
59
+
60
+ /**
61
+ * The three wall sizes.
62
+ *
63
+ * Square only, and only three of them. A VMS layout picker exists so a
64
+ * supervisor can change what they are watching in one click; a list of eleven
65
+ * arrangements is a settings screen wearing a toolbar's clothes.
66
+ */
67
+ export const WALL_LAYOUTS = [
68
+ { key: "1x1", columns: 1, tiles: 1, label: "Single" },
69
+ { key: "2x2", columns: 2, tiles: 4, label: "Four" },
70
+ { key: "3x3", columns: 3, tiles: 9, label: "Nine" },
71
+ ] as const;
72
+
73
+ export type TWallLayoutKey = (typeof WALL_LAYOUTS)[number]["key"];
74
+
75
+ /** Four tiles: enough to be a wall, small enough to still be readable. */
76
+ export const DEFAULT_WALL_LAYOUT: TWallLayoutKey = "2x2";
77
+
78
+ export function wallLayout(key: string | undefined) {
79
+ return WALL_LAYOUTS.find((l) => l.key === key) ?? WALL_LAYOUTS[1];
80
+ }
81
+
82
+ /**
83
+ * At three columns a tile is small enough that the chrome starts eating the
84
+ * picture, so the tile drops its status WORD and keeps the dot.
85
+ */
86
+ export function isDenseLayout(key: string | undefined) {
87
+ return wallLayout(key).columns >= 3;
88
+ }
89
+
90
+ /* -------------------------------------------------------------------------- */
91
+ /* Which cameras, and in what order */
92
+ /* -------------------------------------------------------------------------- */
93
+
94
+ /** Rule 1. Only `type: "ip"`. See the note at the top of this file. */
95
+ export function wallCameras(items: unknown): TWallCamera[] {
96
+ if (!Array.isArray(items)) return [];
97
+ return items.filter(
98
+ (c): c is TWallCamera => !!c && typeof c === "object" && (c as TWallCamera).type === "ip"
99
+ );
100
+ }
101
+
102
+ /**
103
+ * The tiles, in order, padded with `null` to the layout's size.
104
+ *
105
+ * An empty selection is **not** "no cameras" — it is "nobody has chosen yet",
106
+ * and the wall falls back to the site's own order. A supervisor who clears the
107
+ * picker gets their site back, not a black screen.
108
+ */
109
+ export function wallTiles(
110
+ cameras: TWallCamera[],
111
+ selectedIds: string[] | undefined,
112
+ layoutKey: string | undefined
113
+ ): Array<TWallCamera | null> {
114
+ const { tiles } = wallLayout(layoutKey);
115
+ const byId = new Map(cameras.map((c) => [c._id, c]));
116
+ const chosen =
117
+ selectedIds && selectedIds.length
118
+ ? selectedIds.map((id) => byId.get(id)).filter((c): c is TWallCamera => !!c)
119
+ : cameras;
120
+
121
+ const out: Array<TWallCamera | null> = chosen.slice(0, tiles);
122
+ while (out.length < tiles) out.push(null);
123
+ return out;
124
+ }
125
+
126
+ /* -------------------------------------------------------------------------- */
127
+ /* Capabilities */
128
+ /* -------------------------------------------------------------------------- */
129
+
130
+ /**
131
+ * The two words a switched-off control wears.
132
+ *
133
+ * Lifted unchanged from the mobile build, and the "yet" is the whole point: it
134
+ * is the difference between a feature that is broken and a feature that is
135
+ * waiting on something somebody has to switch on.
136
+ */
137
+ export const CAPABILITY_TAGS = {
138
+ unsupported: "Not set up yet",
139
+ unknown: "Not checked yet",
140
+ } as const;
141
+
142
+ /**
143
+ * What a client with no descriptor at all should say. This is the server's own
144
+ * `device-not-probed` sentence, reused rather than reinvented, so an older API
145
+ * that sends no descriptor still explains itself in the product's own words.
146
+ */
147
+ const NOT_PROBED = "This camera has not been asked what it can do yet.";
148
+
149
+ /** The four capabilities the wall draws a control for, and their plain names. */
150
+ export const WALL_CAPABILITY_CONTROLS = [
151
+ { key: "playback", title: "Recorded footage" },
152
+ { key: "events", title: "Events and alarms" },
153
+ { key: "ptz", title: "Move camera" },
154
+ { key: "presets", title: "Stored positions" },
155
+ ] as const;
156
+
157
+ export type TCapabilityView = {
158
+ state: "supported" | "unsupported" | "unknown";
159
+ available: boolean;
160
+ /** `null` when the capability works — a working control needs no tag. */
161
+ tag: string | null;
162
+ /** The server's sentence, or the not-probed one. Never empty. */
163
+ detail: string;
164
+ };
165
+
166
+ /** Rule 2 and rule 3, in one place. */
167
+ export function capabilityView(
168
+ camera: TWallCamera | null | undefined,
169
+ key: string
170
+ ): TCapabilityView {
171
+ const entry = camera?.capabilities?.[key];
172
+ const state =
173
+ entry?.state === "supported" || entry?.state === "unsupported"
174
+ ? entry.state
175
+ : "unknown";
176
+
177
+ return {
178
+ state,
179
+ available: state === "supported",
180
+ tag: state === "supported" ? null : CAPABILITY_TAGS[state],
181
+ detail: (entry?.detail || "").trim() || NOT_PROBED,
182
+ };
183
+ }
184
+
185
+ /* -------------------------------------------------------------------------- */
186
+ /* Tile state */
187
+ /* -------------------------------------------------------------------------- */
188
+
189
+ /**
190
+ * How long a player page may take to load before the tile stops claiming it is
191
+ * coming. Deliberately generous — lifted from the mobile build, where the
192
+ * argument is that reporting a working video service as dead is the worse of
193
+ * the two mistakes.
194
+ */
195
+ export const LIVE_CONNECT_TIMEOUT_MS = 15_000;
196
+
197
+ export type TTileState =
198
+ | "live"
199
+ | "connecting"
200
+ | "no-signal"
201
+ | "offline"
202
+ | "unavailable";
203
+
204
+ /**
205
+ * The four words a tile may say about itself.
206
+ *
207
+ * `offline` and `no-signal` are two different facts and used to share one word:
208
+ * "Offline" on every tile at once means the BROWSER lost the network; "No
209
+ * signal" on one tile means that camera is not answering. A supervisor needs to
210
+ * know which of those they are looking at.
211
+ */
212
+ export const TILE_LABELS = {
213
+ live: "Live",
214
+ connecting: "Connecting…",
215
+ "no-signal": "No signal",
216
+ offline: "No connection",
217
+ unavailable: "Not set up yet",
218
+ } as const;
219
+
220
+ /** What the browser knows about the embedded player page. */
221
+ export type TPlayerState = "loading" | "ready" | "timeout";
222
+
223
+ export type TTileView = {
224
+ state: TTileState;
225
+ label: string;
226
+ /** A sentence for the states that need explaining; `null` when live. */
227
+ detail: string | null;
228
+ /** Whether the player page may be embedded at all. */
229
+ showPlayer: boolean;
230
+ };
231
+
232
+ /**
233
+ * Rule 4, plus every reason a tile may have nothing to show.
234
+ *
235
+ * Order matters: the browser being offline beats everything (it explains every
236
+ * tile at once), then the record's own problems, then the server's verdict on
237
+ * live video, and only then what the embedded page is actually doing.
238
+ */
239
+ export function tileView(
240
+ camera: TWallCamera | null | undefined,
241
+ opts: { online?: boolean; player?: TPlayerState } = {}
242
+ ): TTileView {
243
+ const online = opts.online !== false;
244
+ const dead = (state: TTileState, detail: string | null): TTileView => ({
245
+ state,
246
+ label: TILE_LABELS[state],
247
+ detail,
248
+ showPlayer: false,
249
+ });
250
+
251
+ if (!camera) return dead("unavailable", "No camera in this position.");
252
+ if (!online)
253
+ return dead("offline", "This browser has no internet connection.");
254
+ if (camera.status && camera.status !== "active")
255
+ return dead("unavailable", "This camera is not active.");
256
+ if (!camera.host) return dead("unavailable", "This camera has no address configured.");
257
+
258
+ const live = capabilityView(camera, "liveVideo");
259
+ if (live.state === "unsupported") return dead("unavailable", live.detail);
260
+
261
+ const player = opts.player ?? "loading";
262
+ if (player === "timeout")
263
+ return {
264
+ state: "no-signal",
265
+ label: TILE_LABELS["no-signal"],
266
+ // Invented: only the browser can know the page never loaded, so the
267
+ // server has no reason code for it. Worded as a fact, not a diagnosis —
268
+ // we genuinely cannot tell a dead camera from a dead video service.
269
+ detail: "The live view did not load. The camera or the video service may be down.",
270
+ showPlayer: true,
271
+ };
272
+ if (player === "loading")
273
+ return {
274
+ state: "connecting",
275
+ label: TILE_LABELS.connecting,
276
+ detail: null,
277
+ showPlayer: true,
278
+ };
279
+
280
+ return { state: "live", label: TILE_LABELS.live, detail: null, showPlayer: true };
281
+ }