node-red-contrib-alarm-ultimate 0.1.0

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,728 @@
1
+ <!doctype html>
2
+ <html lang="en">
3
+ <head>
4
+ <meta charset="utf-8" />
5
+ <meta name="viewport" content="width=device-width,initial-scale=1" />
6
+ <title>Alarm Panel</title>
7
+ <style>
8
+ :root {
9
+ color-scheme: light dark;
10
+ --bg: #0b1020;
11
+ --panel: #111936;
12
+ --text: #e7eaf6;
13
+ --muted: #a9b0d2;
14
+ --border: #2a355f;
15
+ --accent: #6ea8fe;
16
+ --ok: #2fbf71;
17
+ --warn: #ffcc66;
18
+ --danger: #ff6b6b;
19
+ --mono: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New",
20
+ monospace;
21
+ --sans: system-ui, -apple-system, Segoe UI, Roboto, Helvetica, Arial, "Apple Color Emoji",
22
+ "Segoe UI Emoji";
23
+ }
24
+
25
+ @media (prefers-color-scheme: light) {
26
+ :root {
27
+ --bg: #f6f8ff;
28
+ --panel: #ffffff;
29
+ --text: #111827;
30
+ --muted: #5b647a;
31
+ --border: #d8deef;
32
+ --accent: #2563eb;
33
+ }
34
+ }
35
+
36
+ body {
37
+ margin: 0;
38
+ background: var(--bg);
39
+ color: var(--text);
40
+ font-family: var(--sans);
41
+ }
42
+
43
+ body.embed header {
44
+ display: none;
45
+ }
46
+
47
+ body.embed main {
48
+ max-width: none;
49
+ padding: 12px;
50
+ }
51
+
52
+ body.embed .grid {
53
+ grid-template-columns: 1fr;
54
+ }
55
+
56
+ header {
57
+ padding: 16px;
58
+ border-bottom: 1px solid var(--border);
59
+ }
60
+
61
+ header h1 {
62
+ margin: 0 0 6px 0;
63
+ font-size: 18px;
64
+ }
65
+
66
+ header p {
67
+ margin: 0;
68
+ color: var(--muted);
69
+ font-size: 13px;
70
+ }
71
+
72
+ main {
73
+ max-width: 1200px;
74
+ margin: 0 auto;
75
+ padding: 16px;
76
+ display: grid;
77
+ gap: 12px;
78
+ }
79
+
80
+ .grid {
81
+ display: grid;
82
+ grid-template-columns: 1fr 1fr;
83
+ gap: 12px;
84
+ }
85
+
86
+ @media (max-width: 980px) {
87
+ .grid {
88
+ grid-template-columns: 1fr;
89
+ }
90
+ }
91
+
92
+ .card {
93
+ border: 1px solid var(--border);
94
+ background: var(--panel);
95
+ border-radius: 10px;
96
+ padding: 12px;
97
+ }
98
+
99
+ .card h2 {
100
+ font-size: 14px;
101
+ margin: 0 0 10px 0;
102
+ }
103
+
104
+ .row {
105
+ display: grid;
106
+ grid-template-columns: 140px 1fr;
107
+ gap: 10px;
108
+ align-items: center;
109
+ margin: 8px 0;
110
+ }
111
+
112
+ .row label {
113
+ font-size: 12px;
114
+ color: var(--muted);
115
+ }
116
+
117
+ select,
118
+ input[type="password"],
119
+ input[type="text"] {
120
+ width: 100%;
121
+ border: 1px solid var(--border);
122
+ background: transparent;
123
+ color: var(--text);
124
+ border-radius: 8px;
125
+ padding: 8px 10px;
126
+ box-sizing: border-box;
127
+ font-family: var(--mono);
128
+ font-size: 12px;
129
+ }
130
+
131
+ .buttons {
132
+ display: flex;
133
+ flex-wrap: wrap;
134
+ gap: 8px;
135
+ margin-top: 10px;
136
+ }
137
+
138
+ button {
139
+ border: 1px solid var(--border);
140
+ background: rgba(110, 168, 254, 0.12);
141
+ color: var(--text);
142
+ border-radius: 8px;
143
+ padding: 8px 10px;
144
+ cursor: pointer;
145
+ font-size: 12px;
146
+ }
147
+
148
+ button.primary {
149
+ border-color: rgba(110, 168, 254, 0.5);
150
+ background: rgba(110, 168, 254, 0.22);
151
+ }
152
+
153
+ button.danger {
154
+ border-color: rgba(255, 107, 107, 0.55);
155
+ background: rgba(255, 107, 107, 0.14);
156
+ }
157
+
158
+ .status {
159
+ border: 1px solid var(--border);
160
+ border-radius: 10px;
161
+ padding: 10px;
162
+ display: grid;
163
+ grid-template-columns: 1fr auto;
164
+ gap: 10px;
165
+ align-items: center;
166
+ }
167
+
168
+ .status .left {
169
+ display: grid;
170
+ gap: 2px;
171
+ }
172
+
173
+ .status .title {
174
+ font-family: var(--mono);
175
+ font-size: 13px;
176
+ }
177
+
178
+ .status .meta {
179
+ color: var(--muted);
180
+ font-size: 12px;
181
+ }
182
+
183
+ .pill {
184
+ font-family: var(--mono);
185
+ font-size: 12px;
186
+ padding: 6px 10px;
187
+ border-radius: 999px;
188
+ border: 1px solid var(--border);
189
+ }
190
+
191
+ .pill.ok {
192
+ border-color: rgba(47, 191, 113, 0.55);
193
+ color: var(--ok);
194
+ }
195
+ .pill.warn {
196
+ border-color: rgba(255, 204, 102, 0.55);
197
+ color: var(--warn);
198
+ }
199
+ .pill.danger {
200
+ border-color: rgba(255, 107, 107, 0.55);
201
+ color: var(--danger);
202
+ }
203
+
204
+ table {
205
+ width: 100%;
206
+ border-collapse: collapse;
207
+ font-family: var(--mono);
208
+ font-size: 12px;
209
+ }
210
+
211
+ th,
212
+ td {
213
+ border-bottom: 1px solid var(--border);
214
+ padding: 8px 6px;
215
+ text-align: left;
216
+ vertical-align: top;
217
+ }
218
+
219
+ th {
220
+ color: var(--muted);
221
+ font-weight: 600;
222
+ }
223
+
224
+ .zone-open {
225
+ color: var(--danger);
226
+ }
227
+ .zone-closed {
228
+ color: var(--ok);
229
+ }
230
+ .zone-bypassed {
231
+ color: var(--warn);
232
+ }
233
+
234
+ .kbd {
235
+ display: grid;
236
+ grid-template-columns: repeat(3, 1fr);
237
+ gap: 8px;
238
+ margin-top: 10px;
239
+ }
240
+
241
+ .kbd button {
242
+ padding: 10px;
243
+ font-family: var(--mono);
244
+ font-size: 14px;
245
+ }
246
+
247
+ .hint {
248
+ color: var(--muted);
249
+ font-size: 12px;
250
+ margin: 8px 0 0 0;
251
+ }
252
+ </style>
253
+ </head>
254
+ <body>
255
+ <header>
256
+ <h1>Alarm Panel</h1>
257
+ <p>View all zones and use the keypad to arm/disarm the Alarm System Ultimate node.</p>
258
+ </header>
259
+
260
+ <main>
261
+ <section class="card">
262
+ <h2>Node</h2>
263
+ <div class="row">
264
+ <label for="nodeSelect">Alarm node</label>
265
+ <select id="nodeSelect"></select>
266
+ </div>
267
+ <div class="hint" id="nodeHint"></div>
268
+ <div id="nodeStatus" class="status" style="display: none">
269
+ <div class="left">
270
+ <div class="title" id="statusTitle"></div>
271
+ <div class="meta" id="statusMeta"></div>
272
+ </div>
273
+ <div class="pill" id="statusPill"></div>
274
+ </div>
275
+ </section>
276
+
277
+ <div class="grid">
278
+ <section class="card">
279
+ <h2>Keypad</h2>
280
+ <div class="row">
281
+ <label for="code">Code</label>
282
+ <input id="code" type="password" autocomplete="one-time-code" placeholder="(optional)" />
283
+ </div>
284
+ <div class="buttons">
285
+ <button class="primary" id="btnArm">Arm</button>
286
+ <button class="danger" id="btnDisarm">Disarm</button>
287
+ </div>
288
+ <div class="kbd" aria-label="Keypad">
289
+ <button data-k="1">1</button>
290
+ <button data-k="2">2</button>
291
+ <button data-k="3">3</button>
292
+ <button data-k="4">4</button>
293
+ <button data-k="5">5</button>
294
+ <button data-k="6">6</button>
295
+ <button data-k="7">7</button>
296
+ <button data-k="8">8</button>
297
+ <button data-k="9">9</button>
298
+ <button data-k="clear">Clear</button>
299
+ <button data-k="0">0</button>
300
+ <button data-k="back">Back</button>
301
+ </div>
302
+ <p class="hint">Commands are sent to the selected node using the Node-RED admin HTTP endpoint.</p>
303
+ <p class="hint" id="cmdStatus" style="display: none"></p>
304
+ </section>
305
+
306
+ <section class="card">
307
+ <h2>Zones</h2>
308
+ <div class="hint" id="zonesHint">Loading...</div>
309
+ <div style="overflow: auto; margin-top: 10px">
310
+ <table>
311
+ <thead>
312
+ <tr>
313
+ <th>Zone</th>
314
+ <th>State</th>
315
+ <th>Type</th>
316
+ <th>Topic</th>
317
+ </tr>
318
+ </thead>
319
+ <tbody id="zonesBody"></tbody>
320
+ </table>
321
+ </div>
322
+ </section>
323
+ </div>
324
+ </main>
325
+
326
+ <script>
327
+ const els = {
328
+ nodeSelect: document.getElementById("nodeSelect"),
329
+ nodeHint: document.getElementById("nodeHint"),
330
+ nodeStatus: document.getElementById("nodeStatus"),
331
+ statusTitle: document.getElementById("statusTitle"),
332
+ statusMeta: document.getElementById("statusMeta"),
333
+ statusPill: document.getElementById("statusPill"),
334
+ zonesBody: document.getElementById("zonesBody"),
335
+ zonesHint: document.getElementById("zonesHint"),
336
+ code: document.getElementById("code"),
337
+ cmdStatus: document.getElementById("cmdStatus"),
338
+ btnArm: document.getElementById("btnArm"),
339
+ btnDisarm: document.getElementById("btnDisarm"),
340
+ };
341
+
342
+ const params = new URLSearchParams(window.location.search);
343
+ const preselectId = params.get("id") || "";
344
+ const embedMode = params.get("embed") === "1" || params.get("embed") === "true";
345
+
346
+ if (embedMode) {
347
+ document.body.classList.add("embed");
348
+ }
349
+
350
+ let selectedId = "";
351
+ let pollTimer = null;
352
+
353
+ let audioCtx = null;
354
+ let armingBeepTimer = null;
355
+ let hasSeenState = false;
356
+ let lastMode = null;
357
+ let lastArmingActive = null;
358
+
359
+ function getAudioContext() {
360
+ if (audioCtx) return audioCtx;
361
+ const Ctx = window.AudioContext || window.webkitAudioContext;
362
+ if (!Ctx) return null;
363
+ audioCtx = new Ctx();
364
+ return audioCtx;
365
+ }
366
+
367
+ async function ensureAudioReady() {
368
+ const ctx = getAudioContext();
369
+ if (!ctx) return null;
370
+ try {
371
+ if (ctx.state === "suspended") {
372
+ await ctx.resume();
373
+ }
374
+ } catch (err) {
375
+ return null;
376
+ }
377
+ return ctx;
378
+ }
379
+
380
+ async function playBeep(freq, durationMs, volume, type) {
381
+ const ctx = await ensureAudioReady();
382
+ if (!ctx) return;
383
+
384
+ const now = ctx.currentTime;
385
+ const osc = ctx.createOscillator();
386
+ const gain = ctx.createGain();
387
+
388
+ osc.type = type || "sine";
389
+ osc.frequency.setValueAtTime(Number(freq) || 800, now);
390
+
391
+ const v = Math.max(0.0001, Math.min(Number(volume) || 0.08, 0.2));
392
+ const dur = Math.max(0.01, (Number(durationMs) || 60) / 1000);
393
+
394
+ gain.gain.setValueAtTime(0.0001, now);
395
+ gain.gain.exponentialRampToValueAtTime(v, now + 0.004);
396
+ gain.gain.exponentialRampToValueAtTime(0.0001, now + dur);
397
+
398
+ osc.connect(gain);
399
+ gain.connect(ctx.destination);
400
+
401
+ osc.start(now);
402
+ osc.stop(now + dur);
403
+
404
+ osc.onended = () => {
405
+ try {
406
+ osc.disconnect();
407
+ gain.disconnect();
408
+ } catch (err) {}
409
+ };
410
+ }
411
+
412
+ function stopArmingBeeps() {
413
+ if (!armingBeepTimer) return;
414
+ clearInterval(armingBeepTimer);
415
+ armingBeepTimer = null;
416
+ }
417
+
418
+ function startArmingBeeps() {
419
+ if (armingBeepTimer) return;
420
+ playBeep(660, 50, 0.05, "sine");
421
+ armingBeepTimer = setInterval(() => {
422
+ playBeep(660, 50, 0.05, "sine");
423
+ }, 800);
424
+ }
425
+
426
+ function playModeBeep(mode) {
427
+ if (mode === "armed") {
428
+ playBeep(880, 70, 0.085, "sine");
429
+ setTimeout(() => playBeep(1175, 70, 0.085, "sine"), 110);
430
+ return;
431
+ }
432
+ if (mode === "disarmed") {
433
+ playBeep(587, 90, 0.085, "sine");
434
+ setTimeout(() => playBeep(440, 110, 0.085, "sine"), 140);
435
+ }
436
+ }
437
+
438
+ async function playKeyClick(kind) {
439
+ const ctx = await ensureAudioReady();
440
+ if (!ctx) return;
441
+
442
+ const now = ctx.currentTime;
443
+ const osc = ctx.createOscillator();
444
+ const gain = ctx.createGain();
445
+
446
+ const freq =
447
+ kind === "back" ? 520 : kind === "clear" ? 340 : kind === "action" ? 860 : 740;
448
+
449
+ osc.type = "square";
450
+ osc.frequency.setValueAtTime(freq, now);
451
+
452
+ gain.gain.setValueAtTime(0.0001, now);
453
+ gain.gain.exponentialRampToValueAtTime(0.08, now + 0.002);
454
+ gain.gain.exponentialRampToValueAtTime(0.0001, now + 0.03);
455
+
456
+ osc.connect(gain);
457
+ gain.connect(ctx.destination);
458
+
459
+ osc.start(now);
460
+ osc.stop(now + 0.03);
461
+
462
+ osc.onended = () => {
463
+ try {
464
+ osc.disconnect();
465
+ gain.disconnect();
466
+ } catch (err) {}
467
+ };
468
+ }
469
+
470
+ function resetStateAudioTracking() {
471
+ stopArmingBeeps();
472
+ hasSeenState = false;
473
+ lastMode = null;
474
+ lastArmingActive = null;
475
+ }
476
+
477
+ function handleStateBeeps(state) {
478
+ if (!state) return;
479
+
480
+ const mode = typeof state.mode === "string" ? state.mode : null;
481
+ const armingActive = Boolean(state.arming && state.arming.active);
482
+
483
+ if (!hasSeenState) {
484
+ hasSeenState = true;
485
+ lastMode = mode;
486
+ lastArmingActive = armingActive;
487
+ if (armingActive) startArmingBeeps();
488
+ return;
489
+ }
490
+
491
+ if (armingActive && !lastArmingActive) startArmingBeeps();
492
+ if (!armingActive && lastArmingActive) stopArmingBeeps();
493
+
494
+ if (mode && mode !== lastMode) {
495
+ stopArmingBeeps();
496
+ playModeBeep(mode);
497
+ }
498
+
499
+ lastMode = mode;
500
+ lastArmingActive = armingActive;
501
+ }
502
+
503
+ function httpAdminRoot() {
504
+ const base = window.location.pathname;
505
+ const idx = base.indexOf("/alarm-ultimate/");
506
+ if (idx >= 0) return base.slice(0, idx + 1);
507
+ return "/";
508
+ }
509
+
510
+ function apiUrl(path) {
511
+ const root = httpAdminRoot();
512
+ return root.endsWith("/") ? `${root}${path.replace(/^\//, "")}` : `${root}/${path.replace(/^\//, "")}`;
513
+ }
514
+
515
+ function setNodeHint(text) {
516
+ if (!els.nodeHint) return;
517
+ els.nodeHint.textContent = text || "";
518
+ }
519
+
520
+ function authHeaders() {
521
+ try {
522
+ const raw = localStorage.getItem("auth-tokens");
523
+ if (!raw) return {};
524
+ const tokens = JSON.parse(raw);
525
+ if (!tokens || !tokens.access_token) return {};
526
+ return { Authorization: `Bearer ${tokens.access_token}` };
527
+ } catch (err) {
528
+ return {};
529
+ }
530
+ }
531
+
532
+ function showCmdStatus(text, kind) {
533
+ els.cmdStatus.style.display = "";
534
+ els.cmdStatus.style.color = kind === "err" ? "var(--danger)" : kind === "ok" ? "var(--ok)" : "var(--muted)";
535
+ els.cmdStatus.textContent = text;
536
+ setTimeout(() => {
537
+ els.cmdStatus.style.display = "none";
538
+ }, 2500);
539
+ }
540
+
541
+ function statusFromState(state) {
542
+ if (!state) return { label: "Unknown", cls: "" };
543
+ if (state.alarmActive) return { label: "ALARM", cls: "danger" };
544
+ if (state.entry && state.entry.active) return { label: "ENTRY", cls: "warn" };
545
+ if (state.arming && state.arming.active) return { label: "ARMING", cls: "warn" };
546
+ if (state.mode === "armed") return { label: "ARMED", cls: "ok" };
547
+ return { label: "DISARMED", cls: "" };
548
+ }
549
+
550
+ function renderZones(zones) {
551
+ els.zonesBody.innerHTML = "";
552
+ const list = Array.isArray(zones) ? zones : [];
553
+ if (list.length === 0) {
554
+ els.zonesHint.textContent = "No zones configured.";
555
+ return;
556
+ }
557
+ els.zonesHint.textContent = `${list.length} zones`;
558
+
559
+ for (const z of list) {
560
+ const tr = document.createElement("tr");
561
+ const title = z.name ? `${z.name}` : z.id;
562
+ const stateText = z.bypassed ? "BYPASSED" : z.open ? "OPEN" : "CLOSED";
563
+ const stateClass = z.bypassed ? "zone-bypassed" : z.open ? "zone-open" : "zone-closed";
564
+ const topic = z.topic || z.topicPattern || "";
565
+
566
+ tr.innerHTML = `
567
+ <td>${escapeHtml(title)}<div style="color:var(--muted); font-size:11px">${escapeHtml(z.id || "")}</div></td>
568
+ <td class="${stateClass}">${escapeHtml(stateText)}</td>
569
+ <td>${escapeHtml(z.type || "")}</td>
570
+ <td>${escapeHtml(topic)}</td>
571
+ `;
572
+ els.zonesBody.appendChild(tr);
573
+ }
574
+ }
575
+
576
+ function escapeHtml(text) {
577
+ return String(text || "")
578
+ .replace(/&/g, "&amp;")
579
+ .replace(/</g, "&lt;")
580
+ .replace(/>/g, "&gt;")
581
+ .replace(/"/g, "&quot;")
582
+ .replace(/'/g, "&#039;");
583
+ }
584
+
585
+ async function loadNodes() {
586
+ const res = await fetch(apiUrl("/alarm-ultimate/alarm/nodes"), {
587
+ credentials: "same-origin",
588
+ headers: { ...authHeaders() },
589
+ });
590
+ if (!res.ok) throw new Error(`Unable to load nodes (${res.status})`);
591
+ const data = await res.json();
592
+ const nodes = Array.isArray(data.nodes) ? data.nodes : [];
593
+
594
+ els.nodeSelect.innerHTML = "";
595
+ els.nodeSelect.disabled = nodes.length === 0;
596
+ for (const n of nodes) {
597
+ const opt = document.createElement("option");
598
+ opt.value = n.id;
599
+ opt.textContent = n.name ? `${n.name} (${n.id})` : n.id;
600
+ els.nodeSelect.appendChild(opt);
601
+ }
602
+
603
+ const preferred = nodes.find((n) => n.id === preselectId) ? preselectId : nodes[0] ? nodes[0].id : "";
604
+ selectedId = preferred;
605
+ els.nodeSelect.value = preferred;
606
+ if (!preferred) {
607
+ els.zonesHint.textContent = "No Alarm nodes found.";
608
+ setNodeHint("No Alarm nodes found. Deploy your flow and refresh.");
609
+ } else {
610
+ setNodeHint("");
611
+ }
612
+ }
613
+
614
+ async function loadState() {
615
+ if (!selectedId) return;
616
+ const res = await fetch(apiUrl(`/alarm-ultimate/alarm/${encodeURIComponent(selectedId)}/state`), {
617
+ credentials: "same-origin",
618
+ headers: { ...authHeaders() },
619
+ });
620
+ if (!res.ok) throw new Error(`Unable to load state (${res.status})`);
621
+ const data = await res.json();
622
+ const state = data.state;
623
+ const st = statusFromState(state);
624
+
625
+ els.nodeStatus.style.display = "";
626
+ els.statusTitle.textContent = data.name ? data.name : selectedId;
627
+ const ct = data.controlTopic ? `controlTopic=${data.controlTopic}` : "";
628
+ const openCount = (Array.isArray(data.zones) ? data.zones.filter((z) => z.open && !z.bypassed).length : 0) || 0;
629
+ els.statusMeta.textContent = `${st.label}${ct ? " • " + ct : ""}${openCount ? " • open=" + openCount : ""}`;
630
+ els.statusPill.textContent = st.label;
631
+ els.statusPill.className = `pill ${st.cls}`;
632
+
633
+ renderZones(data.zones);
634
+ handleStateBeeps(state);
635
+ }
636
+
637
+ function startPolling() {
638
+ if (pollTimer) clearInterval(pollTimer);
639
+ pollTimer = setInterval(() => {
640
+ loadState().catch((err) => {
641
+ els.zonesHint.textContent = err.message;
642
+ });
643
+ }, 1000);
644
+ }
645
+
646
+ async function sendCommand(payload) {
647
+ if (!selectedId) return;
648
+ const res = await fetch(apiUrl(`/alarm-ultimate/alarm/${encodeURIComponent(selectedId)}/command`), {
649
+ method: "POST",
650
+ headers: { "content-type": "application/json", ...authHeaders() },
651
+ body: JSON.stringify(payload),
652
+ credentials: "same-origin",
653
+ });
654
+ if (!res.ok) {
655
+ const text = await res.text();
656
+ throw new Error(`Command failed (${res.status}): ${text}`);
657
+ }
658
+ }
659
+
660
+ function codeValue() {
661
+ const v = String(els.code.value || "").trim();
662
+ return v.length ? v : undefined;
663
+ }
664
+
665
+ els.nodeSelect.addEventListener("change", () => {
666
+ selectedId = els.nodeSelect.value;
667
+ resetStateAudioTracking();
668
+ loadState().catch(() => {});
669
+ });
670
+
671
+ els.btnArm.addEventListener("click", async () => {
672
+ playKeyClick("action");
673
+ try {
674
+ await sendCommand({ command: "arm", code: codeValue() });
675
+ showCmdStatus("Arm sent.", "ok");
676
+ } catch (err) {
677
+ showCmdStatus(err.message, "err");
678
+ }
679
+ });
680
+ els.btnDisarm.addEventListener("click", async () => {
681
+ playKeyClick("action");
682
+ try {
683
+ await sendCommand({ command: "disarm", code: codeValue() });
684
+ showCmdStatus("Disarm sent.", "ok");
685
+ } catch (err) {
686
+ showCmdStatus(err.message, "err");
687
+ }
688
+ });
689
+
690
+ document.querySelector(".kbd").addEventListener("click", (e) => {
691
+ const btn = e.target && e.target.closest("button");
692
+ if (!btn) return;
693
+ const k = btn.getAttribute("data-k");
694
+ if (!k) return;
695
+ if (k === "clear") {
696
+ playKeyClick("clear");
697
+ els.code.value = "";
698
+ return;
699
+ }
700
+ if (k === "back") {
701
+ playKeyClick("back");
702
+ els.code.value = String(els.code.value || "").slice(0, -1);
703
+ return;
704
+ }
705
+ playKeyClick("key");
706
+ els.code.value = `${els.code.value || ""}${k}`;
707
+ });
708
+
709
+ els.code.addEventListener("keydown", (e) => {
710
+ if (e.key === "Backspace") playKeyClick("back");
711
+ else if (e.key === "Escape" || e.key === "Delete") playKeyClick("clear");
712
+ else if (/^\d$/.test(e.key)) playKeyClick("key");
713
+ });
714
+
715
+ (async function init() {
716
+ try {
717
+ setNodeHint(`API root: ${httpAdminRoot()}`);
718
+ await loadNodes();
719
+ await loadState();
720
+ startPolling();
721
+ } catch (err) {
722
+ setNodeHint(err.message);
723
+ els.zonesHint.textContent = err.message;
724
+ }
725
+ })();
726
+ </script>
727
+ </body>
728
+ </html>