node-red-contrib-alarm-ultimate 0.1.1 → 0.1.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +14 -0
- package/examples/README.md +93 -3
- package/examples/alarm-ultimate-basic.json +0 -1
- package/examples/alarm-ultimate-dashboard-controls.json +34 -4
- package/examples/alarm-ultimate-dashboard-v2.json +834 -0
- package/examples/alarm-ultimate-dashboard.json +34 -5
- package/examples/alarm-ultimate-home-assistant-alarm-panel.json +335 -0
- package/flowfuse-node-red-dashboard-1.30.2.tgz +0 -0
- package/nodes/AlarmSystemUltimate.html +332 -105
- package/nodes/AlarmSystemUltimate.js +158 -12
- package/nodes/AlarmUltimateInputAdapter.html +304 -0
- package/nodes/AlarmUltimateInputAdapter.js +188 -0
- package/nodes/AlarmUltimateZone.html +2 -2
- package/nodes/AlarmUltimateZone.js +6 -3
- package/nodes/presets/input-adapter/ax-pro-hikvision-ultimate.js +34 -0
- package/nodes/presets/input-adapter/boolean-from-payload.js +10 -0
- package/nodes/presets/input-adapter/ha-on-off.js +24 -0
- package/nodes/presets/input-adapter/knx-ultimate.js +29 -0
- package/nodes/presets/input-adapter/passthrough.js +7 -0
- package/package.json +4 -3
- package/test/alarm-system.spec.js +112 -0
- package/test/input-adapter.spec.js +243 -0
- package/test/output-nodes.spec.js +3 -0
- package/tools/alarm-json-mapper.html +955 -167
- package/tools/alarm-panel.html +995 -139
package/tools/alarm-panel.html
CHANGED
|
@@ -53,6 +53,28 @@
|
|
|
53
53
|
grid-template-columns: 1fr;
|
|
54
54
|
}
|
|
55
55
|
|
|
56
|
+
body.view-keypad .grid,
|
|
57
|
+
body.view-zones .grid {
|
|
58
|
+
grid-template-columns: 1fr;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
body.view-keypad #zonesCard {
|
|
62
|
+
display: none;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
body.view-zones #keypadCard {
|
|
66
|
+
display: none;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
body.view-keypad #logCard,
|
|
70
|
+
body.view-zones #logCard {
|
|
71
|
+
display: none;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
body.view-log .grid {
|
|
75
|
+
display: none;
|
|
76
|
+
}
|
|
77
|
+
|
|
56
78
|
header {
|
|
57
79
|
padding: 16px;
|
|
58
80
|
border-bottom: 1px solid var(--border);
|
|
@@ -143,11 +165,13 @@
|
|
|
143
165
|
padding: 8px 10px;
|
|
144
166
|
cursor: pointer;
|
|
145
167
|
font-size: 12px;
|
|
168
|
+
transition: background-color 120ms ease, border-color 120ms ease, transform 80ms ease;
|
|
146
169
|
}
|
|
147
170
|
|
|
148
171
|
button.primary {
|
|
149
|
-
border-color: rgba(110, 168, 254, 0.
|
|
150
|
-
background:
|
|
172
|
+
border-color: rgba(110, 168, 254, 0.85);
|
|
173
|
+
background: var(--accent);
|
|
174
|
+
color: #fff;
|
|
151
175
|
}
|
|
152
176
|
|
|
153
177
|
button.danger {
|
|
@@ -155,6 +179,62 @@
|
|
|
155
179
|
background: rgba(255, 107, 107, 0.14);
|
|
156
180
|
}
|
|
157
181
|
|
|
182
|
+
button:hover {
|
|
183
|
+
background: rgba(110, 168, 254, 0.18);
|
|
184
|
+
border-color: rgba(110, 168, 254, 0.35);
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
button.primary:hover {
|
|
188
|
+
filter: brightness(1.05);
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
button:active {
|
|
192
|
+
transform: translateY(1px);
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
button.selected {
|
|
196
|
+
border-color: rgba(110, 168, 254, 0.9);
|
|
197
|
+
background: rgba(110, 168, 254, 0.28);
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
button.selected.ok {
|
|
201
|
+
border-color: rgba(47, 191, 113, 0.65);
|
|
202
|
+
background: rgba(47, 191, 113, 0.16);
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
button.selected.warn {
|
|
206
|
+
border-color: rgba(255, 204, 102, 0.65);
|
|
207
|
+
background: rgba(255, 204, 102, 0.14);
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
button.selected.danger {
|
|
211
|
+
border-color: rgba(255, 107, 107, 0.65);
|
|
212
|
+
background: rgba(255, 107, 107, 0.14);
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
button.node {
|
|
216
|
+
display: inline-flex;
|
|
217
|
+
align-items: center;
|
|
218
|
+
gap: 8px;
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
button.node .dot {
|
|
222
|
+
width: 8px;
|
|
223
|
+
height: 8px;
|
|
224
|
+
border-radius: 999px;
|
|
225
|
+
background: var(--border);
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
button.node.ok .dot {
|
|
229
|
+
background: var(--ok);
|
|
230
|
+
}
|
|
231
|
+
button.node.warn .dot {
|
|
232
|
+
background: var(--warn);
|
|
233
|
+
}
|
|
234
|
+
button.node.danger .dot {
|
|
235
|
+
background: var(--danger);
|
|
236
|
+
}
|
|
237
|
+
|
|
158
238
|
.status {
|
|
159
239
|
border: 1px solid var(--border);
|
|
160
240
|
border-radius: 10px;
|
|
@@ -180,6 +260,35 @@
|
|
|
180
260
|
font-size: 12px;
|
|
181
261
|
}
|
|
182
262
|
|
|
263
|
+
.status .meta .lines {
|
|
264
|
+
display: grid;
|
|
265
|
+
gap: 6px;
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
.status .meta .line {
|
|
269
|
+
display: grid;
|
|
270
|
+
grid-template-columns: 180px auto;
|
|
271
|
+
gap: 10px;
|
|
272
|
+
align-items: center;
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
.status .meta .line .name {
|
|
276
|
+
font-family: var(--mono);
|
|
277
|
+
font-size: 12px;
|
|
278
|
+
white-space: nowrap;
|
|
279
|
+
overflow: hidden;
|
|
280
|
+
text-overflow: ellipsis;
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
.status .meta .line .details {
|
|
284
|
+
font-family: var(--mono);
|
|
285
|
+
font-size: 11px;
|
|
286
|
+
color: var(--muted);
|
|
287
|
+
overflow: hidden;
|
|
288
|
+
text-overflow: ellipsis;
|
|
289
|
+
white-space: nowrap;
|
|
290
|
+
}
|
|
291
|
+
|
|
183
292
|
.pill {
|
|
184
293
|
font-family: var(--mono);
|
|
185
294
|
font-size: 12px;
|
|
@@ -201,6 +310,11 @@
|
|
|
201
310
|
color: var(--danger);
|
|
202
311
|
}
|
|
203
312
|
|
|
313
|
+
.pill.small {
|
|
314
|
+
padding: 4px 8px;
|
|
315
|
+
font-size: 11px;
|
|
316
|
+
}
|
|
317
|
+
|
|
204
318
|
table {
|
|
205
319
|
width: 100%;
|
|
206
320
|
border-collapse: collapse;
|
|
@@ -249,6 +363,11 @@
|
|
|
249
363
|
font-size: 12px;
|
|
250
364
|
margin: 8px 0 0 0;
|
|
251
365
|
}
|
|
366
|
+
|
|
367
|
+
.log-details {
|
|
368
|
+
color: var(--muted);
|
|
369
|
+
font-size: 11px;
|
|
370
|
+
}
|
|
252
371
|
</style>
|
|
253
372
|
</head>
|
|
254
373
|
<body>
|
|
@@ -258,29 +377,35 @@
|
|
|
258
377
|
</header>
|
|
259
378
|
|
|
260
379
|
<main>
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
380
|
+
<section class="card">
|
|
381
|
+
<h2>Node</h2>
|
|
382
|
+
<div class="row">
|
|
383
|
+
<label>Alarm nodes</label>
|
|
384
|
+
<div>
|
|
385
|
+
<div class="buttons" id="nodeButtons"></div>
|
|
386
|
+
<div class="hint" id="nodeHint"></div>
|
|
387
|
+
<div class="hint" id="selectionHint"></div>
|
|
388
|
+
<div class="buttons" style="margin-top:8px;">
|
|
389
|
+
<button id="btnSelectAll" type="button">Select all</button>
|
|
390
|
+
</div>
|
|
391
|
+
</div>
|
|
392
|
+
</div>
|
|
393
|
+
<div id="nodeStatus" class="status" style="display: none">
|
|
394
|
+
<div class="left">
|
|
395
|
+
<div class="title" id="statusTitle"></div>
|
|
396
|
+
<div class="meta" id="statusMeta"></div>
|
|
397
|
+
</div>
|
|
273
398
|
<div class="pill" id="statusPill"></div>
|
|
274
399
|
</div>
|
|
275
400
|
</section>
|
|
276
401
|
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
402
|
+
<div class="grid">
|
|
403
|
+
<section class="card" id="keypadCard" data-section="keypad">
|
|
404
|
+
<h2>Keypad</h2>
|
|
405
|
+
<div class="row">
|
|
406
|
+
<label for="code">Code</label>
|
|
407
|
+
<input id="code" type="password" autocomplete="one-time-code" placeholder="(optional)" />
|
|
408
|
+
</div>
|
|
284
409
|
<div class="buttons">
|
|
285
410
|
<button class="primary" id="btnArm">Arm</button>
|
|
286
411
|
<button class="danger" id="btnDisarm">Disarm</button>
|
|
@@ -301,15 +426,20 @@
|
|
|
301
426
|
</div>
|
|
302
427
|
<p class="hint">Commands are sent to the selected node using the Node-RED admin HTTP endpoint.</p>
|
|
303
428
|
<p class="hint" id="cmdStatus" style="display: none"></p>
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
429
|
+
</section>
|
|
430
|
+
|
|
431
|
+
<section class="card" id="zonesCard" data-section="zones">
|
|
432
|
+
<h2>Zones</h2>
|
|
433
|
+
<div class="hint" id="zonesHint">Loading...</div>
|
|
434
|
+
<div class="buttons" style="margin-top:8px;">
|
|
435
|
+
<button id="btnZonesFilterOpen" type="button">OPEN</button>
|
|
436
|
+
<button id="btnZonesFilterAll" type="button">ALL</button>
|
|
437
|
+
</div>
|
|
438
|
+
<div style="overflow: auto; margin-top: 10px">
|
|
439
|
+
<table>
|
|
440
|
+
<thead>
|
|
441
|
+
<tr>
|
|
442
|
+
<th>Alarm</th>
|
|
313
443
|
<th>Zone</th>
|
|
314
444
|
<th>State</th>
|
|
315
445
|
<th>Type</th>
|
|
@@ -321,40 +451,100 @@
|
|
|
321
451
|
</div>
|
|
322
452
|
</section>
|
|
323
453
|
</div>
|
|
454
|
+
|
|
455
|
+
<section class="card" id="logCard" data-section="log">
|
|
456
|
+
<h2>Log</h2>
|
|
457
|
+
<div class="hint" id="logHint">Loading...</div>
|
|
458
|
+
<div class="buttons" style="margin-top:8px;">
|
|
459
|
+
<button id="btnLogFilterAll" type="button">ALL</button>
|
|
460
|
+
<button id="btnLogFilterAlarm" type="button">ALARM</button>
|
|
461
|
+
<button id="btnLogFilterArming" type="button">ARMING</button>
|
|
462
|
+
<button id="btnLogFilterZones" type="button">ZONES</button>
|
|
463
|
+
<button id="btnLogFilterErrors" type="button">ERRORS</button>
|
|
464
|
+
<button id="btnLogDownload" type="button">Download JSON</button>
|
|
465
|
+
</div>
|
|
466
|
+
<div style="overflow:auto; margin-top:10px;">
|
|
467
|
+
<table>
|
|
468
|
+
<thead>
|
|
469
|
+
<tr>
|
|
470
|
+
<th>Alarm</th>
|
|
471
|
+
<th>Time</th>
|
|
472
|
+
<th>Event</th>
|
|
473
|
+
<th>Details</th>
|
|
474
|
+
</tr>
|
|
475
|
+
</thead>
|
|
476
|
+
<tbody id="logBody"></tbody>
|
|
477
|
+
</table>
|
|
478
|
+
</div>
|
|
479
|
+
</section>
|
|
324
480
|
</main>
|
|
325
481
|
|
|
326
482
|
<script>
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
483
|
+
const els = {
|
|
484
|
+
nodeButtons: document.getElementById("nodeButtons"),
|
|
485
|
+
nodeHint: document.getElementById("nodeHint"),
|
|
486
|
+
selectionHint: document.getElementById("selectionHint"),
|
|
487
|
+
btnSelectAll: document.getElementById("btnSelectAll"),
|
|
488
|
+
nodeStatus: document.getElementById("nodeStatus"),
|
|
489
|
+
statusTitle: document.getElementById("statusTitle"),
|
|
490
|
+
statusMeta: document.getElementById("statusMeta"),
|
|
491
|
+
statusPill: document.getElementById("statusPill"),
|
|
492
|
+
zonesBody: document.getElementById("zonesBody"),
|
|
493
|
+
zonesHint: document.getElementById("zonesHint"),
|
|
494
|
+
btnZonesFilterOpen: document.getElementById("btnZonesFilterOpen"),
|
|
495
|
+
btnZonesFilterAll: document.getElementById("btnZonesFilterAll"),
|
|
496
|
+
logBody: document.getElementById("logBody"),
|
|
497
|
+
logHint: document.getElementById("logHint"),
|
|
498
|
+
btnLogFilterAll: document.getElementById("btnLogFilterAll"),
|
|
499
|
+
btnLogFilterAlarm: document.getElementById("btnLogFilterAlarm"),
|
|
500
|
+
btnLogFilterArming: document.getElementById("btnLogFilterArming"),
|
|
501
|
+
btnLogFilterZones: document.getElementById("btnLogFilterZones"),
|
|
502
|
+
btnLogFilterErrors: document.getElementById("btnLogFilterErrors"),
|
|
503
|
+
btnLogDownload: document.getElementById("btnLogDownload"),
|
|
504
|
+
code: document.getElementById("code"),
|
|
505
|
+
cmdStatus: document.getElementById("cmdStatus"),
|
|
506
|
+
btnArm: document.getElementById("btnArm"),
|
|
507
|
+
btnDisarm: document.getElementById("btnDisarm"),
|
|
508
|
+
};
|
|
341
509
|
|
|
342
510
|
const params = new URLSearchParams(window.location.search);
|
|
343
511
|
const preselectId = params.get("id") || "";
|
|
344
512
|
const embedMode = params.get("embed") === "1" || params.get("embed") === "true";
|
|
513
|
+
const view = (params.get("view") || "").toLowerCase();
|
|
345
514
|
|
|
346
515
|
if (embedMode) {
|
|
347
516
|
document.body.classList.add("embed");
|
|
348
517
|
}
|
|
349
518
|
|
|
350
|
-
|
|
351
|
-
|
|
519
|
+
if (view === "zones") {
|
|
520
|
+
document.body.classList.add("view-zones");
|
|
521
|
+
} else if (view === "keypad") {
|
|
522
|
+
document.body.classList.add("view-keypad");
|
|
523
|
+
} else if (view === "log") {
|
|
524
|
+
document.body.classList.add("view-log");
|
|
525
|
+
}
|
|
526
|
+
|
|
527
|
+
const showLog = view !== "zones" && view !== "keypad";
|
|
352
528
|
|
|
353
|
-
let
|
|
354
|
-
let
|
|
355
|
-
let
|
|
356
|
-
let
|
|
357
|
-
let
|
|
529
|
+
let nodesList = [];
|
|
530
|
+
let selectedIds = new Set();
|
|
531
|
+
let pollTimer = null;
|
|
532
|
+
let nodeStateTimer = null;
|
|
533
|
+
let nodeStateById = new Map();
|
|
534
|
+
|
|
535
|
+
let logFilter = "all"; // all | alarm | arming | zones | errors
|
|
536
|
+
let logsById = new Map(); // alarmId -> entries[]
|
|
537
|
+
let logLastTsById = new Map(); // alarmId -> last ts
|
|
538
|
+
|
|
539
|
+
let audioCtx = null;
|
|
540
|
+
let armingBeepTimer = null;
|
|
541
|
+
let hasSeenState = false;
|
|
542
|
+
let lastMode = null;
|
|
543
|
+
let lastArmingActive = null;
|
|
544
|
+
let lastZonesSnapshot = [];
|
|
545
|
+
let zonesFilter = "open"; // "open" | "all"
|
|
546
|
+
let zonesFilterUserSelected = false;
|
|
547
|
+
let zonesFilterInitialized = false;
|
|
358
548
|
|
|
359
549
|
function getAudioContext() {
|
|
360
550
|
if (audioCtx) return audioCtx;
|
|
@@ -547,24 +737,99 @@
|
|
|
547
737
|
return { label: "DISARMED", cls: "" };
|
|
548
738
|
}
|
|
549
739
|
|
|
550
|
-
function
|
|
551
|
-
|
|
552
|
-
const
|
|
553
|
-
if (
|
|
554
|
-
|
|
555
|
-
|
|
740
|
+
function lastEventSummary(state) {
|
|
741
|
+
const log = state && Array.isArray(state.log) ? state.log : [];
|
|
742
|
+
const last = log.length ? log[log.length - 1] : null;
|
|
743
|
+
if (!last || typeof last !== "object") return "";
|
|
744
|
+
const e = typeof last.event === "string" ? last.event : "";
|
|
745
|
+
if (!e) return "";
|
|
746
|
+
if (e === "arm_blocked") {
|
|
747
|
+
const violations = Array.isArray(last.violations) ? last.violations : [];
|
|
748
|
+
const names = violations
|
|
749
|
+
.map((v) => (v && typeof v.name === "string" && v.name.trim() ? v.name.trim() : v && v.id ? String(v.id) : ""))
|
|
750
|
+
.filter(Boolean);
|
|
751
|
+
const shown = names.slice(0, 3).join(", ");
|
|
752
|
+
const suffix = names.length > 3 ? "…" : "";
|
|
753
|
+
return `arm_blocked${names.length ? ` (${shown}${suffix})` : ""}`;
|
|
754
|
+
}
|
|
755
|
+
if (e === "denied") {
|
|
756
|
+
const a = last.action ? String(last.action) : "";
|
|
757
|
+
return `denied${a ? ` (${a})` : ""}`;
|
|
758
|
+
}
|
|
759
|
+
if (e === "arming") {
|
|
760
|
+
const s = Number.isFinite(Number(last.seconds)) ? Number(last.seconds) : null;
|
|
761
|
+
return `arming${s !== null ? ` (${s}s)` : ""}`;
|
|
556
762
|
}
|
|
557
|
-
|
|
763
|
+
if (e === "entry_delay") {
|
|
764
|
+
const s = Number.isFinite(Number(last.seconds)) ? Number(last.seconds) : null;
|
|
765
|
+
return `entry_delay${s !== null ? ` (${s}s)` : ""}`;
|
|
766
|
+
}
|
|
767
|
+
if (e === "alarm") {
|
|
768
|
+
const k = last.kind ? String(last.kind) : "";
|
|
769
|
+
return `alarm${k ? ` (${k})` : ""}`;
|
|
770
|
+
}
|
|
771
|
+
return e;
|
|
772
|
+
}
|
|
558
773
|
|
|
559
|
-
|
|
560
|
-
|
|
561
|
-
|
|
562
|
-
|
|
563
|
-
|
|
564
|
-
|
|
774
|
+
function armingErrorPill(state) {
|
|
775
|
+
const log = state && Array.isArray(state.log) ? state.log : [];
|
|
776
|
+
const last = log.length ? log[log.length - 1] : null;
|
|
777
|
+
const e = last && typeof last.event === "string" ? last.event : "";
|
|
778
|
+
if (e === "arm_blocked") {
|
|
779
|
+
return { cls: "warn", label: "ARM BLOCKED", details: lastEventSummary(state) };
|
|
780
|
+
}
|
|
781
|
+
if (e === "denied" && last && String(last.action || "") === "arm") {
|
|
782
|
+
return { cls: "danger", label: "ARM DENIED", details: lastEventSummary(state) };
|
|
783
|
+
}
|
|
784
|
+
return null;
|
|
785
|
+
}
|
|
786
|
+
|
|
787
|
+
function updateZonesFilterButtons() {
|
|
788
|
+
if (!els.btnZonesFilterOpen || !els.btnZonesFilterAll) return;
|
|
789
|
+
els.btnZonesFilterOpen.classList.toggle("selected", zonesFilter === "open");
|
|
790
|
+
els.btnZonesFilterAll.classList.toggle("selected", zonesFilter === "all");
|
|
791
|
+
}
|
|
792
|
+
|
|
793
|
+
function setZonesFilter(next, opts) {
|
|
794
|
+
const options = opts && typeof opts === "object" ? opts : {};
|
|
795
|
+
const user = options.user === true;
|
|
796
|
+
zonesFilter = String(next || "").toLowerCase() === "all" ? "all" : "open";
|
|
797
|
+
if (user) zonesFilterUserSelected = true;
|
|
798
|
+
updateZonesFilterButtons();
|
|
799
|
+
renderZones(lastZonesSnapshot);
|
|
800
|
+
}
|
|
801
|
+
|
|
802
|
+
function renderZones(zones) {
|
|
803
|
+
els.zonesBody.innerHTML = "";
|
|
804
|
+
const list = Array.isArray(zones) ? zones : [];
|
|
805
|
+
lastZonesSnapshot = list;
|
|
806
|
+
const filtered = zonesFilter === "open" ? list.filter((z) => z && z.open) : list;
|
|
807
|
+
|
|
808
|
+
if (list.length === 0) {
|
|
809
|
+
els.zonesHint.textContent = "No zones configured.";
|
|
810
|
+
return;
|
|
811
|
+
}
|
|
812
|
+
if (filtered.length === 0) {
|
|
813
|
+
els.zonesHint.textContent = "No OPEN zones.";
|
|
814
|
+
return;
|
|
815
|
+
}
|
|
816
|
+
if (zonesFilter === "open") {
|
|
817
|
+
els.zonesHint.textContent = `${filtered.length} OPEN zones (of ${list.length})`;
|
|
818
|
+
} else {
|
|
819
|
+
els.zonesHint.textContent = `${list.length} zones`;
|
|
820
|
+
}
|
|
821
|
+
|
|
822
|
+
for (const z of filtered) {
|
|
823
|
+
const tr = document.createElement("tr");
|
|
824
|
+
const title = z.name ? `${z.name}` : z.id;
|
|
825
|
+
const stateText = z.bypassed ? "BYPASSED" : z.open ? "OPEN" : "CLOSED";
|
|
826
|
+
const stateClass = z.bypassed ? "zone-bypassed" : z.open ? "zone-open" : "zone-closed";
|
|
827
|
+
const topic = z.topic || z.topicPattern || "";
|
|
828
|
+
const alarmLabel = z.__alarmName || z.__alarmId || "";
|
|
565
829
|
|
|
566
830
|
tr.innerHTML = `
|
|
567
|
-
<td>${escapeHtml(
|
|
831
|
+
<td>${escapeHtml(alarmLabel)}</td>
|
|
832
|
+
<td>${escapeHtml(title)}</td>
|
|
568
833
|
<td class="${stateClass}">${escapeHtml(stateText)}</td>
|
|
569
834
|
<td>${escapeHtml(z.type || "")}</td>
|
|
570
835
|
<td>${escapeHtml(topic)}</td>
|
|
@@ -582,57 +847,527 @@
|
|
|
582
847
|
.replace(/'/g, "'");
|
|
583
848
|
}
|
|
584
849
|
|
|
585
|
-
|
|
586
|
-
|
|
587
|
-
|
|
588
|
-
|
|
589
|
-
|
|
590
|
-
|
|
591
|
-
|
|
592
|
-
|
|
593
|
-
|
|
594
|
-
|
|
595
|
-
|
|
596
|
-
|
|
597
|
-
|
|
598
|
-
|
|
599
|
-
|
|
600
|
-
|
|
850
|
+
function pad2(n) {
|
|
851
|
+
return String(Math.max(0, Math.trunc(Number(n) || 0))).padStart(2, "0");
|
|
852
|
+
}
|
|
853
|
+
|
|
854
|
+
function formatTs(ts) {
|
|
855
|
+
const t = Number(ts) || 0;
|
|
856
|
+
if (!t) return "";
|
|
857
|
+
const d = new Date(t);
|
|
858
|
+
return `${d.getFullYear()}-${pad2(d.getMonth() + 1)}-${pad2(d.getDate())} ${pad2(d.getHours())}:${pad2(
|
|
859
|
+
d.getMinutes(),
|
|
860
|
+
)}:${pad2(d.getSeconds())}`;
|
|
861
|
+
}
|
|
862
|
+
|
|
863
|
+
function logGroupForEvent(e) {
|
|
864
|
+
const evt = String(e || "").toLowerCase();
|
|
865
|
+
if (evt === "alarm") return "alarm";
|
|
866
|
+
if (evt === "error" || evt === "denied") return "errors";
|
|
867
|
+
if (
|
|
868
|
+
evt === "arming" ||
|
|
869
|
+
evt === "armed" ||
|
|
870
|
+
evt === "disarmed" ||
|
|
871
|
+
evt === "entry_delay" ||
|
|
872
|
+
evt === "arm_blocked" ||
|
|
873
|
+
evt === "already_armed" ||
|
|
874
|
+
evt === "reset" ||
|
|
875
|
+
evt === "siren_on" ||
|
|
876
|
+
evt === "siren_off"
|
|
877
|
+
)
|
|
878
|
+
return "arming";
|
|
879
|
+
if (
|
|
880
|
+
evt === "bypassed" ||
|
|
881
|
+
evt === "unbypassed" ||
|
|
882
|
+
evt === "chime" ||
|
|
883
|
+
evt === "zone_open" ||
|
|
884
|
+
evt === "zone_close" ||
|
|
885
|
+
evt === "zone_ignored_exit" ||
|
|
886
|
+
evt === "zone_bypassed_trigger" ||
|
|
887
|
+
evt === "zone_restore"
|
|
888
|
+
)
|
|
889
|
+
return "zones";
|
|
890
|
+
return "all";
|
|
891
|
+
}
|
|
892
|
+
|
|
893
|
+
function eventPillClass(evt) {
|
|
894
|
+
const g = logGroupForEvent(evt);
|
|
895
|
+
if (g === "alarm") return "danger";
|
|
896
|
+
if (g === "errors") return "danger";
|
|
897
|
+
if (g === "arming") return "warn";
|
|
898
|
+
if (g === "zones") return "";
|
|
899
|
+
return "";
|
|
900
|
+
}
|
|
901
|
+
|
|
902
|
+
function eventDetails(entry) {
|
|
903
|
+
const e = entry && entry.event ? String(entry.event) : "";
|
|
904
|
+
|
|
905
|
+
if (e === "alarm") {
|
|
906
|
+
const k = entry && entry.kind ? String(entry.kind) : "";
|
|
907
|
+
const z = entry && entry.zone && typeof entry.zone === "object" ? entry.zone : null;
|
|
908
|
+
const zn = z && (z.name || z.id) ? String(z.name || z.id) : "";
|
|
909
|
+
const silent = entry && entry.silent === true ? "silent" : "";
|
|
910
|
+
return [k ? `kind=${k}` : "", zn ? `zone=${zn}` : "", silent].filter(Boolean).join(" • ");
|
|
911
|
+
}
|
|
912
|
+
|
|
913
|
+
if (e === "denied") {
|
|
914
|
+
const a = entry && entry.action ? String(entry.action) : "";
|
|
915
|
+
const t = entry && entry.target ? String(entry.target) : "";
|
|
916
|
+
return [a ? `action=${a}` : "", t ? `target=${t}` : ""].filter(Boolean).join(" • ");
|
|
917
|
+
}
|
|
918
|
+
|
|
919
|
+
if (e === "error") {
|
|
920
|
+
const err = entry && entry.error ? String(entry.error) : "";
|
|
921
|
+
const z = entry && entry.zone ? String(entry.zone) : "";
|
|
922
|
+
return [err ? `error=${err}` : "", z ? `zone=${z}` : ""].filter(Boolean).join(" • ");
|
|
923
|
+
}
|
|
924
|
+
|
|
925
|
+
if (e === "arm_blocked") {
|
|
926
|
+
const v = Array.isArray(entry && entry.violations) ? entry.violations : [];
|
|
927
|
+
if (!v.length) return "violations";
|
|
928
|
+
const top = v
|
|
929
|
+
.slice(0, 3)
|
|
930
|
+
.map((x) => (x && typeof x === "object" ? x.id || x.name || x.zone || "" : ""))
|
|
931
|
+
.filter(Boolean)
|
|
932
|
+
.join(", ");
|
|
933
|
+
return `violations=${v.length}${top ? ` (${top}${v.length > 3 ? ", …" : ""})` : ""}`;
|
|
934
|
+
}
|
|
935
|
+
|
|
936
|
+
if (e === "arming" || e === "entry_delay") {
|
|
937
|
+
const s = Number.isFinite(Number(entry && entry.seconds)) ? Number(entry.seconds) : null;
|
|
938
|
+
const z = entry && entry.zone && typeof entry.zone === "object" ? entry.zone : null;
|
|
939
|
+
const zn = z && (z.name || z.id) ? String(z.name || z.id) : "";
|
|
940
|
+
return [s !== null ? `seconds=${s}` : "", zn ? `zone=${zn}` : ""].filter(Boolean).join(" • ");
|
|
941
|
+
}
|
|
942
|
+
|
|
943
|
+
if (e === "armed" || e === "disarmed") {
|
|
944
|
+
const r = entry && entry.reason ? String(entry.reason) : "";
|
|
945
|
+
const d = entry && entry.duress === true ? "duress" : "";
|
|
946
|
+
return [r ? `reason=${r}` : "", d].filter(Boolean).join(" • ");
|
|
947
|
+
}
|
|
948
|
+
|
|
949
|
+
if (e === "siren_on" || e === "siren_off") {
|
|
950
|
+
const r = entry && entry.reason ? String(entry.reason) : "";
|
|
951
|
+
return r ? `reason=${r}` : "";
|
|
952
|
+
}
|
|
953
|
+
|
|
954
|
+
if (
|
|
955
|
+
e === "bypassed" ||
|
|
956
|
+
e === "unbypassed" ||
|
|
957
|
+
e === "chime" ||
|
|
958
|
+
e === "zone_open" ||
|
|
959
|
+
e === "zone_close" ||
|
|
960
|
+
e === "zone_ignored_exit" ||
|
|
961
|
+
e === "zone_bypassed_trigger" ||
|
|
962
|
+
e === "zone_restore"
|
|
963
|
+
) {
|
|
964
|
+
const z = entry && entry.zone && typeof entry.zone === "object" ? entry.zone : null;
|
|
965
|
+
const zn = z && (z.name || z.id) ? String(z.name || z.id) : "";
|
|
966
|
+
const b = entry && entry.bypassed === true ? "bypassed" : "";
|
|
967
|
+
return [zn ? `zone=${zn}` : "", b].filter(Boolean).join(" • ");
|
|
968
|
+
}
|
|
969
|
+
|
|
970
|
+
try {
|
|
971
|
+
const copy = { ...(entry || {}) };
|
|
972
|
+
delete copy.ts;
|
|
973
|
+
delete copy.event;
|
|
974
|
+
const keys = Object.keys(copy);
|
|
975
|
+
if (!keys.length) return "";
|
|
976
|
+
const txt = JSON.stringify(copy);
|
|
977
|
+
return txt.length > 220 ? `${txt.slice(0, 220)}…` : txt;
|
|
978
|
+
} catch (_err) {
|
|
979
|
+
return "";
|
|
980
|
+
}
|
|
981
|
+
}
|
|
982
|
+
|
|
983
|
+
function updateLogFilterButtons() {
|
|
984
|
+
const map = [
|
|
985
|
+
["all", els.btnLogFilterAll],
|
|
986
|
+
["alarm", els.btnLogFilterAlarm],
|
|
987
|
+
["arming", els.btnLogFilterArming],
|
|
988
|
+
["zones", els.btnLogFilterZones],
|
|
989
|
+
["errors", els.btnLogFilterErrors],
|
|
990
|
+
];
|
|
991
|
+
map.forEach(([key, el]) => {
|
|
992
|
+
if (!el) return;
|
|
993
|
+
el.classList.toggle("selected", logFilter === key);
|
|
994
|
+
});
|
|
995
|
+
}
|
|
996
|
+
|
|
997
|
+
function setLogFilter(next) {
|
|
998
|
+
const v = String(next || "").toLowerCase();
|
|
999
|
+
logFilter = ["all", "alarm", "arming", "zones", "errors"].includes(v) ? v : "all";
|
|
1000
|
+
updateLogFilterButtons();
|
|
1001
|
+
renderLogs();
|
|
1002
|
+
}
|
|
1003
|
+
|
|
1004
|
+
function resetLogTracking() {
|
|
1005
|
+
logsById = new Map();
|
|
1006
|
+
logLastTsById = new Map();
|
|
1007
|
+
}
|
|
1008
|
+
|
|
1009
|
+
async function fetchLogForNode(nodeId, since) {
|
|
1010
|
+
const qs = new URLSearchParams();
|
|
1011
|
+
qs.set("limit", "200");
|
|
1012
|
+
if (Number.isFinite(Number(since)) && Number(since) > 0) {
|
|
1013
|
+
qs.set("since", String(Number(since)));
|
|
1014
|
+
}
|
|
1015
|
+
const res = await fetch(apiUrl(`/alarm-ultimate/alarm/${encodeURIComponent(nodeId)}/log?${qs.toString()}`), {
|
|
1016
|
+
credentials: "same-origin",
|
|
1017
|
+
headers: { ...authHeaders() },
|
|
1018
|
+
});
|
|
1019
|
+
if (!res.ok) throw new Error(`Unable to load log (${res.status})`);
|
|
1020
|
+
return res.json();
|
|
1021
|
+
}
|
|
1022
|
+
|
|
1023
|
+
async function loadLogs(okStates) {
|
|
1024
|
+
if (!showLog) return;
|
|
1025
|
+
if (!els.logBody || !els.logHint) return;
|
|
1026
|
+
const ids = Array.from(selectedIds);
|
|
1027
|
+
if (!ids.length) return;
|
|
1028
|
+
|
|
1029
|
+
const nameById = new Map(
|
|
1030
|
+
(Array.isArray(okStates) ? okStates : []).map((x) => [x.id, (x.data && x.data.name) || x.id]),
|
|
1031
|
+
);
|
|
1032
|
+
|
|
1033
|
+
const results = await Promise.allSettled(
|
|
1034
|
+
ids.map(async (id) => {
|
|
1035
|
+
const since = logLastTsById.has(id) ? logLastTsById.get(id) : null;
|
|
1036
|
+
const data = await fetchLogForNode(id, since);
|
|
1037
|
+
return { id, data };
|
|
1038
|
+
}),
|
|
1039
|
+
);
|
|
1040
|
+
|
|
1041
|
+
for (const r of results) {
|
|
1042
|
+
if (r.status !== "fulfilled") continue;
|
|
1043
|
+
const id = r.value.id;
|
|
1044
|
+
const payload = r.value.data && typeof r.value.data === "object" ? r.value.data : {};
|
|
1045
|
+
const entries = Array.isArray(payload.log) ? payload.log : [];
|
|
1046
|
+
|
|
1047
|
+
const prev = logsById.get(id) || [];
|
|
1048
|
+
const next = logLastTsById.has(id) ? prev.concat(entries) : entries;
|
|
1049
|
+
logsById.set(id, next);
|
|
1050
|
+
|
|
1051
|
+
const maxTs = next.reduce((m, e) => Math.max(m, Number(e && e.ts) || 0), 0);
|
|
1052
|
+
logLastTsById.set(id, maxTs);
|
|
1053
|
+
}
|
|
1054
|
+
|
|
1055
|
+
renderLogs(nameById);
|
|
1056
|
+
}
|
|
1057
|
+
|
|
1058
|
+
function renderLogs(nameById) {
|
|
1059
|
+
if (!els.logBody || !els.logHint) return;
|
|
1060
|
+
const ids = Array.from(selectedIds);
|
|
1061
|
+
const nameMap = nameById instanceof Map ? nameById : new Map(ids.map((id) => [id, id]));
|
|
1062
|
+
|
|
1063
|
+
const all = [];
|
|
1064
|
+
ids.forEach((id) => {
|
|
1065
|
+
const alarmName = nameMap.get(id) || id;
|
|
1066
|
+
const list = logsById.get(id) || [];
|
|
1067
|
+
list.forEach((e) => all.push({ ...(e || {}), __alarmId: id, __alarmName: alarmName }));
|
|
1068
|
+
});
|
|
1069
|
+
|
|
1070
|
+
const filtered =
|
|
1071
|
+
logFilter === "all" ? all : all.filter((e) => logGroupForEvent(e && e.event) === logFilter);
|
|
1072
|
+
|
|
1073
|
+
filtered.sort((a, b) => (Number(b.ts) || 0) - (Number(a.ts) || 0));
|
|
1074
|
+
const show = filtered.slice(0, 200);
|
|
1075
|
+
|
|
1076
|
+
if (!show.length) {
|
|
1077
|
+
els.logHint.textContent = "No log entries yet.";
|
|
1078
|
+
els.logBody.innerHTML = "";
|
|
1079
|
+
return;
|
|
1080
|
+
}
|
|
1081
|
+
|
|
1082
|
+
els.logHint.textContent = `Showing ${show.length} entr${show.length === 1 ? "y" : "ies"}${
|
|
1083
|
+
logFilter !== "all" ? ` (${logFilter})` : ""
|
|
1084
|
+
}.`;
|
|
1085
|
+
els.logBody.innerHTML = "";
|
|
1086
|
+
|
|
1087
|
+
for (const entry of show) {
|
|
1088
|
+
const tr = document.createElement("tr");
|
|
1089
|
+
const alarmLabel = entry.__alarmName || entry.__alarmId || "";
|
|
1090
|
+
const evt = entry && entry.event ? String(entry.event) : "";
|
|
1091
|
+
const cls = eventPillClass(evt);
|
|
1092
|
+
const details = eventDetails(entry);
|
|
1093
|
+
|
|
1094
|
+
tr.innerHTML = `
|
|
1095
|
+
<td>${escapeHtml(alarmLabel)}</td>
|
|
1096
|
+
<td>${escapeHtml(formatTs(entry.ts))}</td>
|
|
1097
|
+
<td><span class="pill small ${cls}">${escapeHtml(evt)}</span></td>
|
|
1098
|
+
<td class="log-details">${escapeHtml(details)}</td>
|
|
1099
|
+
`;
|
|
1100
|
+
els.logBody.appendChild(tr);
|
|
1101
|
+
}
|
|
1102
|
+
}
|
|
1103
|
+
|
|
1104
|
+
function normalizePreselectIds(raw) {
|
|
1105
|
+
const v = String(raw || "").trim();
|
|
1106
|
+
if (!v) return [];
|
|
1107
|
+
if (v.includes(",")) {
|
|
1108
|
+
return v
|
|
1109
|
+
.split(",")
|
|
1110
|
+
.map((s) => s.trim())
|
|
1111
|
+
.filter(Boolean);
|
|
1112
|
+
}
|
|
1113
|
+
return [v];
|
|
1114
|
+
}
|
|
1115
|
+
|
|
1116
|
+
function persistSelectedIds() {
|
|
1117
|
+
try {
|
|
1118
|
+
localStorage.setItem("alarm-ultimate-panel:selectedIds", JSON.stringify(Array.from(selectedIds)));
|
|
1119
|
+
} catch (_err) {}
|
|
1120
|
+
}
|
|
1121
|
+
|
|
1122
|
+
function restoreSelectedIds() {
|
|
1123
|
+
const idsFromUrl = normalizePreselectIds(preselectId);
|
|
1124
|
+
if (idsFromUrl.length) return idsFromUrl;
|
|
1125
|
+
try {
|
|
1126
|
+
const raw = localStorage.getItem("alarm-ultimate-panel:selectedIds");
|
|
1127
|
+
const parsed = JSON.parse(raw);
|
|
1128
|
+
if (Array.isArray(parsed)) return parsed.map(String).filter(Boolean);
|
|
1129
|
+
} catch (_err) {}
|
|
1130
|
+
return [];
|
|
601
1131
|
}
|
|
602
1132
|
|
|
603
|
-
|
|
604
|
-
|
|
605
|
-
|
|
606
|
-
|
|
607
|
-
|
|
608
|
-
|
|
609
|
-
|
|
1133
|
+
function updateSelectAllVisibility() {
|
|
1134
|
+
if (!els.btnSelectAll) return;
|
|
1135
|
+
const container = els.btnSelectAll.closest(".buttons") || els.btnSelectAll;
|
|
1136
|
+
container.style.display = nodesList.length > 1 ? "" : "none";
|
|
1137
|
+
}
|
|
1138
|
+
|
|
1139
|
+
function statusFromStateSafe(state) {
|
|
1140
|
+
const st = statusFromState(state);
|
|
1141
|
+
return st && typeof st === "object" ? st : { label: "Unknown", cls: "" };
|
|
1142
|
+
}
|
|
1143
|
+
|
|
1144
|
+
function updateSelectionHint() {
|
|
1145
|
+
const count = selectedIds.size;
|
|
1146
|
+
if (!els.selectionHint) return;
|
|
1147
|
+
if (count === 0) {
|
|
1148
|
+
els.selectionHint.textContent = "Select at least one Alarm node.";
|
|
1149
|
+
return;
|
|
1150
|
+
}
|
|
1151
|
+
els.selectionHint.innerHTML = `Selected: <span class="pill small">${count}</span> (click buttons to add/remove)`;
|
|
1152
|
+
}
|
|
1153
|
+
|
|
1154
|
+
function setSelectedIds(next) {
|
|
1155
|
+
const ids = Array.isArray(next) ? next.map(String).filter(Boolean) : [];
|
|
1156
|
+
selectedIds = new Set(ids);
|
|
1157
|
+
// enforce non-empty selection when possible
|
|
1158
|
+
if (selectedIds.size === 0 && nodesList[0]) {
|
|
1159
|
+
selectedIds.add(nodesList[0].id);
|
|
1160
|
+
}
|
|
1161
|
+
persistSelectedIds();
|
|
1162
|
+
resetStateAudioTracking();
|
|
1163
|
+
resetLogTracking();
|
|
1164
|
+
updateSelectionHint();
|
|
1165
|
+
renderNodeButtons();
|
|
1166
|
+
loadState().catch(() => {});
|
|
1167
|
+
}
|
|
1168
|
+
|
|
1169
|
+
function selectAll() {
|
|
1170
|
+
if (!nodesList.length) return;
|
|
1171
|
+
setSelectedIds(nodesList.map((n) => n.id));
|
|
1172
|
+
}
|
|
1173
|
+
|
|
1174
|
+
function toggleSelectedId(id) {
|
|
1175
|
+
const key = String(id || "").trim();
|
|
1176
|
+
if (!key) return;
|
|
1177
|
+
const next = new Set(selectedIds);
|
|
1178
|
+
if (next.has(key)) {
|
|
1179
|
+
if (next.size === 1) return; // never allow empty
|
|
1180
|
+
next.delete(key);
|
|
1181
|
+
} else {
|
|
1182
|
+
next.add(key);
|
|
1183
|
+
}
|
|
1184
|
+
setSelectedIds(Array.from(next));
|
|
1185
|
+
}
|
|
1186
|
+
|
|
1187
|
+
function renderNodeButtons() {
|
|
1188
|
+
if (!els.nodeButtons) return;
|
|
1189
|
+
updateSelectAllVisibility();
|
|
1190
|
+
els.nodeButtons.innerHTML = "";
|
|
1191
|
+
if (!nodesList.length) return;
|
|
1192
|
+
|
|
1193
|
+
nodesList.forEach((n) => {
|
|
1194
|
+
const state = nodeStateById.get(n.id);
|
|
1195
|
+
const st = statusFromStateSafe(state);
|
|
1196
|
+
const btn = document.createElement("button");
|
|
1197
|
+
btn.type = "button";
|
|
1198
|
+
btn.className = `node ${st.cls || ""} ${selectedIds.has(n.id) ? `selected ${st.cls || ""}` : ""}`.trim();
|
|
1199
|
+
btn.dataset.id = n.id;
|
|
1200
|
+
const name = n.name ? n.name : n.id;
|
|
1201
|
+
btn.innerHTML = `<span class="dot"></span><span>${escapeHtml(name)}</span>`;
|
|
1202
|
+
btn.addEventListener("click", () => toggleSelectedId(n.id));
|
|
1203
|
+
els.nodeButtons.appendChild(btn);
|
|
1204
|
+
});
|
|
1205
|
+
}
|
|
1206
|
+
|
|
1207
|
+
async function loadNodes() {
|
|
1208
|
+
const res = await fetch(apiUrl("/alarm-ultimate/alarm/nodes"), {
|
|
1209
|
+
credentials: "same-origin",
|
|
1210
|
+
headers: { ...authHeaders() },
|
|
1211
|
+
});
|
|
1212
|
+
if (!res.ok) throw new Error(`Unable to load nodes (${res.status})`);
|
|
1213
|
+
const data = await res.json();
|
|
1214
|
+
nodesList = Array.isArray(data.nodes) ? data.nodes : [];
|
|
1215
|
+
updateSelectAllVisibility();
|
|
1216
|
+
if (!nodesList.length) {
|
|
1217
|
+
els.zonesHint.textContent = "No Alarm nodes found.";
|
|
1218
|
+
setNodeHint("No Alarm nodes found. Deploy your flow and refresh.");
|
|
1219
|
+
renderNodeButtons();
|
|
1220
|
+
updateSelectionHint();
|
|
1221
|
+
} else {
|
|
610
1222
|
setNodeHint("");
|
|
1223
|
+
const restored = restoreSelectedIds().filter((id) => nodesList.some((n) => n.id === id));
|
|
1224
|
+
setSelectedIds(restored.length ? restored : [nodesList[0].id]);
|
|
611
1225
|
}
|
|
612
1226
|
}
|
|
613
1227
|
|
|
614
|
-
|
|
615
|
-
|
|
616
|
-
|
|
617
|
-
|
|
618
|
-
|
|
619
|
-
|
|
620
|
-
|
|
621
|
-
|
|
622
|
-
|
|
623
|
-
|
|
624
|
-
|
|
625
|
-
|
|
626
|
-
|
|
627
|
-
|
|
628
|
-
|
|
629
|
-
|
|
630
|
-
|
|
631
|
-
|
|
632
|
-
|
|
633
|
-
|
|
634
|
-
|
|
635
|
-
|
|
1228
|
+
async function fetchStateForNode(nodeId) {
|
|
1229
|
+
const res = await fetch(apiUrl(`/alarm-ultimate/alarm/${encodeURIComponent(nodeId)}/state`), {
|
|
1230
|
+
credentials: "same-origin",
|
|
1231
|
+
headers: { ...authHeaders() },
|
|
1232
|
+
});
|
|
1233
|
+
if (!res.ok) throw new Error(`Unable to load state (${res.status})`);
|
|
1234
|
+
return res.json();
|
|
1235
|
+
}
|
|
1236
|
+
|
|
1237
|
+
async function loadNodeStatesForButtons() {
|
|
1238
|
+
if (!nodesList.length) return;
|
|
1239
|
+
const ids = nodesList.map((n) => n.id);
|
|
1240
|
+
const results = await Promise.allSettled(ids.map((id) => fetchStateForNode(id)));
|
|
1241
|
+
for (let i = 0; i < results.length; i += 1) {
|
|
1242
|
+
const id = ids[i];
|
|
1243
|
+
const r = results[i];
|
|
1244
|
+
if (r.status === "fulfilled") {
|
|
1245
|
+
nodeStateById.set(id, r.value && r.value.state ? r.value.state : null);
|
|
1246
|
+
}
|
|
1247
|
+
}
|
|
1248
|
+
renderNodeButtons();
|
|
1249
|
+
}
|
|
1250
|
+
|
|
1251
|
+
async function loadState() {
|
|
1252
|
+
const ids = Array.from(selectedIds);
|
|
1253
|
+
if (!ids.length) return;
|
|
1254
|
+
|
|
1255
|
+
const results = await Promise.allSettled(ids.map((id) => fetchStateForNode(id)));
|
|
1256
|
+
const okStates = [];
|
|
1257
|
+
for (let i = 0; i < results.length; i += 1) {
|
|
1258
|
+
const id = ids[i];
|
|
1259
|
+
const r = results[i];
|
|
1260
|
+
if (r.status === "fulfilled") {
|
|
1261
|
+
okStates.push({ id, data: r.value });
|
|
1262
|
+
nodeStateById.set(id, r.value && r.value.state ? r.value.state : null);
|
|
1263
|
+
}
|
|
1264
|
+
}
|
|
1265
|
+
|
|
1266
|
+
renderNodeButtons();
|
|
1267
|
+
updateSelectionHint();
|
|
1268
|
+
|
|
1269
|
+
if (okStates.length === 0) {
|
|
1270
|
+
els.nodeStatus.style.display = "none";
|
|
1271
|
+
els.zonesHint.textContent = "Unable to load selected alarm state.";
|
|
1272
|
+
return;
|
|
1273
|
+
}
|
|
1274
|
+
|
|
1275
|
+
// Combine status and zones.
|
|
1276
|
+
const zones = [];
|
|
1277
|
+
okStates.forEach(({ id, data }) => {
|
|
1278
|
+
const alarmName = data && data.name ? data.name : id;
|
|
1279
|
+
const list = Array.isArray(data && data.zones) ? data.zones : [];
|
|
1280
|
+
list.forEach((z) => zones.push({ ...z, __alarmId: id, __alarmName: alarmName }));
|
|
1281
|
+
});
|
|
1282
|
+
|
|
1283
|
+
zones.sort((a, b) => {
|
|
1284
|
+
const an = String(a.__alarmName || "").localeCompare(String(b.__alarmName || ""));
|
|
1285
|
+
if (an !== 0) return an;
|
|
1286
|
+
return String(a.name || a.id || "").localeCompare(String(b.name || b.id || ""));
|
|
1287
|
+
});
|
|
1288
|
+
|
|
1289
|
+
const multiple = okStates.length > 1;
|
|
1290
|
+
if (multiple) {
|
|
1291
|
+
stopArmingBeeps();
|
|
1292
|
+
}
|
|
1293
|
+
|
|
1294
|
+
els.nodeStatus.style.display = "";
|
|
1295
|
+
els.statusTitle.textContent = multiple
|
|
1296
|
+
? `Selected alarms (${okStates.length})`
|
|
1297
|
+
: okStates[0].data && okStates[0].data.name
|
|
1298
|
+
? okStates[0].data.name
|
|
1299
|
+
: okStates[0].id;
|
|
1300
|
+
|
|
1301
|
+
if (multiple) {
|
|
1302
|
+
const lines = okStates
|
|
1303
|
+
.map(({ id, data }) => {
|
|
1304
|
+
const state = data && data.state ? data.state : null;
|
|
1305
|
+
const st = statusFromStateSafe(state);
|
|
1306
|
+
const name = data && data.name ? data.name : id;
|
|
1307
|
+
const openCount = Array.isArray(data && data.zones)
|
|
1308
|
+
? data.zones.filter((z) => z && z.open && !z.bypassed).length
|
|
1309
|
+
: 0;
|
|
1310
|
+
const lastEvt = lastEventSummary(state);
|
|
1311
|
+
const armErr = armingErrorPill(state);
|
|
1312
|
+
const details = [
|
|
1313
|
+
`state=${st.label}`,
|
|
1314
|
+
openCount ? `open=${openCount}` : "",
|
|
1315
|
+
lastEvt ? `last=${lastEvt}` : "",
|
|
1316
|
+
]
|
|
1317
|
+
.filter(Boolean)
|
|
1318
|
+
.join(" • ");
|
|
1319
|
+
|
|
1320
|
+
return `
|
|
1321
|
+
<div class="line">
|
|
1322
|
+
<div class="name">${escapeHtml(name)}</div>
|
|
1323
|
+
<div class="details">
|
|
1324
|
+
<span class="pill small ${st.cls || ""}">${escapeHtml(st.label)}</span>
|
|
1325
|
+
${
|
|
1326
|
+
armErr
|
|
1327
|
+
? ` <span class="pill small ${armErr.cls}">${escapeHtml(armErr.label)}</span>`
|
|
1328
|
+
: ""
|
|
1329
|
+
}
|
|
1330
|
+
${details ? ` ${escapeHtml(details)}` : ""}
|
|
1331
|
+
</div>
|
|
1332
|
+
</div>
|
|
1333
|
+
`;
|
|
1334
|
+
})
|
|
1335
|
+
.join("");
|
|
1336
|
+
|
|
1337
|
+
els.statusMeta.innerHTML = `<div class="lines">${lines}</div>`;
|
|
1338
|
+
els.statusPill.textContent = "MULTI";
|
|
1339
|
+
els.statusPill.className = "pill warn";
|
|
1340
|
+
} else {
|
|
1341
|
+
const state = okStates[0].data && okStates[0].data.state ? okStates[0].data.state : null;
|
|
1342
|
+
const st = statusFromStateSafe(state);
|
|
1343
|
+
const openCount = zones.filter((z) => z.open && !z.bypassed).length;
|
|
1344
|
+
const lastEvt = lastEventSummary(state);
|
|
1345
|
+
const armErr = armingErrorPill(state);
|
|
1346
|
+
const meta = [`${st.label}`, openCount ? `open=${openCount}` : "", lastEvt ? `last=${lastEvt}` : ""]
|
|
1347
|
+
.filter(Boolean)
|
|
1348
|
+
.join(" • ");
|
|
1349
|
+
els.statusMeta.textContent = meta;
|
|
1350
|
+
els.statusPill.textContent = st.label;
|
|
1351
|
+
els.statusPill.className = `pill ${st.cls}`;
|
|
1352
|
+
if (armErr && els.statusPill) {
|
|
1353
|
+
els.statusPill.textContent = armErr.label;
|
|
1354
|
+
els.statusPill.className = `pill ${armErr.cls}`;
|
|
1355
|
+
}
|
|
1356
|
+
}
|
|
1357
|
+
|
|
1358
|
+
if (!zonesFilterInitialized && !zonesFilterUserSelected) {
|
|
1359
|
+
zonesFilter = zones.some((z) => z && z.open) ? "open" : "all";
|
|
1360
|
+
zonesFilterInitialized = true;
|
|
1361
|
+
updateZonesFilterButtons();
|
|
1362
|
+
}
|
|
1363
|
+
renderZones(zones);
|
|
1364
|
+
if (!multiple) {
|
|
1365
|
+
handleStateBeeps(okStates[0].data && okStates[0].data.state);
|
|
1366
|
+
}
|
|
1367
|
+
if (showLog) {
|
|
1368
|
+
await loadLogs(okStates);
|
|
1369
|
+
}
|
|
1370
|
+
}
|
|
636
1371
|
|
|
637
1372
|
function startPolling() {
|
|
638
1373
|
if (pollTimer) clearInterval(pollTimer);
|
|
@@ -643,18 +1378,85 @@
|
|
|
643
1378
|
}, 1000);
|
|
644
1379
|
}
|
|
645
1380
|
|
|
646
|
-
|
|
647
|
-
|
|
648
|
-
|
|
649
|
-
|
|
650
|
-
|
|
651
|
-
|
|
652
|
-
|
|
653
|
-
|
|
654
|
-
|
|
655
|
-
|
|
656
|
-
|
|
1381
|
+
function startNodeButtonPolling() {
|
|
1382
|
+
if (nodeStateTimer) clearInterval(nodeStateTimer);
|
|
1383
|
+
nodeStateTimer = setInterval(() => {
|
|
1384
|
+
loadNodeStatesForButtons().catch(() => {});
|
|
1385
|
+
}, 2500);
|
|
1386
|
+
}
|
|
1387
|
+
|
|
1388
|
+
function extractResultState(json) {
|
|
1389
|
+
const root = json && typeof json === "object" ? json : null;
|
|
1390
|
+
const result = root && root.result && typeof root.result === "object" ? root.result : null;
|
|
1391
|
+
return result && result.state ? result.state : null;
|
|
1392
|
+
}
|
|
1393
|
+
|
|
1394
|
+
function extractResultName(json, fallbackId) {
|
|
1395
|
+
const root = json && typeof json === "object" ? json : null;
|
|
1396
|
+
const result = root && root.result && typeof root.result === "object" ? root.result : null;
|
|
1397
|
+
return (result && result.name) || fallbackId || "";
|
|
1398
|
+
}
|
|
1399
|
+
|
|
1400
|
+
function isArmSuccess(state) {
|
|
1401
|
+
if (!state) return false;
|
|
1402
|
+
if (state.mode === "armed") return true;
|
|
1403
|
+
return Boolean(state.arming && state.arming.active);
|
|
1404
|
+
}
|
|
1405
|
+
|
|
1406
|
+
function isDisarmSuccess(state) {
|
|
1407
|
+
if (!state) return false;
|
|
1408
|
+
return state.mode === "disarmed";
|
|
657
1409
|
}
|
|
1410
|
+
|
|
1411
|
+
async function sendCommand(payload) {
|
|
1412
|
+
const ids = Array.from(selectedIds);
|
|
1413
|
+
if (!ids.length) return;
|
|
1414
|
+
const results = await Promise.allSettled(
|
|
1415
|
+
ids.map(async (id) => {
|
|
1416
|
+
const res = await fetch(apiUrl(`/alarm-ultimate/alarm/${encodeURIComponent(id)}/command`), {
|
|
1417
|
+
method: "POST",
|
|
1418
|
+
headers: { "content-type": "application/json", ...authHeaders() },
|
|
1419
|
+
body: JSON.stringify(payload),
|
|
1420
|
+
credentials: "same-origin",
|
|
1421
|
+
});
|
|
1422
|
+
if (!res.ok) {
|
|
1423
|
+
const text = await res.text();
|
|
1424
|
+
throw new Error(`(${id}) ${res.status}: ${text}`);
|
|
1425
|
+
}
|
|
1426
|
+
const json = await res.json().catch(() => null);
|
|
1427
|
+
const state = extractResultState(json);
|
|
1428
|
+
if (state) {
|
|
1429
|
+
nodeStateById.set(id, state);
|
|
1430
|
+
}
|
|
1431
|
+
return {
|
|
1432
|
+
id,
|
|
1433
|
+
name: extractResultName(json, id),
|
|
1434
|
+
state,
|
|
1435
|
+
};
|
|
1436
|
+
}),
|
|
1437
|
+
);
|
|
1438
|
+
const ok = results.filter((r) => r.status === "fulfilled").map((r) => r.value);
|
|
1439
|
+
const err = results
|
|
1440
|
+
.filter((r) => r.status === "rejected")
|
|
1441
|
+
.map((r) => (r.reason && r.reason.message ? r.reason.message : String(r.reason)));
|
|
1442
|
+
|
|
1443
|
+
// Heuristic feedback: HTTP 200 doesn't mean the Alarm accepted the command.
|
|
1444
|
+
const cmd = payload && typeof payload.command === "string" ? payload.command : "";
|
|
1445
|
+
const check = cmd === "disarm" ? isDisarmSuccess : cmd === "arm" ? isArmSuccess : null;
|
|
1446
|
+
const okApplied = check ? ok.filter((r) => check(r && r.state)).map((r) => r.name || r.id) : [];
|
|
1447
|
+
const maybeRejected = check ? ok.filter((r) => !check(r && r.state)).map((r) => r.name || r.id) : [];
|
|
1448
|
+
|
|
1449
|
+
renderNodeButtons();
|
|
1450
|
+
|
|
1451
|
+
if (err.length) {
|
|
1452
|
+
throw new Error(`Sent to ${ok.length}/${ids.length}. ${err[0]}`);
|
|
1453
|
+
}
|
|
1454
|
+
if (check && maybeRejected.length) {
|
|
1455
|
+
showCmdStatus(
|
|
1456
|
+
`${cmd} sent to ${ids.length}. Applied: ${okApplied.length}/${ids.length}. Not applied: ${maybeRejected.slice(0, 3).join(", ")}${maybeRejected.length > 3 ? "…" : ""}`,
|
|
1457
|
+
"warn",
|
|
1458
|
+
);
|
|
1459
|
+
}
|
|
658
1460
|
}
|
|
659
1461
|
|
|
660
1462
|
function codeValue() {
|
|
@@ -662,17 +1464,63 @@
|
|
|
662
1464
|
return v.length ? v : undefined;
|
|
663
1465
|
}
|
|
664
1466
|
|
|
665
|
-
|
|
666
|
-
|
|
667
|
-
|
|
668
|
-
|
|
669
|
-
|
|
670
|
-
|
|
671
|
-
|
|
672
|
-
|
|
673
|
-
|
|
674
|
-
|
|
675
|
-
|
|
1467
|
+
// No select: buttons manage selection.
|
|
1468
|
+
if (els.btnSelectAll) {
|
|
1469
|
+
els.btnSelectAll.addEventListener("click", () => selectAll());
|
|
1470
|
+
}
|
|
1471
|
+
if (els.btnZonesFilterOpen) {
|
|
1472
|
+
els.btnZonesFilterOpen.addEventListener("click", () => setZonesFilter("open", { user: true }));
|
|
1473
|
+
}
|
|
1474
|
+
if (els.btnZonesFilterAll) {
|
|
1475
|
+
els.btnZonesFilterAll.addEventListener("click", () => setZonesFilter("all", { user: true }));
|
|
1476
|
+
}
|
|
1477
|
+
if (els.btnLogFilterAll) {
|
|
1478
|
+
els.btnLogFilterAll.addEventListener("click", () => setLogFilter("all"));
|
|
1479
|
+
}
|
|
1480
|
+
if (els.btnLogFilterAlarm) {
|
|
1481
|
+
els.btnLogFilterAlarm.addEventListener("click", () => setLogFilter("alarm"));
|
|
1482
|
+
}
|
|
1483
|
+
if (els.btnLogFilterArming) {
|
|
1484
|
+
els.btnLogFilterArming.addEventListener("click", () => setLogFilter("arming"));
|
|
1485
|
+
}
|
|
1486
|
+
if (els.btnLogFilterZones) {
|
|
1487
|
+
els.btnLogFilterZones.addEventListener("click", () => setLogFilter("zones"));
|
|
1488
|
+
}
|
|
1489
|
+
if (els.btnLogFilterErrors) {
|
|
1490
|
+
els.btnLogFilterErrors.addEventListener("click", () => setLogFilter("errors"));
|
|
1491
|
+
}
|
|
1492
|
+
if (els.btnLogDownload) {
|
|
1493
|
+
els.btnLogDownload.addEventListener("click", () => {
|
|
1494
|
+
const ids = Array.from(selectedIds);
|
|
1495
|
+
const out = [];
|
|
1496
|
+
ids.forEach((id) => {
|
|
1497
|
+
const alarmName = nodesList.find((n) => n && n.id === id)?.name || id;
|
|
1498
|
+
const list = logsById.get(id) || [];
|
|
1499
|
+
list.forEach((e) => out.push({ ...(e || {}), alarmId: id, alarmName }));
|
|
1500
|
+
});
|
|
1501
|
+
out.sort((a, b) => (Number(a.ts) || 0) - (Number(b.ts) || 0));
|
|
1502
|
+
const blob = new Blob([JSON.stringify(out, null, 2)], { type: "application/json" });
|
|
1503
|
+
const a = document.createElement("a");
|
|
1504
|
+
a.href = URL.createObjectURL(blob);
|
|
1505
|
+
a.download = `alarm-log-${new Date().toISOString().replace(/[:.]/g, "-")}.json`;
|
|
1506
|
+
document.body.appendChild(a);
|
|
1507
|
+
a.click();
|
|
1508
|
+
setTimeout(() => {
|
|
1509
|
+
try {
|
|
1510
|
+
URL.revokeObjectURL(a.href);
|
|
1511
|
+
a.remove();
|
|
1512
|
+
} catch (_err) {}
|
|
1513
|
+
}, 0);
|
|
1514
|
+
});
|
|
1515
|
+
}
|
|
1516
|
+
|
|
1517
|
+
els.btnArm.addEventListener("click", async () => {
|
|
1518
|
+
playKeyClick("action");
|
|
1519
|
+
try {
|
|
1520
|
+
await sendCommand({ command: "arm", code: codeValue() });
|
|
1521
|
+
if (els.cmdStatus.style.display === "none") {
|
|
1522
|
+
showCmdStatus(`Arm sent to ${selectedIds.size} node(s).`, "ok");
|
|
1523
|
+
}
|
|
676
1524
|
} catch (err) {
|
|
677
1525
|
showCmdStatus(err.message, "err");
|
|
678
1526
|
}
|
|
@@ -681,7 +1529,9 @@
|
|
|
681
1529
|
playKeyClick("action");
|
|
682
1530
|
try {
|
|
683
1531
|
await sendCommand({ command: "disarm", code: codeValue() });
|
|
684
|
-
|
|
1532
|
+
if (els.cmdStatus.style.display === "none") {
|
|
1533
|
+
showCmdStatus(`Disarm sent to ${selectedIds.size} node(s).`, "ok");
|
|
1534
|
+
}
|
|
685
1535
|
} catch (err) {
|
|
686
1536
|
showCmdStatus(err.message, "err");
|
|
687
1537
|
}
|
|
@@ -712,12 +1562,18 @@
|
|
|
712
1562
|
else if (/^\d$/.test(e.key)) playKeyClick("key");
|
|
713
1563
|
});
|
|
714
1564
|
|
|
715
|
-
|
|
716
|
-
|
|
717
|
-
|
|
718
|
-
|
|
719
|
-
|
|
720
|
-
|
|
1565
|
+
(async function init() {
|
|
1566
|
+
try {
|
|
1567
|
+
setNodeHint(`API root: ${httpAdminRoot()}`);
|
|
1568
|
+
updateLogFilterButtons();
|
|
1569
|
+
if (showLog && els.logHint) {
|
|
1570
|
+
els.logHint.textContent = "Loading...";
|
|
1571
|
+
}
|
|
1572
|
+
await loadNodes();
|
|
1573
|
+
await loadNodeStatesForButtons().catch(() => {});
|
|
1574
|
+
await loadState();
|
|
1575
|
+
startPolling();
|
|
1576
|
+
startNodeButtonPolling();
|
|
721
1577
|
} catch (err) {
|
|
722
1578
|
setNodeHint(err.message);
|
|
723
1579
|
els.zonesHint.textContent = err.message;
|