@attraccess/plugin-shelly 0.1.0-nightly.925.2

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,1627 @@
1
+ import { r as importShared } from "./_virtual___federation_fn_import-BXODRNto.js";
2
+ import { t as require_jsx_runtime } from "./jsx-runtime-B9Qmn8k7.js";
3
+ //#region ../../../libs/plugins-frontend-sdk/src/lib/frontend.api-client.ts
4
+ var BASE_URL_GLOBAL = "__ATTRACCESS_API_BASE_URL__";
5
+ /** The API origin the host is talking to. Falls back to the current origin. */
6
+ function getApiBaseUrl() {
7
+ return window[BASE_URL_GLOBAL] ?? window.location.origin;
8
+ }
9
+ /** Thrown for any non-2xx response, carrying the status for callers that branch on it. */
10
+ var PluginApiError = class extends Error {
11
+ constructor(message, status) {
12
+ super(message);
13
+ this.status = status;
14
+ this.name = "PluginApiError";
15
+ }
16
+ };
17
+ async function toError(res) {
18
+ try {
19
+ const body = await res.json();
20
+ const message = Array.isArray(body.message) ? body.message.join(", ") : body.message;
21
+ if (message) return new PluginApiError(message, res.status);
22
+ } catch {}
23
+ return new PluginApiError(`Request failed (HTTP ${res.status}).`, res.status);
24
+ }
25
+ /**
26
+ * Creates a client bound to `basePath`, which is resolved against the host's
27
+ * API origin. Plugin routes are mounted under `/api/<plugin-name>`, so that is
28
+ * the usual argument; pass nothing to address the host API directly.
29
+ */
30
+ function createPluginApiClient(basePath = "") {
31
+ const url = (path, query) => {
32
+ const target = new URL(`${basePath}${path}`, getApiBaseUrl());
33
+ Object.entries(query ?? {}).forEach(([key, value]) => {
34
+ if (value !== void 0 && value !== null) target.searchParams.set(key, String(value));
35
+ });
36
+ return target.toString();
37
+ };
38
+ const doFetch = (path, init) => fetch(url(path), {
39
+ credentials: "include",
40
+ ...init
41
+ });
42
+ return {
43
+ url,
44
+ fetch: doFetch,
45
+ async request(path, options = {}) {
46
+ const { body, query, headers, ...init } = options;
47
+ const res = await fetch(url(path, query), {
48
+ credentials: "include",
49
+ ...init,
50
+ headers: body === void 0 ? headers : {
51
+ "Content-Type": "application/json",
52
+ ...headers
53
+ },
54
+ body: body === void 0 ? void 0 : JSON.stringify(body)
55
+ });
56
+ if (!res.ok) throw await toError(res);
57
+ const text = await res.text();
58
+ return text.length > 0 ? JSON.parse(text) : null;
59
+ }
60
+ };
61
+ }
62
+ //#endregion
63
+ //#region frontend/src/api.ts
64
+ var api = createPluginApiClient("/api/shelly");
65
+ function listDevices() {
66
+ return api.request("/devices");
67
+ }
68
+ function addDevice(input) {
69
+ return api.request("/devices", {
70
+ method: "POST",
71
+ body: input
72
+ });
73
+ }
74
+ function reprobeDevice(id) {
75
+ return api.request(`/devices/${id}/probe`, { method: "POST" });
76
+ }
77
+ function deleteDevice(id) {
78
+ return api.request(`/devices/${id}`, { method: "DELETE" });
79
+ }
80
+ function getDeviceInfo(id, input) {
81
+ return api.request(`/devices/${id}/info`, {
82
+ method: "POST",
83
+ body: input ?? {}
84
+ });
85
+ }
86
+ /** Firmware state of every registered device, checked in one round trip. */
87
+ function listFirmware() {
88
+ return api.request("/devices/firmware");
89
+ }
90
+ function getFirmware(id, input) {
91
+ const params = new URLSearchParams();
92
+ if (input?.username) params.set("username", input.username);
93
+ if (input?.currentPassword) params.set("currentPassword", input.currentPassword);
94
+ const query = params.size ? `?${params.toString()}` : "";
95
+ return api.request(`/devices/${id}/firmware${query}`);
96
+ }
97
+ function startFirmwareUpdate(id, input) {
98
+ return api.request(`/devices/${id}/firmware/update`, {
99
+ method: "POST",
100
+ body: input
101
+ });
102
+ }
103
+ function setAdminPassword(id, input) {
104
+ return api.request(`/devices/${id}/auth`, {
105
+ method: "POST",
106
+ body: input
107
+ });
108
+ }
109
+ /**
110
+ * Runs discovery on the server (mDNS + subnet scan) and returns what it found.
111
+ * Slow by nature — a /24 scan takes a few seconds — so callers must show a
112
+ * pending state.
113
+ */
114
+ function discoverDevices(input = {}) {
115
+ return api.request("/discovery", {
116
+ method: "POST",
117
+ body: input
118
+ });
119
+ }
120
+ //#endregion
121
+ //#region frontend/src/drawer.tsx
122
+ var import_jsx_runtime = require_jsx_runtime();
123
+ var { Button: Button$6, Description, DrawerBackdrop, DrawerContent, DrawerDialog, Input, InputGroup, Label, TextField, Tooltip: Tooltip$2 } = await importShared("@heroui/react");
124
+ var { EyeIcon: EyeIcon$1, EyeOffIcon: EyeOffIcon$1 } = await importShared("lucide-react");
125
+ var { useState: useState$6 } = await importShared("react");
126
+ var DRAWER_DIALOG_CLASSNAME = "sh:md:max-w-2xl sh:md:mx-auto sh:bg-surface-secondary";
127
+ var FIELD_CONTRAST_STYLE = {
128
+ ["--field-border"]: "var(--border-secondary)",
129
+ ["--border-width-field"]: "1px"
130
+ };
131
+ function StandardDrawer({ isOpen, onOpenChange, children }) {
132
+ return /* @__PURE__ */ (0, import_jsx_runtime.jsx)(DrawerBackdrop, {
133
+ isOpen,
134
+ onOpenChange,
135
+ children: /* @__PURE__ */ (0, import_jsx_runtime.jsx)(DrawerContent, { children: /* @__PURE__ */ (0, import_jsx_runtime.jsx)(DrawerDialog, {
136
+ className: DRAWER_DIALOG_CLASSNAME,
137
+ style: FIELD_CONTRAST_STYLE,
138
+ children
139
+ }) })
140
+ });
141
+ }
142
+ function TextFieldRow({ label, value, onChange, placeholder, required, description, dataCy }) {
143
+ return /* @__PURE__ */ (0, import_jsx_runtime.jsxs)(TextField, {
144
+ value,
145
+ onChange,
146
+ isRequired: required,
147
+ children: [
148
+ /* @__PURE__ */ (0, import_jsx_runtime.jsx)(Label, { children: label }),
149
+ /* @__PURE__ */ (0, import_jsx_runtime.jsx)(Input, {
150
+ placeholder,
151
+ autoComplete: "off",
152
+ "data-cy": dataCy
153
+ }),
154
+ description && /* @__PURE__ */ (0, import_jsx_runtime.jsx)("p", {
155
+ className: "sh:mt-1 sh:text-xs sh:text-muted",
156
+ children: description
157
+ })
158
+ ]
159
+ });
160
+ }
161
+ function PasswordFieldRow({ label, value, onChange, description, required, dataCy, autoComplete }) {
162
+ const [visible, setVisible] = useState$6(false);
163
+ const toggleLabel = visible ? "Hide password" : "Show password";
164
+ return /* @__PURE__ */ (0, import_jsx_runtime.jsxs)(TextField, {
165
+ value,
166
+ onChange,
167
+ isRequired: required,
168
+ children: [
169
+ /* @__PURE__ */ (0, import_jsx_runtime.jsx)(Label, { children: label }),
170
+ /* @__PURE__ */ (0, import_jsx_runtime.jsxs)(InputGroup, { children: [/* @__PURE__ */ (0, import_jsx_runtime.jsx)(InputGroup.Input, {
171
+ type: visible ? "text" : "password",
172
+ autoComplete,
173
+ "data-cy": dataCy
174
+ }), /* @__PURE__ */ (0, import_jsx_runtime.jsx)(InputGroup.Suffix, { children: /* @__PURE__ */ (0, import_jsx_runtime.jsxs)(Tooltip$2, { children: [/* @__PURE__ */ (0, import_jsx_runtime.jsx)(Tooltip$2.Trigger, { children: /* @__PURE__ */ (0, import_jsx_runtime.jsx)(Button$6, {
175
+ isIconOnly: true,
176
+ variant: "ghost",
177
+ "aria-label": toggleLabel,
178
+ onPress: () => setVisible((v) => !v),
179
+ children: visible ? /* @__PURE__ */ (0, import_jsx_runtime.jsx)(EyeOffIcon$1, { className: "sh:h-4 sh:w-4" }) : /* @__PURE__ */ (0, import_jsx_runtime.jsx)(EyeIcon$1, { className: "sh:h-4 sh:w-4" })
180
+ }) }), /* @__PURE__ */ (0, import_jsx_runtime.jsx)(Tooltip$2.Content, { children: toggleLabel })] }) })] }),
181
+ description && /* @__PURE__ */ (0, import_jsx_runtime.jsx)(Description, { children: description })
182
+ ]
183
+ });
184
+ }
185
+ //#endregion
186
+ //#region frontend/src/StatusAlert.tsx
187
+ var { Alert, AlertContent, AlertDescription, AlertIndicator, AlertTitle } = await importShared("@heroui/react");
188
+ var { AlertCircleIcon, AlertTriangleIcon, CheckCircle2Icon, InfoIcon: InfoIcon$2 } = await importShared("lucide-react");
189
+ var iconByStatus = {
190
+ default: InfoIcon$2,
191
+ accent: InfoIcon$2,
192
+ success: CheckCircle2Icon,
193
+ warning: AlertTriangleIcon,
194
+ danger: AlertCircleIcon
195
+ };
196
+ function StatusAlert({ status, title, children, dataCy }) {
197
+ const Icon = iconByStatus[status];
198
+ return /* @__PURE__ */ (0, import_jsx_runtime.jsxs)(Alert, {
199
+ status,
200
+ children: [/* @__PURE__ */ (0, import_jsx_runtime.jsx)(AlertIndicator, { children: /* @__PURE__ */ (0, import_jsx_runtime.jsx)(Icon, {}) }), /* @__PURE__ */ (0, import_jsx_runtime.jsxs)(AlertContent, { children: [/* @__PURE__ */ (0, import_jsx_runtime.jsx)(AlertTitle, { children: title }), /* @__PURE__ */ (0, import_jsx_runtime.jsx)(AlertDescription, {
201
+ "data-cy": dataCy,
202
+ children
203
+ })] })]
204
+ });
205
+ }
206
+ //#endregion
207
+ //#region frontend/src/AddDeviceDrawer.tsx
208
+ var { Button: Button$5, DrawerBody: DrawerBody$4, DrawerHeader: DrawerHeader$4, Form: Form$3 } = await importShared("@heroui/react");
209
+ var { PlusIcon: PlusIcon$1, XIcon: XIcon$4 } = await importShared("lucide-react");
210
+ var { useCallback: useCallback$5, useRef: useRef$2, useState: useState$5 } = await importShared("react");
211
+ function AddDeviceDrawer({ isOpen, onOpenChange, onAdded }) {
212
+ const [ipAddress, setIpAddress] = useState$5("");
213
+ const [name, setName] = useState$5("");
214
+ const [submitting, setSubmitting] = useState$5(false);
215
+ const [error, setError] = useState$5(null);
216
+ const close = useCallback$5(() => onOpenChange(false), [onOpenChange]);
217
+ const inFlight = useRef$2(false);
218
+ const submit = useCallback$5(async () => {
219
+ if (inFlight.current) return;
220
+ const ip = ipAddress.trim();
221
+ if (!ip) {
222
+ setError("IP address is required.");
223
+ return;
224
+ }
225
+ inFlight.current = true;
226
+ setSubmitting(true);
227
+ setError(null);
228
+ try {
229
+ await addDevice({
230
+ ipAddress: ip,
231
+ name: name.trim() || void 0
232
+ });
233
+ setIpAddress("");
234
+ setName("");
235
+ onAdded();
236
+ close();
237
+ } catch (err) {
238
+ setError(err instanceof Error ? err.message : String(err));
239
+ } finally {
240
+ inFlight.current = false;
241
+ setSubmitting(false);
242
+ }
243
+ }, [
244
+ ipAddress,
245
+ name,
246
+ onAdded,
247
+ close
248
+ ]);
249
+ return /* @__PURE__ */ (0, import_jsx_runtime.jsxs)(StandardDrawer, {
250
+ isOpen,
251
+ onOpenChange,
252
+ children: [/* @__PURE__ */ (0, import_jsx_runtime.jsx)(DrawerHeader$4, { children: /* @__PURE__ */ (0, import_jsx_runtime.jsxs)("div", {
253
+ className: "sh:flex sh:w-full sh:items-start sh:justify-between sh:gap-3",
254
+ children: [/* @__PURE__ */ (0, import_jsx_runtime.jsxs)("div", {
255
+ className: "sh:flex sh:flex-col sh:gap-1",
256
+ children: [/* @__PURE__ */ (0, import_jsx_runtime.jsx)("h2", {
257
+ className: "sh:text-lg sh:font-semibold",
258
+ children: "Add a device"
259
+ }), /* @__PURE__ */ (0, import_jsx_runtime.jsxs)("p", {
260
+ className: "sh:text-sm sh:text-muted",
261
+ children: [
262
+ "Enter the device's IP address. We probe ",
263
+ /* @__PURE__ */ (0, import_jsx_runtime.jsx)("code", { children: "GET /shelly" }),
264
+ " to detect its generation and model."
265
+ ]
266
+ })]
267
+ }), /* @__PURE__ */ (0, import_jsx_runtime.jsx)(Button$5, {
268
+ isIconOnly: true,
269
+ variant: "ghost",
270
+ "aria-label": "Close",
271
+ onPress: close,
272
+ children: /* @__PURE__ */ (0, import_jsx_runtime.jsx)(XIcon$4, { size: 16 })
273
+ })]
274
+ }) }), /* @__PURE__ */ (0, import_jsx_runtime.jsx)(DrawerBody$4, { children: /* @__PURE__ */ (0, import_jsx_runtime.jsxs)(Form$3, {
275
+ onSubmit: submit,
276
+ className: "sh:flex sh:flex-col sh:gap-4",
277
+ children: [
278
+ /* @__PURE__ */ (0, import_jsx_runtime.jsx)(TextFieldRow, {
279
+ label: "IP address",
280
+ value: ipAddress,
281
+ onChange: setIpAddress,
282
+ placeholder: "192.168.1.42",
283
+ required: true,
284
+ dataCy: "shelly-add-ip"
285
+ }),
286
+ /* @__PURE__ */ (0, import_jsx_runtime.jsx)(TextFieldRow, {
287
+ label: "Name (optional)",
288
+ value: name,
289
+ onChange: setName,
290
+ placeholder: "Workshop light",
291
+ dataCy: "shelly-add-name"
292
+ }),
293
+ error && /* @__PURE__ */ (0, import_jsx_runtime.jsx)(StatusAlert, {
294
+ status: "danger",
295
+ title: "Could not add the device",
296
+ dataCy: "shelly-add-error",
297
+ children: error
298
+ }),
299
+ /* @__PURE__ */ (0, import_jsx_runtime.jsxs)("div", {
300
+ className: "sh:flex sh:justify-end sh:gap-2 sh:pt-2",
301
+ children: [/* @__PURE__ */ (0, import_jsx_runtime.jsx)(Button$5, {
302
+ variant: "secondary",
303
+ onPress: close,
304
+ children: "Cancel"
305
+ }), /* @__PURE__ */ (0, import_jsx_runtime.jsxs)(Button$5, {
306
+ variant: "primary",
307
+ type: "submit",
308
+ isPending: submitting,
309
+ onPress: submit,
310
+ "data-cy": "shelly-add-submit",
311
+ children: [/* @__PURE__ */ (0, import_jsx_runtime.jsx)(PlusIcon$1, { className: "sh:h-4 sh:w-4" }), " Add device"]
312
+ })]
313
+ }),
314
+ /* @__PURE__ */ (0, import_jsx_runtime.jsx)("input", {
315
+ type: "submit",
316
+ hidden: true
317
+ })
318
+ ]
319
+ }) })]
320
+ });
321
+ }
322
+ //#endregion
323
+ //#region frontend/src/AdminPasswordDrawer.tsx
324
+ var { Button: Button$4, DrawerBody: DrawerBody$3, DrawerFooter: DrawerFooter$2, DrawerHeader: DrawerHeader$3, Form: Form$2 } = await importShared("@heroui/react");
325
+ var { KeyRoundIcon: KeyRoundIcon$1, XIcon: XIcon$3 } = await importShared("lucide-react");
326
+ var { useCallback: useCallback$4, useEffect: useEffect$3, useState: useState$4 } = await importShared("react");
327
+ function AdminPasswordDrawer({ device, onOpenChange, onSaved }) {
328
+ const [currentPassword, setCurrentPassword] = useState$4("");
329
+ const [password, setPassword] = useState$4("");
330
+ const [submitting, setSubmitting] = useState$4(false);
331
+ const [error, setError] = useState$4(null);
332
+ useEffect$3(() => {
333
+ if (device) {
334
+ setCurrentPassword("");
335
+ setPassword("");
336
+ setError(null);
337
+ }
338
+ }, [device]);
339
+ const close = useCallback$4(() => onOpenChange(false), [onOpenChange]);
340
+ const submit = useCallback$4(async () => {
341
+ if (!device) return;
342
+ const nextPassword = password.trim();
343
+ if (!nextPassword) {
344
+ setError("New password is required.");
345
+ return;
346
+ }
347
+ setSubmitting(true);
348
+ setError(null);
349
+ try {
350
+ await setAdminPassword(device.id, {
351
+ currentPassword: currentPassword || void 0,
352
+ password: nextPassword
353
+ });
354
+ onSaved();
355
+ close();
356
+ } catch (err) {
357
+ setError(err instanceof Error ? err.message : String(err));
358
+ } finally {
359
+ setSubmitting(false);
360
+ }
361
+ }, [
362
+ close,
363
+ currentPassword,
364
+ device,
365
+ onSaved,
366
+ password
367
+ ]);
368
+ const onSubmit = useCallback$4((event) => {
369
+ event.preventDefault();
370
+ submit();
371
+ }, [submit]);
372
+ return /* @__PURE__ */ (0, import_jsx_runtime.jsxs)(StandardDrawer, {
373
+ isOpen: !!device,
374
+ onOpenChange,
375
+ children: [
376
+ /* @__PURE__ */ (0, import_jsx_runtime.jsx)(DrawerHeader$3, { children: /* @__PURE__ */ (0, import_jsx_runtime.jsxs)("div", {
377
+ className: "sh:flex sh:w-full sh:items-start sh:justify-between sh:gap-3",
378
+ children: [/* @__PURE__ */ (0, import_jsx_runtime.jsxs)("div", {
379
+ className: "sh:flex sh:min-w-0 sh:flex-col sh:gap-1",
380
+ children: [/* @__PURE__ */ (0, import_jsx_runtime.jsxs)("div", {
381
+ className: "sh:flex sh:items-center sh:gap-2",
382
+ children: [/* @__PURE__ */ (0, import_jsx_runtime.jsx)(KeyRoundIcon$1, { className: "sh:h-5 sh:w-5 sh:shrink-0 sh:text-accent-soft-foreground" }), /* @__PURE__ */ (0, import_jsx_runtime.jsx)("h2", {
383
+ className: "sh:text-lg sh:font-semibold",
384
+ children: "Admin password"
385
+ })]
386
+ }), device && /* @__PURE__ */ (0, import_jsx_runtime.jsxs)("p", {
387
+ className: "sh:text-sm sh:text-muted",
388
+ children: [
389
+ "Set or change the admin password of ",
390
+ device.name,
391
+ " (",
392
+ device.ipAddress,
393
+ ")."
394
+ ]
395
+ })]
396
+ }), /* @__PURE__ */ (0, import_jsx_runtime.jsx)(Button$4, {
397
+ isIconOnly: true,
398
+ variant: "ghost",
399
+ "aria-label": "Close",
400
+ onPress: close,
401
+ children: /* @__PURE__ */ (0, import_jsx_runtime.jsx)(XIcon$3, { size: 16 })
402
+ })]
403
+ }) }),
404
+ /* @__PURE__ */ (0, import_jsx_runtime.jsx)(DrawerBody$3, { children: /* @__PURE__ */ (0, import_jsx_runtime.jsxs)(Form$2, {
405
+ onSubmit,
406
+ className: "sh:flex sh:flex-col sh:gap-4",
407
+ children: [
408
+ device?.authState === "required" && /* @__PURE__ */ (0, import_jsx_runtime.jsx)(PasswordFieldRow, {
409
+ label: "Current password",
410
+ value: currentPassword,
411
+ onChange: setCurrentPassword,
412
+ description: "Required because this device already has authentication enabled.",
413
+ autoComplete: "current-password",
414
+ dataCy: "shelly-auth-current-password"
415
+ }),
416
+ /* @__PURE__ */ (0, import_jsx_runtime.jsx)(PasswordFieldRow, {
417
+ label: "New admin password",
418
+ value: password,
419
+ onChange: setPassword,
420
+ description: "Protects the device's local web interface and API.",
421
+ required: true,
422
+ autoComplete: "new-password",
423
+ dataCy: "shelly-auth-password"
424
+ }),
425
+ error && /* @__PURE__ */ (0, import_jsx_runtime.jsx)(StatusAlert, {
426
+ status: "danger",
427
+ title: "Could not set password",
428
+ dataCy: "shelly-auth-error",
429
+ children: error
430
+ }),
431
+ /* @__PURE__ */ (0, import_jsx_runtime.jsx)("input", {
432
+ type: "submit",
433
+ hidden: true
434
+ })
435
+ ]
436
+ }) }),
437
+ /* @__PURE__ */ (0, import_jsx_runtime.jsxs)(DrawerFooter$2, { children: [/* @__PURE__ */ (0, import_jsx_runtime.jsx)(Button$4, {
438
+ variant: "secondary",
439
+ onPress: close,
440
+ children: "Cancel"
441
+ }), /* @__PURE__ */ (0, import_jsx_runtime.jsxs)(Button$4, {
442
+ variant: "primary",
443
+ isPending: submitting,
444
+ onPress: () => void submit(),
445
+ "data-cy": "shelly-auth-submit",
446
+ children: [/* @__PURE__ */ (0, import_jsx_runtime.jsx)(KeyRoundIcon$1, { className: "sh:h-4 sh:w-4" }), " Save password"]
447
+ })] })
448
+ ]
449
+ });
450
+ }
451
+ //#endregion
452
+ //#region frontend/src/DeviceInfoDrawer.tsx
453
+ var { Button: Button$3, Card, DrawerBody: DrawerBody$2, DrawerFooter: DrawerFooter$1, DrawerHeader: DrawerHeader$2, Form: Form$1, Skeleton } = await importShared("@heroui/react");
454
+ var { EyeIcon, EyeOffIcon, InfoIcon: InfoIcon$1, RefreshCwIcon: RefreshCwIcon$2, XIcon: XIcon$2 } = await importShared("lucide-react");
455
+ var { useCallback: useCallback$3, useEffect: useEffect$2, useState: useState$3 } = await importShared("react");
456
+ function generationLabel$1(generation) {
457
+ if (generation === null) return "Unknown";
458
+ return generation === 1 ? "Gen 1" : `Gen ${generation}+`;
459
+ }
460
+ function isRecord(value) {
461
+ return typeof value === "object" && value !== null && !Array.isArray(value);
462
+ }
463
+ function readPath(source, path) {
464
+ return path.split(".").reduce((value, key) => {
465
+ if (Array.isArray(value)) return value[Number(key)];
466
+ return isRecord(value) ? value[key] : void 0;
467
+ }, source);
468
+ }
469
+ function firstValue(source, paths) {
470
+ for (const path of paths) {
471
+ const value = readPath(source, path);
472
+ if (value !== void 0 && value !== null && value !== "") return value;
473
+ }
474
+ }
475
+ function formatValue(value, suffix = "") {
476
+ if (value === void 0 || value === null || value === "") return "Not reported";
477
+ if (typeof value === "boolean") return value ? "On" : "Off";
478
+ if (typeof value === "number") return `${Number.isInteger(value) ? value : value.toFixed(1)}${suffix}`;
479
+ return String(value);
480
+ }
481
+ function formatUptime(seconds) {
482
+ if (typeof seconds !== "number") return formatValue(seconds);
483
+ const days = Math.floor(seconds / 86400);
484
+ const hours = Math.floor(seconds % 86400 / 3600);
485
+ const minutes = Math.floor(seconds % 3600 / 60);
486
+ if (days > 0) return `${days}d ${hours}h ${minutes}m`;
487
+ if (hours > 0) return `${hours}h ${minutes}m`;
488
+ return `${minutes}m`;
489
+ }
490
+ function AuthProtectedForm({ authState, currentPassword, onChange, loading, onLoad }) {
491
+ const [visible, setVisible] = useState$3(false);
492
+ if (authState !== "required") return null;
493
+ return /* @__PURE__ */ (0, import_jsx_runtime.jsx)("div", {
494
+ className: "sh:rounded-md sh:border-l-4 sh:border-l-warning sh:bg-warning/5 sh:p-4",
495
+ children: /* @__PURE__ */ (0, import_jsx_runtime.jsxs)(Form$1, {
496
+ onSubmit: (e) => {
497
+ e.preventDefault();
498
+ onLoad();
499
+ },
500
+ className: "sh:flex sh:flex-col sh:gap-3",
501
+ children: [
502
+ /* @__PURE__ */ (0, import_jsx_runtime.jsx)("p", {
503
+ className: "sh:text-sm",
504
+ children: "This device requires authentication. Enter its admin password to read protected info."
505
+ }),
506
+ /* @__PURE__ */ (0, import_jsx_runtime.jsxs)("div", {
507
+ className: "sh:relative",
508
+ children: [/* @__PURE__ */ (0, import_jsx_runtime.jsx)(TextFieldRow, {
509
+ label: "Admin password",
510
+ value: currentPassword,
511
+ onChange,
512
+ placeholder: "device admin password",
513
+ dataCy: "shelly-info-current-password"
514
+ }), /* @__PURE__ */ (0, import_jsx_runtime.jsx)(Button$3, {
515
+ isIconOnly: true,
516
+ variant: "ghost",
517
+ size: "sm",
518
+ "aria-label": visible ? "Hide password" : "Show password",
519
+ className: "sh:absolute sh:right-1 sh:top-6",
520
+ onPress: () => setVisible((v) => !v),
521
+ children: visible ? /* @__PURE__ */ (0, import_jsx_runtime.jsx)(EyeOffIcon, { className: "sh:h-4 sh:w-4" }) : /* @__PURE__ */ (0, import_jsx_runtime.jsx)(EyeIcon, { className: "sh:h-4 sh:w-4" })
522
+ })]
523
+ }),
524
+ /* @__PURE__ */ (0, import_jsx_runtime.jsx)("div", {
525
+ className: "sh:flex sh:justify-end",
526
+ children: /* @__PURE__ */ (0, import_jsx_runtime.jsx)(Button$3, {
527
+ variant: "primary",
528
+ size: "sm",
529
+ isPending: loading,
530
+ onPress: onLoad,
531
+ "data-cy": "shelly-info-unlock",
532
+ children: "Load info"
533
+ })
534
+ }),
535
+ /* @__PURE__ */ (0, import_jsx_runtime.jsx)("input", {
536
+ type: "submit",
537
+ hidden: true
538
+ })
539
+ ]
540
+ })
541
+ });
542
+ }
543
+ function DeviceInfoCards({ info }) {
544
+ const status = info.status;
545
+ const config = info.config;
546
+ const output = firstValue(status, [
547
+ "switch:0.output",
548
+ "relays.0.ison",
549
+ "lights.0.ison"
550
+ ]);
551
+ const power = firstValue(status, [
552
+ "switch:0.apower",
553
+ "meters.0.power",
554
+ "lights.0.power"
555
+ ]);
556
+ const voltage = firstValue(status, ["switch:0.voltage", "meters.0.voltage"]);
557
+ const current = firstValue(status, ["switch:0.current", "meters.0.current"]);
558
+ const cards = [
559
+ {
560
+ title: "Device",
561
+ rows: [
562
+ {
563
+ label: "Name",
564
+ value: formatValue(firstValue(config, [
565
+ "sys.device.name",
566
+ "name",
567
+ "device.name"
568
+ ]))
569
+ },
570
+ {
571
+ label: "Generation",
572
+ value: generationLabel$1(info.generation)
573
+ },
574
+ {
575
+ label: "Timezone",
576
+ value: formatValue(firstValue(config, ["sys.location.tz", "timezone"]))
577
+ },
578
+ {
579
+ label: "Uptime",
580
+ value: formatUptime(firstValue(status, ["sys.uptime", "uptime"]))
581
+ }
582
+ ]
583
+ },
584
+ {
585
+ title: "Network",
586
+ rows: [
587
+ {
588
+ label: "IP address",
589
+ value: formatValue(firstValue(status, [
590
+ "wifi.sta_ip",
591
+ "wifi_sta.ip",
592
+ "sta_ip"
593
+ ]))
594
+ },
595
+ {
596
+ label: "Wi-Fi network",
597
+ value: formatValue(firstValue(status, [
598
+ "wifi.ssid",
599
+ "wifi_sta.ssid",
600
+ "ssid"
601
+ ]))
602
+ },
603
+ {
604
+ label: "Signal",
605
+ value: formatValue(firstValue(status, ["wifi.rssi", "wifi_sta.rssi"]), " dBm")
606
+ }
607
+ ]
608
+ },
609
+ {
610
+ title: "Output",
611
+ rows: [
612
+ {
613
+ label: "State",
614
+ value: formatValue(output)
615
+ },
616
+ {
617
+ label: "Power",
618
+ value: formatValue(power, " W")
619
+ },
620
+ {
621
+ label: "Voltage",
622
+ value: formatValue(voltage, " V")
623
+ },
624
+ {
625
+ label: "Current",
626
+ value: formatValue(current, " A")
627
+ }
628
+ ]
629
+ }
630
+ ];
631
+ return /* @__PURE__ */ (0, import_jsx_runtime.jsx)("div", {
632
+ className: "sh:grid sh:gap-4",
633
+ children: cards.map((card) => /* @__PURE__ */ (0, import_jsx_runtime.jsxs)(Card, { children: [/* @__PURE__ */ (0, import_jsx_runtime.jsx)(Card.Header, {
634
+ className: "sh:pb-0",
635
+ children: /* @__PURE__ */ (0, import_jsx_runtime.jsx)("span", {
636
+ className: "sh:text-sm sh:font-semibold",
637
+ children: card.title
638
+ })
639
+ }), /* @__PURE__ */ (0, import_jsx_runtime.jsx)(Card.Content, { children: /* @__PURE__ */ (0, import_jsx_runtime.jsx)("dl", {
640
+ className: "sh:grid sh:grid-cols-1 sh:gap-3 sh:sm:grid-cols-2",
641
+ children: card.rows.map((row) => /* @__PURE__ */ (0, import_jsx_runtime.jsxs)("div", {
642
+ className: "sh:min-w-0",
643
+ children: [/* @__PURE__ */ (0, import_jsx_runtime.jsx)("dt", {
644
+ className: "sh:text-xs sh:font-medium sh:uppercase sh:tracking-wide sh:text-default-500",
645
+ children: row.label
646
+ }), /* @__PURE__ */ (0, import_jsx_runtime.jsx)("dd", {
647
+ className: "sh:mt-1 sh:truncate sh:text-sm sh:text-default-800",
648
+ title: row.value,
649
+ children: row.value
650
+ })]
651
+ }, row.label))
652
+ }) })] }, card.title))
653
+ });
654
+ }
655
+ function DeviceInfoDrawer({ device, onOpenChange }) {
656
+ const [info, setInfo] = useState$3(null);
657
+ const [currentPassword, setCurrentPassword] = useState$3("");
658
+ const [loading, setLoading] = useState$3(false);
659
+ const [error, setError] = useState$3(null);
660
+ useEffect$2(() => {
661
+ if (device) {
662
+ setInfo(null);
663
+ setCurrentPassword("");
664
+ setError(null);
665
+ }
666
+ }, [device]);
667
+ const close = useCallback$3(() => onOpenChange(false), [onOpenChange]);
668
+ const load = useCallback$3(async () => {
669
+ if (!device) return;
670
+ setLoading(true);
671
+ setError(null);
672
+ try {
673
+ setInfo(await getDeviceInfo(device.id, { currentPassword: currentPassword || void 0 }));
674
+ } catch (err) {
675
+ setError(err instanceof Error ? err.message : String(err));
676
+ } finally {
677
+ setLoading(false);
678
+ }
679
+ }, [device, currentPassword]);
680
+ useEffect$2(() => {
681
+ if (device && device.authState !== "required") load();
682
+ }, [device]);
683
+ return /* @__PURE__ */ (0, import_jsx_runtime.jsxs)(StandardDrawer, {
684
+ isOpen: !!device,
685
+ onOpenChange,
686
+ children: [
687
+ /* @__PURE__ */ (0, import_jsx_runtime.jsx)(DrawerHeader$2, { children: /* @__PURE__ */ (0, import_jsx_runtime.jsxs)("div", {
688
+ className: "sh:flex sh:w-full sh:items-start sh:justify-between sh:gap-3",
689
+ children: [/* @__PURE__ */ (0, import_jsx_runtime.jsxs)("div", {
690
+ className: "sh:flex sh:min-w-0 sh:flex-col sh:gap-1",
691
+ children: [/* @__PURE__ */ (0, import_jsx_runtime.jsxs)("div", {
692
+ className: "sh:flex sh:items-center sh:gap-2",
693
+ children: [/* @__PURE__ */ (0, import_jsx_runtime.jsx)(InfoIcon$1, { className: "sh:h-5 sh:w-5 sh:shrink-0 sh:text-accent-soft-foreground" }), /* @__PURE__ */ (0, import_jsx_runtime.jsx)("h2", {
694
+ className: "sh:text-lg sh:font-semibold",
695
+ children: device?.name ?? "Device info"
696
+ })]
697
+ }), device && /* @__PURE__ */ (0, import_jsx_runtime.jsx)("p", {
698
+ className: "sh:text-sm sh:text-muted",
699
+ children: device.ipAddress
700
+ })]
701
+ }), /* @__PURE__ */ (0, import_jsx_runtime.jsx)(Button$3, {
702
+ isIconOnly: true,
703
+ variant: "ghost",
704
+ "aria-label": "Close",
705
+ onPress: close,
706
+ children: /* @__PURE__ */ (0, import_jsx_runtime.jsx)(XIcon$2, { size: 16 })
707
+ })]
708
+ }) }),
709
+ /* @__PURE__ */ (0, import_jsx_runtime.jsx)(DrawerBody$2, { children: /* @__PURE__ */ (0, import_jsx_runtime.jsxs)("div", {
710
+ className: "sh:flex sh:flex-col sh:gap-4",
711
+ children: [
712
+ /* @__PURE__ */ (0, import_jsx_runtime.jsx)(AuthProtectedForm, {
713
+ authState: device?.authState ?? "unknown",
714
+ currentPassword,
715
+ onChange: setCurrentPassword,
716
+ loading,
717
+ onLoad: () => void load()
718
+ }),
719
+ error && /* @__PURE__ */ (0, import_jsx_runtime.jsx)(StatusAlert, {
720
+ status: "danger",
721
+ title: "Could not load device info",
722
+ children: error
723
+ }),
724
+ loading && !info ? /* @__PURE__ */ (0, import_jsx_runtime.jsxs)("div", {
725
+ className: "sh:flex sh:flex-col sh:gap-4",
726
+ "aria-hidden": "true",
727
+ children: [
728
+ /* @__PURE__ */ (0, import_jsx_runtime.jsx)(Skeleton, { className: "sh:h-32 sh:w-full sh:rounded-xl" }),
729
+ /* @__PURE__ */ (0, import_jsx_runtime.jsx)(Skeleton, { className: "sh:h-32 sh:w-full sh:rounded-xl" }),
730
+ /* @__PURE__ */ (0, import_jsx_runtime.jsx)(Skeleton, { className: "sh:h-32 sh:w-full sh:rounded-xl" })
731
+ ]
732
+ }) : info ? /* @__PURE__ */ (0, import_jsx_runtime.jsx)(DeviceInfoCards, { info }) : null
733
+ ]
734
+ }) }),
735
+ /* @__PURE__ */ (0, import_jsx_runtime.jsx)(DrawerFooter$1, { children: /* @__PURE__ */ (0, import_jsx_runtime.jsxs)("div", {
736
+ className: "sh:flex sh:w-full sh:items-center sh:justify-between sh:gap-3",
737
+ children: [/* @__PURE__ */ (0, import_jsx_runtime.jsx)("span", {
738
+ className: "sh:text-xs sh:text-default-500",
739
+ children: info ? `Updated ${new Date(info.fetchedAt).toLocaleTimeString()}` : ""
740
+ }), /* @__PURE__ */ (0, import_jsx_runtime.jsxs)(Button$3, {
741
+ variant: "secondary",
742
+ onPress: () => void load(),
743
+ isPending: loading,
744
+ "data-cy": "shelly-info-refresh",
745
+ children: [/* @__PURE__ */ (0, import_jsx_runtime.jsx)(RefreshCwIcon$2, { className: "sh:h-4 sh:w-4" }), " Refresh"]
746
+ })]
747
+ }) })
748
+ ]
749
+ });
750
+ }
751
+ //#endregion
752
+ //#region frontend/src/DiscoverDrawer.tsx
753
+ var { Button: Button$2, Chip: Chip$1, DrawerBody: DrawerBody$1, DrawerHeader: DrawerHeader$1, Form, Spinner: Spinner$2 } = await importShared("@heroui/react");
754
+ var { SearchIcon: SearchIcon$1, XIcon: XIcon$1 } = await importShared("lucide-react");
755
+ var { useCallback: useCallback$2, useRef: useRef$1, useState: useState$2 } = await importShared("react");
756
+ function ResultSummary({ result }) {
757
+ const added = result.devices.filter((device) => device.isNew).length;
758
+ const scanned = result.subnets.length > 0 ? result.subnets.join(", ") : "no subnet (mDNS only)";
759
+ return /* @__PURE__ */ (0, import_jsx_runtime.jsxs)("div", {
760
+ className: "sh:flex sh:flex-col sh:gap-3",
761
+ "data-cy": "shelly-discover-result",
762
+ children: [
763
+ /* @__PURE__ */ (0, import_jsx_runtime.jsxs)(StatusAlert, {
764
+ status: result.devices.length > 0 ? "success" : "warning",
765
+ title: result.devices.length > 0 ? `Found ${result.devices.length}, added ${added}` : "No devices found",
766
+ children: [
767
+ "Probed ",
768
+ result.probed,
769
+ " address",
770
+ result.probed === 1 ? "" : "es",
771
+ " in ",
772
+ scanned,
773
+ "."
774
+ ]
775
+ }),
776
+ result.devices.length === 0 && /* @__PURE__ */ (0, import_jsx_runtime.jsxs)("p", {
777
+ className: "sh:text-sm sh:text-muted",
778
+ children: [
779
+ "Nothing answered ",
780
+ /* @__PURE__ */ (0, import_jsx_runtime.jsx)("code", { children: "GET /shelly" }),
781
+ ". If Attraccess runs in a container, enter the subnet your devices are on — the container's own network is not your LAN."
782
+ ]
783
+ }),
784
+ /* @__PURE__ */ (0, import_jsx_runtime.jsx)("ul", {
785
+ className: "sh:flex sh:flex-col sh:gap-2",
786
+ children: result.devices.map((device) => /* @__PURE__ */ (0, import_jsx_runtime.jsxs)("li", {
787
+ className: "sh:flex sh:flex-wrap sh:items-center sh:justify-between sh:gap-2 sh:rounded-lg sh:bg-surface sh:px-3 sh:py-2",
788
+ "data-cy": `shelly-discovered-${device.deviceId}`,
789
+ children: [/* @__PURE__ */ (0, import_jsx_runtime.jsxs)("div", {
790
+ className: "sh:min-w-0",
791
+ children: [/* @__PURE__ */ (0, import_jsx_runtime.jsx)("div", {
792
+ className: "sh:truncate sh:font-medium sh:text-foreground",
793
+ children: device.name
794
+ }), /* @__PURE__ */ (0, import_jsx_runtime.jsxs)("div", {
795
+ className: "sh:text-xs sh:text-muted",
796
+ children: [
797
+ device.ipAddress,
798
+ " · Gen ",
799
+ device.generation,
800
+ " · via ",
801
+ device.source === "mdns" ? "mDNS" : "subnet scan"
802
+ ]
803
+ })]
804
+ }), /* @__PURE__ */ (0, import_jsx_runtime.jsx)(Chip$1, {
805
+ variant: "soft",
806
+ color: device.isNew ? "success" : "default",
807
+ children: device.isNew ? "Added" : "Already known"
808
+ })]
809
+ }, device.deviceId))
810
+ })
811
+ ]
812
+ });
813
+ }
814
+ function DiscoverDrawer({ isOpen, onOpenChange, onDiscovered }) {
815
+ const [cidr, setCidr] = useState$2("");
816
+ const [running, setRunning] = useState$2(false);
817
+ const [error, setError] = useState$2(null);
818
+ const [result, setResult] = useState$2(null);
819
+ const close = useCallback$2(() => onOpenChange(false), [onOpenChange]);
820
+ const inFlight = useRef$1(false);
821
+ const submit = useCallback$2(async () => {
822
+ if (inFlight.current) return;
823
+ inFlight.current = true;
824
+ setRunning(true);
825
+ setError(null);
826
+ setResult(null);
827
+ try {
828
+ const discovered = await discoverDevices({ cidr: cidr.trim() || void 0 });
829
+ setResult(discovered);
830
+ onDiscovered();
831
+ } catch (err) {
832
+ setError(err instanceof Error ? err.message : String(err));
833
+ } finally {
834
+ inFlight.current = false;
835
+ setRunning(false);
836
+ }
837
+ }, [cidr, onDiscovered]);
838
+ return /* @__PURE__ */ (0, import_jsx_runtime.jsxs)(StandardDrawer, {
839
+ isOpen,
840
+ onOpenChange,
841
+ children: [/* @__PURE__ */ (0, import_jsx_runtime.jsx)(DrawerHeader$1, { children: /* @__PURE__ */ (0, import_jsx_runtime.jsxs)("div", {
842
+ className: "sh:flex sh:w-full sh:items-start sh:justify-between sh:gap-3",
843
+ children: [/* @__PURE__ */ (0, import_jsx_runtime.jsxs)("div", {
844
+ className: "sh:flex sh:flex-col sh:gap-1",
845
+ children: [/* @__PURE__ */ (0, import_jsx_runtime.jsx)("h2", {
846
+ className: "sh:text-lg sh:font-semibold",
847
+ children: "Discover devices"
848
+ }), /* @__PURE__ */ (0, import_jsx_runtime.jsx)("p", {
849
+ className: "sh:text-sm sh:text-muted",
850
+ children: "Listens for Shelly devices announcing over mDNS, then probes every address of a subnet. Everything found is added to the registry."
851
+ })]
852
+ }), /* @__PURE__ */ (0, import_jsx_runtime.jsx)(Button$2, {
853
+ isIconOnly: true,
854
+ variant: "ghost",
855
+ "aria-label": "Close",
856
+ onPress: close,
857
+ children: /* @__PURE__ */ (0, import_jsx_runtime.jsx)(XIcon$1, { size: 16 })
858
+ })]
859
+ }) }), /* @__PURE__ */ (0, import_jsx_runtime.jsx)(DrawerBody$1, { children: /* @__PURE__ */ (0, import_jsx_runtime.jsxs)(Form, {
860
+ onSubmit: submit,
861
+ className: "sh:flex sh:flex-col sh:gap-4",
862
+ children: [
863
+ /* @__PURE__ */ (0, import_jsx_runtime.jsx)(TextFieldRow, {
864
+ label: "Subnet to scan (optional)",
865
+ value: cidr,
866
+ onChange: setCidr,
867
+ placeholder: "192.168.1.0/24",
868
+ description: "Leave empty to scan the server's own networks. Private ranges only, /22 at most. Required when Attraccess runs in a container, since its network is not your LAN.",
869
+ dataCy: "shelly-discover-cidr"
870
+ }),
871
+ error && /* @__PURE__ */ (0, import_jsx_runtime.jsx)(StatusAlert, {
872
+ status: "danger",
873
+ title: "Discovery failed",
874
+ dataCy: "shelly-discover-error",
875
+ children: error
876
+ }),
877
+ running && /* @__PURE__ */ (0, import_jsx_runtime.jsxs)("div", {
878
+ className: "sh:flex sh:items-center sh:gap-3 sh:text-sm sh:text-muted",
879
+ children: [/* @__PURE__ */ (0, import_jsx_runtime.jsx)(Spinner$2, {
880
+ color: "accent",
881
+ size: "sm"
882
+ }), "Probing addresses — a /24 takes a few seconds."]
883
+ }),
884
+ result && !running && /* @__PURE__ */ (0, import_jsx_runtime.jsx)(ResultSummary, { result }),
885
+ /* @__PURE__ */ (0, import_jsx_runtime.jsxs)("div", {
886
+ className: "sh:flex sh:justify-end sh:gap-2 sh:pt-2",
887
+ children: [/* @__PURE__ */ (0, import_jsx_runtime.jsx)(Button$2, {
888
+ variant: "secondary",
889
+ onPress: close,
890
+ children: result ? "Done" : "Cancel"
891
+ }), /* @__PURE__ */ (0, import_jsx_runtime.jsxs)(Button$2, {
892
+ variant: "primary",
893
+ type: "submit",
894
+ isPending: running,
895
+ onPress: submit,
896
+ "data-cy": "shelly-discover-submit",
897
+ children: [/* @__PURE__ */ (0, import_jsx_runtime.jsx)(SearchIcon$1, { className: "sh:h-4 sh:w-4" }), " Start discovery"]
898
+ })]
899
+ }),
900
+ /* @__PURE__ */ (0, import_jsx_runtime.jsx)("input", {
901
+ type: "submit",
902
+ hidden: true
903
+ })
904
+ ]
905
+ }) })]
906
+ });
907
+ }
908
+ //#endregion
909
+ //#region frontend/src/FirmwareDrawer.tsx
910
+ var { Button: Button$1, DrawerBody, DrawerFooter, DrawerHeader, Spinner: Spinner$1, Tooltip: Tooltip$1 } = await importShared("@heroui/react");
911
+ var { ArrowUpCircleIcon, CpuIcon: CpuIcon$1, DownloadIcon, RefreshCwIcon: RefreshCwIcon$1, XIcon } = await importShared("lucide-react");
912
+ var { useCallback: useCallback$1, useEffect: useEffect$1, useRef, useState: useState$1 } = await importShared("react");
913
+ var POLL_INTERVAL_MS = 5e3;
914
+ var UPDATE_TIMEOUT_MS = 3e5;
915
+ var STAGE_LABEL = {
916
+ stable: "stable",
917
+ beta: "beta"
918
+ };
919
+ /**
920
+ * Gen1 reports versions as `20230913-114150/v1.14.0` — only the tail is useful
921
+ * at a glance, the full string stays in the tooltip.
922
+ */
923
+ function shortVersion(version) {
924
+ const tail = version.split("/").pop();
925
+ return tail && tail.length > 0 ? tail : version;
926
+ }
927
+ /** Table cell summarising a device's firmware state from the bulk overview. */
928
+ function FirmwareCell({ entry }) {
929
+ if (!entry) return /* @__PURE__ */ (0, import_jsx_runtime.jsx)("span", {
930
+ className: "sh:text-sm sh:text-default-400",
931
+ children: "Checking…"
932
+ });
933
+ if (entry.error || !entry.status) return /* @__PURE__ */ (0, import_jsx_runtime.jsx)("span", {
934
+ className: "sh:block sh:max-w-40 sh:truncate sh:text-sm sh:text-default-400",
935
+ title: entry.error ?? void 0,
936
+ children: "Unavailable"
937
+ });
938
+ return /* @__PURE__ */ (0, import_jsx_runtime.jsxs)("div", {
939
+ className: "sh:flex sh:flex-col sh:gap-1",
940
+ children: [/* @__PURE__ */ (0, import_jsx_runtime.jsx)("span", {
941
+ className: "sh:block sh:max-w-40 sh:truncate sh:text-sm sh:text-default-700",
942
+ title: entry.status.currentVersion ?? void 0,
943
+ children: entry.status.currentVersion ? shortVersion(entry.status.currentVersion) : "Unknown"
944
+ }), entry.status.hasUpdate && entry.status.available.stable && /* @__PURE__ */ (0, import_jsx_runtime.jsxs)("span", {
945
+ className: "sh:max-w-40 sh:truncate sh:text-xs sh:text-warning-600",
946
+ title: entry.status.available.stable,
947
+ children: [shortVersion(entry.status.available.stable), " available"]
948
+ })]
949
+ });
950
+ }
951
+ /**
952
+ * "Update available" marker next to the device name, so the signal survives the
953
+ * breakpoints where the Firmware column is hidden. Icon-only on purpose: a text
954
+ * chip here widens the Device column enough to push the row actions out of the
955
+ * table's visible width on tablets.
956
+ */
957
+ function UpdateAvailableIndicator({ entry }) {
958
+ const version = entry?.status?.hasUpdate ? entry.status.available.stable : null;
959
+ if (!version) return null;
960
+ const label = `Firmware update available: ${shortVersion(version)}`;
961
+ return /* @__PURE__ */ (0, import_jsx_runtime.jsxs)(Tooltip$1, { children: [/* @__PURE__ */ (0, import_jsx_runtime.jsx)(Tooltip$1.Trigger, { children: /* @__PURE__ */ (0, import_jsx_runtime.jsx)(Button$1, {
962
+ variant: "ghost",
963
+ size: "sm",
964
+ isIconOnly: true,
965
+ "aria-label": label,
966
+ className: "sh:h-6 sh:w-6 sh:min-w-6 sh:text-warning",
967
+ "data-cy": "shelly-update-available",
968
+ children: /* @__PURE__ */ (0, import_jsx_runtime.jsx)(ArrowUpCircleIcon, { className: "sh:h-4 sh:w-4" })
969
+ }) }), /* @__PURE__ */ (0, import_jsx_runtime.jsx)(Tooltip$1.Content, { children: label })] });
970
+ }
971
+ function VersionRow({ label, value }) {
972
+ return /* @__PURE__ */ (0, import_jsx_runtime.jsxs)("div", {
973
+ className: "sh:flex sh:items-baseline sh:justify-between sh:gap-3 sh:border-b sh:border-default-200 sh:py-2 sh:last:border-b-0",
974
+ children: [/* @__PURE__ */ (0, import_jsx_runtime.jsx)("span", {
975
+ className: "sh:text-xs sh:font-medium sh:uppercase sh:tracking-wide sh:text-default-500",
976
+ children: label
977
+ }), /* @__PURE__ */ (0, import_jsx_runtime.jsx)("span", {
978
+ className: "sh:min-w-0 sh:truncate sh:text-sm sh:text-default-800",
979
+ title: value,
980
+ children: value
981
+ })]
982
+ });
983
+ }
984
+ function FirmwareDetails({ status }) {
985
+ if (!status) return /* @__PURE__ */ (0, import_jsx_runtime.jsx)("div", {
986
+ className: "sh:rounded-xl sh:border sh:border-dashed sh:border-default-300 sh:p-4 sh:text-sm sh:text-default-500",
987
+ children: "No firmware info loaded yet."
988
+ });
989
+ return /* @__PURE__ */ (0, import_jsx_runtime.jsxs)("section", {
990
+ className: "sh:rounded-xl sh:border sh:border-default-200 sh:bg-surface sh:p-4",
991
+ "data-cy": "shelly-firmware-details",
992
+ children: [
993
+ /* @__PURE__ */ (0, import_jsx_runtime.jsx)(VersionRow, {
994
+ label: "Installed",
995
+ value: status.currentVersion ?? "Unknown"
996
+ }),
997
+ /* @__PURE__ */ (0, import_jsx_runtime.jsx)(VersionRow, {
998
+ label: "Stable channel",
999
+ value: status.available.stable ?? "Up to date"
1000
+ }),
1001
+ /* @__PURE__ */ (0, import_jsx_runtime.jsx)(VersionRow, {
1002
+ label: "Beta channel",
1003
+ value: status.available.beta ?? "Nothing newer"
1004
+ }),
1005
+ /* @__PURE__ */ (0, import_jsx_runtime.jsx)(VersionRow, {
1006
+ label: "Checked",
1007
+ value: new Date(status.fetchedAt).toLocaleString()
1008
+ })
1009
+ ]
1010
+ });
1011
+ }
1012
+ function FirmwareDrawer({ device, onOpenChange, onUpdated }) {
1013
+ const [status, setStatus] = useState$1(null);
1014
+ const [currentPassword, setCurrentPassword] = useState$1("");
1015
+ const [loading, setLoading] = useState$1(false);
1016
+ const [error, setError] = useState$1(null);
1017
+ const [installing, setInstalling] = useState$1(null);
1018
+ const [installedVersion, setInstalledVersion] = useState$1(null);
1019
+ const targetVersion = useRef(null);
1020
+ const deadline = useRef(0);
1021
+ const close = useCallback$1(() => onOpenChange(false), [onOpenChange]);
1022
+ const fetchStatus = useCallback$1(async () => {
1023
+ if (!device) return null;
1024
+ return getFirmware(device.id, { currentPassword: currentPassword || void 0 });
1025
+ }, [device, currentPassword]);
1026
+ const load = useCallback$1(async () => {
1027
+ if (!device) return;
1028
+ setLoading(true);
1029
+ setError(null);
1030
+ try {
1031
+ setStatus(await fetchStatus());
1032
+ } catch (err) {
1033
+ setError(err instanceof Error ? err.message : String(err));
1034
+ } finally {
1035
+ setLoading(false);
1036
+ }
1037
+ }, [device, fetchStatus]);
1038
+ useEffect$1(() => {
1039
+ if (!device) return;
1040
+ setStatus(null);
1041
+ setInstalling(null);
1042
+ setInstalledVersion(null);
1043
+ setError(null);
1044
+ load();
1045
+ }, [device]);
1046
+ const install = useCallback$1(async (stage) => {
1047
+ if (!device) return;
1048
+ setError(null);
1049
+ setInstalling(stage);
1050
+ targetVersion.current = status?.available[stage] ?? null;
1051
+ deadline.current = Date.now() + UPDATE_TIMEOUT_MS;
1052
+ try {
1053
+ await startFirmwareUpdate(device.id, {
1054
+ stage,
1055
+ currentPassword: currentPassword || void 0
1056
+ });
1057
+ } catch (err) {
1058
+ setInstalling(null);
1059
+ setError(err instanceof Error ? err.message : String(err));
1060
+ }
1061
+ }, [
1062
+ currentPassword,
1063
+ device,
1064
+ status
1065
+ ]);
1066
+ useEffect$1(() => {
1067
+ if (!installing || !device) return;
1068
+ let cancelled = false;
1069
+ const poll = async () => {
1070
+ if (cancelled) return;
1071
+ if (Date.now() > deadline.current) {
1072
+ setInstalling(null);
1073
+ setError("The device did not report the expected firmware version within 5 minutes. Check it and re-check the firmware manually.");
1074
+ return;
1075
+ }
1076
+ try {
1077
+ const next = await fetchStatus();
1078
+ if (cancelled || !next) return;
1079
+ setStatus(next);
1080
+ if ((targetVersion.current ? next.currentVersion === targetVersion.current : !next.hasUpdate) && next.state !== "updating" && next.state !== "pending") {
1081
+ setInstalling(null);
1082
+ setInstalledVersion(next.currentVersion);
1083
+ onUpdated();
1084
+ }
1085
+ } catch {}
1086
+ };
1087
+ const timer = setInterval(poll, POLL_INTERVAL_MS);
1088
+ return () => {
1089
+ cancelled = true;
1090
+ clearInterval(timer);
1091
+ };
1092
+ }, [
1093
+ installing,
1094
+ device,
1095
+ fetchStatus,
1096
+ onUpdated
1097
+ ]);
1098
+ return /* @__PURE__ */ (0, import_jsx_runtime.jsxs)(StandardDrawer, {
1099
+ isOpen: !!device,
1100
+ onOpenChange,
1101
+ children: [
1102
+ /* @__PURE__ */ (0, import_jsx_runtime.jsx)(DrawerHeader, { children: /* @__PURE__ */ (0, import_jsx_runtime.jsxs)("div", {
1103
+ className: "sh:flex sh:w-full sh:items-start sh:justify-between sh:gap-3",
1104
+ children: [/* @__PURE__ */ (0, import_jsx_runtime.jsxs)("div", {
1105
+ className: "sh:flex sh:min-w-0 sh:flex-col sh:gap-1",
1106
+ children: [/* @__PURE__ */ (0, import_jsx_runtime.jsxs)("div", {
1107
+ className: "sh:flex sh:items-center sh:gap-2",
1108
+ children: [/* @__PURE__ */ (0, import_jsx_runtime.jsx)(CpuIcon$1, { className: "sh:h-5 sh:w-5 sh:shrink-0 sh:text-accent-soft-foreground" }), /* @__PURE__ */ (0, import_jsx_runtime.jsx)("h2", {
1109
+ className: "sh:text-lg sh:font-semibold",
1110
+ children: "Firmware"
1111
+ })]
1112
+ }), device && /* @__PURE__ */ (0, import_jsx_runtime.jsxs)("p", {
1113
+ className: "sh:text-sm sh:text-muted",
1114
+ children: [
1115
+ "Check for and install firmware updates on ",
1116
+ device.name,
1117
+ " (",
1118
+ device.ipAddress,
1119
+ ")."
1120
+ ]
1121
+ })]
1122
+ }), /* @__PURE__ */ (0, import_jsx_runtime.jsx)(Button$1, {
1123
+ isIconOnly: true,
1124
+ variant: "ghost",
1125
+ "aria-label": "Close",
1126
+ onPress: close,
1127
+ children: /* @__PURE__ */ (0, import_jsx_runtime.jsx)(XIcon, { size: 16 })
1128
+ })]
1129
+ }) }),
1130
+ /* @__PURE__ */ (0, import_jsx_runtime.jsx)(DrawerBody, { children: /* @__PURE__ */ (0, import_jsx_runtime.jsxs)("div", {
1131
+ className: "sh:flex sh:flex-col sh:gap-4",
1132
+ children: [
1133
+ device?.authState === "required" && /* @__PURE__ */ (0, import_jsx_runtime.jsx)(PasswordFieldRow, {
1134
+ label: "Current password",
1135
+ value: currentPassword,
1136
+ onChange: setCurrentPassword,
1137
+ description: "Required because this device already has authentication enabled.",
1138
+ autoComplete: "current-password",
1139
+ dataCy: "shelly-firmware-current-password"
1140
+ }),
1141
+ error && /* @__PURE__ */ (0, import_jsx_runtime.jsx)(StatusAlert, {
1142
+ status: "danger",
1143
+ title: "Firmware check failed",
1144
+ dataCy: "shelly-firmware-error",
1145
+ children: error
1146
+ }),
1147
+ installing && /* @__PURE__ */ (0, import_jsx_runtime.jsx)(StatusAlert, {
1148
+ status: "accent",
1149
+ title: "Update running",
1150
+ dataCy: "shelly-firmware-progress",
1151
+ children: /* @__PURE__ */ (0, import_jsx_runtime.jsxs)("span", {
1152
+ className: "sh:flex sh:items-center sh:gap-2",
1153
+ children: [
1154
+ /* @__PURE__ */ (0, import_jsx_runtime.jsx)(Spinner$1, {
1155
+ size: "sm",
1156
+ color: "accent"
1157
+ }),
1158
+ "Installing the ",
1159
+ STAGE_LABEL[installing],
1160
+ " firmware",
1161
+ targetVersion.current ? ` (${targetVersion.current})` : "",
1162
+ ". The device reboots during the update and is offline for a moment — this page keeps checking."
1163
+ ]
1164
+ })
1165
+ }),
1166
+ installedVersion && !installing && /* @__PURE__ */ (0, import_jsx_runtime.jsxs)(StatusAlert, {
1167
+ status: "success",
1168
+ title: "Update finished",
1169
+ dataCy: "shelly-firmware-success",
1170
+ children: [
1171
+ "The device now runs ",
1172
+ installedVersion,
1173
+ "."
1174
+ ]
1175
+ }),
1176
+ loading && !status ? /* @__PURE__ */ (0, import_jsx_runtime.jsx)("div", {
1177
+ className: "sh:flex sh:items-center sh:justify-center sh:p-6",
1178
+ children: /* @__PURE__ */ (0, import_jsx_runtime.jsx)(Spinner$1, { color: "accent" })
1179
+ }) : /* @__PURE__ */ (0, import_jsx_runtime.jsx)(FirmwareDetails, { status })
1180
+ ]
1181
+ }) }),
1182
+ /* @__PURE__ */ (0, import_jsx_runtime.jsxs)(DrawerFooter, {
1183
+ className: "sh:flex-wrap",
1184
+ children: [/* @__PURE__ */ (0, import_jsx_runtime.jsxs)(Button$1, {
1185
+ variant: "secondary",
1186
+ onPress: load,
1187
+ isPending: loading,
1188
+ isDisabled: !!installing,
1189
+ "data-cy": "shelly-firmware-refresh",
1190
+ children: [/* @__PURE__ */ (0, import_jsx_runtime.jsx)(RefreshCwIcon$1, { className: "sh:h-4 sh:w-4" }), " Check again"]
1191
+ }), ["stable", "beta"].map((stage) => {
1192
+ const version = status?.available[stage];
1193
+ if (!version) return null;
1194
+ return /* @__PURE__ */ (0, import_jsx_runtime.jsxs)(Button$1, {
1195
+ variant: stage === "stable" ? "primary" : "secondary",
1196
+ onPress: () => install(stage),
1197
+ isPending: installing === stage,
1198
+ isDisabled: !!installing,
1199
+ "data-cy": `shelly-firmware-install-${stage}`,
1200
+ "aria-label": `Install ${STAGE_LABEL[stage]} firmware ${version}`,
1201
+ children: [
1202
+ /* @__PURE__ */ (0, import_jsx_runtime.jsx)(DownloadIcon, { className: "sh:h-4 sh:w-4" }),
1203
+ " Install ",
1204
+ STAGE_LABEL[stage],
1205
+ " ",
1206
+ shortVersion(version)
1207
+ ]
1208
+ }, stage);
1209
+ })]
1210
+ })
1211
+ ]
1212
+ });
1213
+ }
1214
+ //#endregion
1215
+ //#region frontend/src/DevicesPage.tsx
1216
+ var { Button, Chip, Dropdown, DropdownItem, DropdownMenu, DropdownPopover, DropdownTrigger, Modal, ModalBackdrop, ModalBody, ModalContainer, ModalDialog, ModalFooter, ModalHeader, ModalHeading, Spinner, Table, TableBody, TableCell, TableColumn, TableContent, TableHeader, TableRow, TableScrollContainer, Tooltip, useOverlayState } = await importShared("@heroui/react");
1217
+ var { CpuIcon, InfoIcon, KeyRoundIcon, MehIcon, MoreVerticalIcon, PlusIcon, RefreshCwIcon, SearchIcon, Trash2Icon, TriangleAlertIcon, WifiIcon: WifiIcon$1 } = await importShared("lucide-react");
1218
+ var { useCallback, useEffect, useState } = await importShared("react");
1219
+ function generationLabel(generation) {
1220
+ if (generation === null) return "Unknown";
1221
+ return generation === 1 ? "Gen 1" : `Gen ${generation}+`;
1222
+ }
1223
+ function AuthChip({ state }) {
1224
+ const { color, label } = {
1225
+ none: {
1226
+ color: "success",
1227
+ label: "No auth"
1228
+ },
1229
+ required: {
1230
+ color: "warning",
1231
+ label: "Auth required"
1232
+ },
1233
+ unknown: {
1234
+ color: "default",
1235
+ label: "Unknown"
1236
+ }
1237
+ }[state];
1238
+ return /* @__PURE__ */ (0, import_jsx_runtime.jsx)(Chip, {
1239
+ variant: "soft",
1240
+ color,
1241
+ size: "sm",
1242
+ className: "sh:whitespace-nowrap",
1243
+ children: label
1244
+ });
1245
+ }
1246
+ function ProbeErrorIndicator({ message }) {
1247
+ return /* @__PURE__ */ (0, import_jsx_runtime.jsxs)(Tooltip, { children: [/* @__PURE__ */ (0, import_jsx_runtime.jsx)(Tooltip.Trigger, { children: /* @__PURE__ */ (0, import_jsx_runtime.jsx)(Button, {
1248
+ variant: "ghost",
1249
+ size: "sm",
1250
+ isIconOnly: true,
1251
+ "aria-label": `Probe failed: ${message}`,
1252
+ className: "sh:h-6 sh:w-6 sh:min-w-6 sh:text-warning",
1253
+ "data-cy": "shelly-device-probe-error",
1254
+ children: /* @__PURE__ */ (0, import_jsx_runtime.jsx)(TriangleAlertIcon, { className: "sh:h-4 sh:w-4" })
1255
+ }) }), /* @__PURE__ */ (0, import_jsx_runtime.jsxs)(Tooltip.Content, { children: ["Probe failed: ", message] })] });
1256
+ }
1257
+ function RowActions({ deviceId, isBusy, onInfo, onFirmware, onAuth, onReprobe, onDelete }) {
1258
+ return /* @__PURE__ */ (0, import_jsx_runtime.jsxs)("div", {
1259
+ className: "sh:flex sh:flex-row sh:items-center sh:justify-end sh:gap-1 sh:whitespace-nowrap",
1260
+ children: [/* @__PURE__ */ (0, import_jsx_runtime.jsxs)(Tooltip, { children: [/* @__PURE__ */ (0, import_jsx_runtime.jsx)(Tooltip.Trigger, { children: /* @__PURE__ */ (0, import_jsx_runtime.jsx)(Button, {
1261
+ variant: "ghost",
1262
+ size: "sm",
1263
+ isIconOnly: true,
1264
+ "aria-label": "View device info",
1265
+ isDisabled: isBusy,
1266
+ onPress: onInfo,
1267
+ "data-cy": `shelly-device-info-${deviceId}`,
1268
+ children: /* @__PURE__ */ (0, import_jsx_runtime.jsx)(InfoIcon, { className: "sh:h-4 sh:w-4" })
1269
+ }) }), /* @__PURE__ */ (0, import_jsx_runtime.jsx)(Tooltip.Content, { children: "View device info" })] }), /* @__PURE__ */ (0, import_jsx_runtime.jsxs)(Dropdown, { children: [/* @__PURE__ */ (0, import_jsx_runtime.jsx)(DropdownTrigger, { children: /* @__PURE__ */ (0, import_jsx_runtime.jsx)(Button, {
1270
+ variant: "ghost",
1271
+ size: "sm",
1272
+ isIconOnly: true,
1273
+ "aria-label": "More actions",
1274
+ isPending: isBusy,
1275
+ "data-cy": `shelly-device-menu-${deviceId}`,
1276
+ children: /* @__PURE__ */ (0, import_jsx_runtime.jsx)(MoreVerticalIcon, { className: "sh:h-4 sh:w-4" })
1277
+ }) }), /* @__PURE__ */ (0, import_jsx_runtime.jsx)(DropdownPopover, { children: /* @__PURE__ */ (0, import_jsx_runtime.jsxs)(DropdownMenu, {
1278
+ "aria-label": "Device actions",
1279
+ children: [
1280
+ /* @__PURE__ */ (0, import_jsx_runtime.jsxs)(DropdownItem, {
1281
+ id: "firmware",
1282
+ onPress: onFirmware,
1283
+ "data-cy": `shelly-device-firmware-${deviceId}`,
1284
+ children: [/* @__PURE__ */ (0, import_jsx_runtime.jsx)(CpuIcon, { className: "sh:mr-2 sh:inline sh:h-4 sh:w-4" }), " Manage firmware"]
1285
+ }),
1286
+ /* @__PURE__ */ (0, import_jsx_runtime.jsxs)(DropdownItem, {
1287
+ id: "auth",
1288
+ onPress: onAuth,
1289
+ "data-cy": `shelly-device-auth-${deviceId}`,
1290
+ children: [/* @__PURE__ */ (0, import_jsx_runtime.jsx)(KeyRoundIcon, { className: "sh:mr-2 sh:inline sh:h-4 sh:w-4" }), " Set admin password"]
1291
+ }),
1292
+ /* @__PURE__ */ (0, import_jsx_runtime.jsxs)(DropdownItem, {
1293
+ id: "reprobe",
1294
+ onPress: onReprobe,
1295
+ "data-cy": `shelly-device-reprobe-${deviceId}`,
1296
+ children: [/* @__PURE__ */ (0, import_jsx_runtime.jsx)(RefreshCwIcon, { className: "sh:mr-2 sh:inline sh:h-4 sh:w-4" }), " Re-probe device"]
1297
+ }),
1298
+ /* @__PURE__ */ (0, import_jsx_runtime.jsxs)(DropdownItem, {
1299
+ id: "delete",
1300
+ onPress: onDelete,
1301
+ className: "sh:text-danger",
1302
+ "data-cy": `shelly-device-delete-${deviceId}`,
1303
+ children: [/* @__PURE__ */ (0, import_jsx_runtime.jsx)(Trash2Icon, { className: "sh:mr-2 sh:inline sh:h-4 sh:w-4" }), " Delete device"]
1304
+ })
1305
+ ]
1306
+ }) })] })]
1307
+ });
1308
+ }
1309
+ function EmptyDevices({ onAdd }) {
1310
+ return /* @__PURE__ */ (0, import_jsx_runtime.jsxs)("div", {
1311
+ className: "sh:flex sh:flex-col sh:items-center sh:justify-center sh:gap-3 sh:px-4 sh:py-12",
1312
+ children: [
1313
+ /* @__PURE__ */ (0, import_jsx_runtime.jsx)(MehIcon, {
1314
+ size: 36,
1315
+ className: "sh:text-default-300"
1316
+ }),
1317
+ /* @__PURE__ */ (0, import_jsx_runtime.jsx)("p", {
1318
+ className: "sh:text-sm sh:text-default-500",
1319
+ children: "No devices yet. Run discovery, or add your first Shelly by its IP."
1320
+ }),
1321
+ /* @__PURE__ */ (0, import_jsx_runtime.jsxs)(Button, {
1322
+ variant: "secondary",
1323
+ size: "sm",
1324
+ onPress: onAdd,
1325
+ "data-cy": "shelly-add-open-empty",
1326
+ children: [/* @__PURE__ */ (0, import_jsx_runtime.jsx)(PlusIcon, { className: "sh:h-4 sh:w-4" }), " Add device"]
1327
+ })
1328
+ ]
1329
+ });
1330
+ }
1331
+ function DevicesPage() {
1332
+ const [devices, setDevices] = useState([]);
1333
+ const [loading, setLoading] = useState(true);
1334
+ const [pageError, setPageError] = useState(null);
1335
+ const [rowBusyId, setRowBusyId] = useState(null);
1336
+ const [infoDevice, setInfoDevice] = useState(null);
1337
+ const [authDevice, setAuthDevice] = useState(null);
1338
+ const [firmwareDevice, setFirmwareDevice] = useState(null);
1339
+ const [firmware, setFirmware] = useState({});
1340
+ const [deleteTarget, setDeleteTarget] = useState(null);
1341
+ const [deleting, setDeleting] = useState(false);
1342
+ const addDrawer = useOverlayState();
1343
+ const discoverDrawer = useOverlayState();
1344
+ const refreshFirmware = useCallback(async (known = []) => {
1345
+ try {
1346
+ const entries = await listFirmware();
1347
+ setFirmware(Object.fromEntries(entries.map((entry) => [entry.deviceId, entry])));
1348
+ } catch (err) {
1349
+ const error = err instanceof Error ? err.message : String(err);
1350
+ setFirmware(Object.fromEntries(known.map((device) => [device.id, {
1351
+ deviceId: device.id,
1352
+ status: null,
1353
+ error
1354
+ }])));
1355
+ }
1356
+ }, []);
1357
+ const refresh = useCallback(async () => {
1358
+ try {
1359
+ const next = await listDevices();
1360
+ setDevices(next);
1361
+ setPageError(null);
1362
+ refreshFirmware(next);
1363
+ } catch (err) {
1364
+ setPageError(err instanceof Error ? err.message : String(err));
1365
+ } finally {
1366
+ setLoading(false);
1367
+ }
1368
+ }, [refreshFirmware]);
1369
+ useEffect(() => {
1370
+ refresh();
1371
+ }, [refresh]);
1372
+ const withRowBusy = useCallback(async (id, action) => {
1373
+ setRowBusyId(id);
1374
+ try {
1375
+ await action();
1376
+ await refresh();
1377
+ } catch (err) {
1378
+ setPageError(err instanceof Error ? err.message : String(err));
1379
+ } finally {
1380
+ setRowBusyId(null);
1381
+ }
1382
+ }, [refresh]);
1383
+ const confirmDelete = useCallback(async () => {
1384
+ if (!deleteTarget) return;
1385
+ setDeleting(true);
1386
+ try {
1387
+ await deleteDevice(deleteTarget.id);
1388
+ await refresh();
1389
+ setDeleteTarget(null);
1390
+ } catch (err) {
1391
+ setPageError(err instanceof Error ? err.message : String(err));
1392
+ setDeleteTarget(null);
1393
+ } finally {
1394
+ setDeleting(false);
1395
+ }
1396
+ }, [deleteTarget, refresh]);
1397
+ return /* @__PURE__ */ (0, import_jsx_runtime.jsxs)("div", {
1398
+ className: "sh:mx-auto sh:flex sh:w-full sh:max-w-5xl sh:flex-col sh:gap-6 sh:p-4 sh:md:p-6",
1399
+ children: [
1400
+ /* @__PURE__ */ (0, import_jsx_runtime.jsxs)("div", {
1401
+ className: "sh:flex sh:w-full sh:flex-wrap sh:items-center sh:justify-between sh:gap-y-4",
1402
+ children: [/* @__PURE__ */ (0, import_jsx_runtime.jsxs)("div", {
1403
+ className: "sh:flex sh:items-center sh:gap-3",
1404
+ children: [/* @__PURE__ */ (0, import_jsx_runtime.jsx)(WifiIcon$1, { className: "sh:h-6 sh:w-6 sh:text-accent-soft-foreground" }), /* @__PURE__ */ (0, import_jsx_runtime.jsxs)("div", { children: [/* @__PURE__ */ (0, import_jsx_runtime.jsx)("h1", {
1405
+ className: "sh:text-2xl sh:font-bold",
1406
+ children: "Shelly Devices"
1407
+ }), /* @__PURE__ */ (0, import_jsx_runtime.jsx)("p", {
1408
+ className: "sh:mt-1 sh:text-sm sh:text-muted",
1409
+ children: "Discovered and manually added Shelly devices."
1410
+ })] })]
1411
+ }), /* @__PURE__ */ (0, import_jsx_runtime.jsxs)("div", {
1412
+ className: "sh:flex sh:flex-wrap sh:gap-2",
1413
+ children: [/* @__PURE__ */ (0, import_jsx_runtime.jsxs)(Button, {
1414
+ variant: "secondary",
1415
+ onPress: discoverDrawer.open,
1416
+ "data-cy": "shelly-discover-open",
1417
+ children: [/* @__PURE__ */ (0, import_jsx_runtime.jsx)(SearchIcon, { className: "sh:h-4 sh:w-4" }), " Discover"]
1418
+ }), /* @__PURE__ */ (0, import_jsx_runtime.jsxs)(Button, {
1419
+ variant: "primary",
1420
+ onPress: addDrawer.open,
1421
+ "data-cy": "shelly-add-open",
1422
+ children: [/* @__PURE__ */ (0, import_jsx_runtime.jsx)(PlusIcon, { className: "sh:h-4 sh:w-4" }), " Add device"]
1423
+ })]
1424
+ })]
1425
+ }),
1426
+ pageError && /* @__PURE__ */ (0, import_jsx_runtime.jsx)(StatusAlert, {
1427
+ status: "danger",
1428
+ title: "Failed to load devices",
1429
+ children: pageError
1430
+ }),
1431
+ loading ? /* @__PURE__ */ (0, import_jsx_runtime.jsx)("div", {
1432
+ className: "sh:flex sh:items-center sh:justify-center sh:p-6",
1433
+ children: /* @__PURE__ */ (0, import_jsx_runtime.jsx)(Spinner, { color: "accent" })
1434
+ }) : /* @__PURE__ */ (0, import_jsx_runtime.jsx)(Table, {
1435
+ "data-cy": "shelly-device-table",
1436
+ children: /* @__PURE__ */ (0, import_jsx_runtime.jsx)(TableScrollContainer, { children: /* @__PURE__ */ (0, import_jsx_runtime.jsxs)(TableContent, {
1437
+ "aria-label": "Shelly devices",
1438
+ children: [/* @__PURE__ */ (0, import_jsx_runtime.jsxs)(TableHeader, { children: [
1439
+ /* @__PURE__ */ (0, import_jsx_runtime.jsx)(TableColumn, {
1440
+ isRowHeader: true,
1441
+ children: "Device"
1442
+ }),
1443
+ /* @__PURE__ */ (0, import_jsx_runtime.jsx)(TableColumn, {
1444
+ className: "sh:hidden sh:sm:table-cell sh:md:hidden sh:lg:table-cell",
1445
+ children: "Address"
1446
+ }),
1447
+ /* @__PURE__ */ (0, import_jsx_runtime.jsx)(TableColumn, {
1448
+ className: "sh:hidden sh:lg:table-cell",
1449
+ children: "Model"
1450
+ }),
1451
+ /* @__PURE__ */ (0, import_jsx_runtime.jsx)(TableColumn, {
1452
+ className: "sh:hidden sh:sm:table-cell",
1453
+ children: "Auth"
1454
+ }),
1455
+ /* @__PURE__ */ (0, import_jsx_runtime.jsx)(TableColumn, {
1456
+ className: "sh:hidden sh:xl:table-cell",
1457
+ children: "Firmware"
1458
+ }),
1459
+ /* @__PURE__ */ (0, import_jsx_runtime.jsx)(TableColumn, {
1460
+ className: "sh:text-end",
1461
+ children: "Actions"
1462
+ })
1463
+ ] }), /* @__PURE__ */ (0, import_jsx_runtime.jsx)(TableBody, {
1464
+ items: devices,
1465
+ dependencies: [firmware, rowBusyId],
1466
+ renderEmptyState: () => /* @__PURE__ */ (0, import_jsx_runtime.jsx)(EmptyDevices, { onAdd: addDrawer.open }),
1467
+ children: (device) => /* @__PURE__ */ (0, import_jsx_runtime.jsxs)(TableRow, {
1468
+ id: device.id,
1469
+ "data-cy": `shelly-device-row-${device.id}`,
1470
+ children: [
1471
+ /* @__PURE__ */ (0, import_jsx_runtime.jsxs)(TableCell, {
1472
+ className: "sh:whitespace-nowrap",
1473
+ children: [/* @__PURE__ */ (0, import_jsx_runtime.jsxs)("div", {
1474
+ className: "sh:flex sh:items-center sh:gap-1",
1475
+ children: [
1476
+ /* @__PURE__ */ (0, import_jsx_runtime.jsx)("span", {
1477
+ className: "sh:max-w-36 sh:truncate sh:font-medium sh:text-default-800 sh:sm:max-w-48",
1478
+ title: device.name,
1479
+ children: device.name
1480
+ }),
1481
+ device.lastProbeError && /* @__PURE__ */ (0, import_jsx_runtime.jsx)(ProbeErrorIndicator, { message: device.lastProbeError }),
1482
+ /* @__PURE__ */ (0, import_jsx_runtime.jsx)(UpdateAvailableIndicator, { entry: firmware[device.id] })
1483
+ ]
1484
+ }), /* @__PURE__ */ (0, import_jsx_runtime.jsx)("div", {
1485
+ className: "sh:text-xs sh:text-default-500 sh:sm:hidden sh:md:block sh:lg:hidden",
1486
+ children: device.ipAddress
1487
+ })]
1488
+ }),
1489
+ /* @__PURE__ */ (0, import_jsx_runtime.jsx)(TableCell, {
1490
+ className: "sh:hidden sh:whitespace-nowrap sh:text-default-600 sh:sm:table-cell sh:md:hidden sh:lg:table-cell",
1491
+ children: device.ipAddress
1492
+ }),
1493
+ /* @__PURE__ */ (0, import_jsx_runtime.jsx)(TableCell, {
1494
+ className: "sh:hidden sh:whitespace-nowrap sh:lg:table-cell",
1495
+ children: /* @__PURE__ */ (0, import_jsx_runtime.jsxs)("div", {
1496
+ className: "sh:flex sh:items-center sh:gap-1.5",
1497
+ children: [/* @__PURE__ */ (0, import_jsx_runtime.jsx)("span", {
1498
+ className: "sh:max-w-36 sh:truncate",
1499
+ title: device.model ?? void 0,
1500
+ children: device.model ?? "—"
1501
+ }), /* @__PURE__ */ (0, import_jsx_runtime.jsx)(Chip, {
1502
+ variant: "soft",
1503
+ size: "sm",
1504
+ className: "sh:whitespace-nowrap",
1505
+ children: generationLabel(device.generation)
1506
+ })]
1507
+ })
1508
+ }),
1509
+ /* @__PURE__ */ (0, import_jsx_runtime.jsx)(TableCell, {
1510
+ className: "sh:hidden sh:whitespace-nowrap sh:sm:table-cell",
1511
+ children: /* @__PURE__ */ (0, import_jsx_runtime.jsx)(AuthChip, { state: device.authState })
1512
+ }),
1513
+ /* @__PURE__ */ (0, import_jsx_runtime.jsx)(TableCell, {
1514
+ className: "sh:hidden sh:whitespace-nowrap sh:xl:table-cell",
1515
+ children: /* @__PURE__ */ (0, import_jsx_runtime.jsx)(FirmwareCell, { entry: firmware[device.id] })
1516
+ }),
1517
+ /* @__PURE__ */ (0, import_jsx_runtime.jsx)(TableCell, {
1518
+ className: "sh:whitespace-nowrap",
1519
+ children: /* @__PURE__ */ (0, import_jsx_runtime.jsx)(RowActions, {
1520
+ deviceId: device.id,
1521
+ isBusy: rowBusyId === device.id,
1522
+ onInfo: () => setInfoDevice(device),
1523
+ onFirmware: () => setFirmwareDevice(device),
1524
+ onAuth: () => setAuthDevice(device),
1525
+ onReprobe: () => withRowBusy(device.id, () => reprobeDevice(device.id)),
1526
+ onDelete: () => setDeleteTarget(device)
1527
+ })
1528
+ })
1529
+ ]
1530
+ }, device.id)
1531
+ })]
1532
+ }) })
1533
+ }),
1534
+ /* @__PURE__ */ (0, import_jsx_runtime.jsx)(AddDeviceDrawer, {
1535
+ isOpen: addDrawer.isOpen,
1536
+ onOpenChange: addDrawer.setOpen,
1537
+ onAdded: refresh
1538
+ }),
1539
+ /* @__PURE__ */ (0, import_jsx_runtime.jsx)(DiscoverDrawer, {
1540
+ isOpen: discoverDrawer.isOpen,
1541
+ onOpenChange: discoverDrawer.setOpen,
1542
+ onDiscovered: refresh
1543
+ }),
1544
+ /* @__PURE__ */ (0, import_jsx_runtime.jsx)(DeviceInfoDrawer, {
1545
+ device: infoDevice,
1546
+ onOpenChange: (open) => !open && setInfoDevice(null)
1547
+ }),
1548
+ /* @__PURE__ */ (0, import_jsx_runtime.jsx)(AdminPasswordDrawer, {
1549
+ device: authDevice,
1550
+ onOpenChange: (open) => !open && setAuthDevice(null),
1551
+ onSaved: refresh
1552
+ }),
1553
+ /* @__PURE__ */ (0, import_jsx_runtime.jsx)(FirmwareDrawer, {
1554
+ device: firmwareDevice,
1555
+ onOpenChange: (open) => !open && setFirmwareDevice(null),
1556
+ onUpdated: () => void refreshFirmware(devices)
1557
+ }),
1558
+ /* @__PURE__ */ (0, import_jsx_runtime.jsx)(Modal, {
1559
+ isOpen: !!deleteTarget,
1560
+ onOpenChange: (open) => {
1561
+ if (!open) setDeleteTarget(null);
1562
+ },
1563
+ "data-cy": "shelly-delete-confirmation-modal",
1564
+ children: /* @__PURE__ */ (0, import_jsx_runtime.jsx)(ModalBackdrop, { children: /* @__PURE__ */ (0, import_jsx_runtime.jsx)(ModalContainer, {
1565
+ size: "sm",
1566
+ children: /* @__PURE__ */ (0, import_jsx_runtime.jsxs)(ModalDialog, { children: [
1567
+ /* @__PURE__ */ (0, import_jsx_runtime.jsx)(ModalHeader, { children: /* @__PURE__ */ (0, import_jsx_runtime.jsx)(ModalHeading, { children: "Delete device" }) }),
1568
+ /* @__PURE__ */ (0, import_jsx_runtime.jsx)(ModalBody, { children: /* @__PURE__ */ (0, import_jsx_runtime.jsxs)("p", { children: [
1569
+ "Remove ",
1570
+ /* @__PURE__ */ (0, import_jsx_runtime.jsx)("span", {
1571
+ className: "sh:font-semibold",
1572
+ children: deleteTarget?.name
1573
+ }),
1574
+ " (",
1575
+ deleteTarget?.ipAddress,
1576
+ ") from the registry? The device itself is not changed."
1577
+ ] }) }),
1578
+ /* @__PURE__ */ (0, import_jsx_runtime.jsxs)(ModalFooter, { children: [/* @__PURE__ */ (0, import_jsx_runtime.jsx)(Button, {
1579
+ variant: "secondary",
1580
+ onPress: () => setDeleteTarget(null),
1581
+ "data-cy": "shelly-delete-cancel",
1582
+ children: "Cancel"
1583
+ }), /* @__PURE__ */ (0, import_jsx_runtime.jsxs)(Button, {
1584
+ variant: "danger",
1585
+ onPress: confirmDelete,
1586
+ isPending: deleting,
1587
+ "data-cy": "shelly-delete-confirm",
1588
+ children: [/* @__PURE__ */ (0, import_jsx_runtime.jsx)(Trash2Icon, { className: "sh:h-4 sh:w-4" }), " Delete"]
1589
+ })] })
1590
+ ] })
1591
+ }) })
1592
+ })
1593
+ ]
1594
+ });
1595
+ }
1596
+ //#endregion
1597
+ //#region frontend/src/plugin.tsx
1598
+ var { WifiIcon } = await importShared("lucide-react");
1599
+ var ShellyPlugin = class {
1600
+ getPluginName() {
1601
+ return "shelly-plugin@0.1.0";
1602
+ }
1603
+ getDependencies() {
1604
+ return [];
1605
+ }
1606
+ init(_store) {}
1607
+ activate() {}
1608
+ deactivate() {}
1609
+ onApiAuthStateChange(_authData) {}
1610
+ onApiEndpointChange(_endpoint) {}
1611
+ getRoutes() {
1612
+ return [{
1613
+ path: "/shelly",
1614
+ authRequired: "resources.update",
1615
+ element: /* @__PURE__ */ (0, import_jsx_runtime.jsx)(DevicesPage, {})
1616
+ }];
1617
+ }
1618
+ getSidebarItems() {
1619
+ return [{
1620
+ label: "Shelly",
1621
+ path: "/shelly",
1622
+ icon: /* @__PURE__ */ (0, import_jsx_runtime.jsx)(WifiIcon, { className: "sh:w-5 sh:h-5" })
1623
+ }];
1624
+ }
1625
+ };
1626
+ //#endregion
1627
+ export { ShellyPlugin as default };