node-red-contrib-alarm-ultimate 0.1.1 → 0.1.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.
- package/README.md +11 -0
- package/examples/README.md +13 -0
- package/examples/alarm-ultimate-basic.json +0 -1
- package/examples/alarm-ultimate-dashboard-controls.json +3 -2
- package/examples/alarm-ultimate-dashboard-v2.json +762 -0
- package/examples/alarm-ultimate-dashboard.json +3 -3
- package/flowfuse-node-red-dashboard-1.30.2.tgz +0 -0
- package/nodes/AlarmSystemUltimate.html +171 -82
- package/nodes/AlarmSystemUltimate.js +39 -8
- 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 +51 -0
- package/test/input-adapter.spec.js +243 -0
- package/test/output-nodes.spec.js +3 -0
- package/tools/alarm-json-mapper.html +934 -165
- package/tools/alarm-panel.html +630 -131
package/tools/alarm-panel.html
CHANGED
|
@@ -53,6 +53,19 @@
|
|
|
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
|
+
|
|
56
69
|
header {
|
|
57
70
|
padding: 16px;
|
|
58
71
|
border-bottom: 1px solid var(--border);
|
|
@@ -143,11 +156,13 @@
|
|
|
143
156
|
padding: 8px 10px;
|
|
144
157
|
cursor: pointer;
|
|
145
158
|
font-size: 12px;
|
|
159
|
+
transition: background-color 120ms ease, border-color 120ms ease, transform 80ms ease;
|
|
146
160
|
}
|
|
147
161
|
|
|
148
162
|
button.primary {
|
|
149
|
-
border-color: rgba(110, 168, 254, 0.
|
|
150
|
-
background:
|
|
163
|
+
border-color: rgba(110, 168, 254, 0.85);
|
|
164
|
+
background: var(--accent);
|
|
165
|
+
color: #fff;
|
|
151
166
|
}
|
|
152
167
|
|
|
153
168
|
button.danger {
|
|
@@ -155,6 +170,62 @@
|
|
|
155
170
|
background: rgba(255, 107, 107, 0.14);
|
|
156
171
|
}
|
|
157
172
|
|
|
173
|
+
button:hover {
|
|
174
|
+
background: rgba(110, 168, 254, 0.18);
|
|
175
|
+
border-color: rgba(110, 168, 254, 0.35);
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
button.primary:hover {
|
|
179
|
+
filter: brightness(1.05);
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
button:active {
|
|
183
|
+
transform: translateY(1px);
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
button.selected {
|
|
187
|
+
border-color: rgba(110, 168, 254, 0.9);
|
|
188
|
+
background: rgba(110, 168, 254, 0.28);
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
button.selected.ok {
|
|
192
|
+
border-color: rgba(47, 191, 113, 0.65);
|
|
193
|
+
background: rgba(47, 191, 113, 0.16);
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
button.selected.warn {
|
|
197
|
+
border-color: rgba(255, 204, 102, 0.65);
|
|
198
|
+
background: rgba(255, 204, 102, 0.14);
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
button.selected.danger {
|
|
202
|
+
border-color: rgba(255, 107, 107, 0.65);
|
|
203
|
+
background: rgba(255, 107, 107, 0.14);
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
button.node {
|
|
207
|
+
display: inline-flex;
|
|
208
|
+
align-items: center;
|
|
209
|
+
gap: 8px;
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
button.node .dot {
|
|
213
|
+
width: 8px;
|
|
214
|
+
height: 8px;
|
|
215
|
+
border-radius: 999px;
|
|
216
|
+
background: var(--border);
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
button.node.ok .dot {
|
|
220
|
+
background: var(--ok);
|
|
221
|
+
}
|
|
222
|
+
button.node.warn .dot {
|
|
223
|
+
background: var(--warn);
|
|
224
|
+
}
|
|
225
|
+
button.node.danger .dot {
|
|
226
|
+
background: var(--danger);
|
|
227
|
+
}
|
|
228
|
+
|
|
158
229
|
.status {
|
|
159
230
|
border: 1px solid var(--border);
|
|
160
231
|
border-radius: 10px;
|
|
@@ -180,6 +251,35 @@
|
|
|
180
251
|
font-size: 12px;
|
|
181
252
|
}
|
|
182
253
|
|
|
254
|
+
.status .meta .lines {
|
|
255
|
+
display: grid;
|
|
256
|
+
gap: 6px;
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
.status .meta .line {
|
|
260
|
+
display: grid;
|
|
261
|
+
grid-template-columns: 180px auto;
|
|
262
|
+
gap: 10px;
|
|
263
|
+
align-items: center;
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
.status .meta .line .name {
|
|
267
|
+
font-family: var(--mono);
|
|
268
|
+
font-size: 12px;
|
|
269
|
+
white-space: nowrap;
|
|
270
|
+
overflow: hidden;
|
|
271
|
+
text-overflow: ellipsis;
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
.status .meta .line .details {
|
|
275
|
+
font-family: var(--mono);
|
|
276
|
+
font-size: 11px;
|
|
277
|
+
color: var(--muted);
|
|
278
|
+
overflow: hidden;
|
|
279
|
+
text-overflow: ellipsis;
|
|
280
|
+
white-space: nowrap;
|
|
281
|
+
}
|
|
282
|
+
|
|
183
283
|
.pill {
|
|
184
284
|
font-family: var(--mono);
|
|
185
285
|
font-size: 12px;
|
|
@@ -201,6 +301,11 @@
|
|
|
201
301
|
color: var(--danger);
|
|
202
302
|
}
|
|
203
303
|
|
|
304
|
+
.pill.small {
|
|
305
|
+
padding: 4px 8px;
|
|
306
|
+
font-size: 11px;
|
|
307
|
+
}
|
|
308
|
+
|
|
204
309
|
table {
|
|
205
310
|
width: 100%;
|
|
206
311
|
border-collapse: collapse;
|
|
@@ -258,29 +363,35 @@
|
|
|
258
363
|
</header>
|
|
259
364
|
|
|
260
365
|
<main>
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
366
|
+
<section class="card">
|
|
367
|
+
<h2>Node</h2>
|
|
368
|
+
<div class="row">
|
|
369
|
+
<label>Alarm nodes</label>
|
|
370
|
+
<div>
|
|
371
|
+
<div class="buttons" id="nodeButtons"></div>
|
|
372
|
+
<div class="hint" id="nodeHint"></div>
|
|
373
|
+
<div class="hint" id="selectionHint"></div>
|
|
374
|
+
<div class="buttons" style="margin-top:8px;">
|
|
375
|
+
<button id="btnSelectAll" type="button">Select all</button>
|
|
376
|
+
</div>
|
|
377
|
+
</div>
|
|
378
|
+
</div>
|
|
379
|
+
<div id="nodeStatus" class="status" style="display: none">
|
|
380
|
+
<div class="left">
|
|
381
|
+
<div class="title" id="statusTitle"></div>
|
|
382
|
+
<div class="meta" id="statusMeta"></div>
|
|
383
|
+
</div>
|
|
273
384
|
<div class="pill" id="statusPill"></div>
|
|
274
385
|
</div>
|
|
275
386
|
</section>
|
|
276
387
|
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
388
|
+
<div class="grid">
|
|
389
|
+
<section class="card" id="keypadCard" data-section="keypad">
|
|
390
|
+
<h2>Keypad</h2>
|
|
391
|
+
<div class="row">
|
|
392
|
+
<label for="code">Code</label>
|
|
393
|
+
<input id="code" type="password" autocomplete="one-time-code" placeholder="(optional)" />
|
|
394
|
+
</div>
|
|
284
395
|
<div class="buttons">
|
|
285
396
|
<button class="primary" id="btnArm">Arm</button>
|
|
286
397
|
<button class="danger" id="btnDisarm">Disarm</button>
|
|
@@ -301,15 +412,20 @@
|
|
|
301
412
|
</div>
|
|
302
413
|
<p class="hint">Commands are sent to the selected node using the Node-RED admin HTTP endpoint.</p>
|
|
303
414
|
<p class="hint" id="cmdStatus" style="display: none"></p>
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
415
|
+
</section>
|
|
416
|
+
|
|
417
|
+
<section class="card" id="zonesCard" data-section="zones">
|
|
418
|
+
<h2>Zones</h2>
|
|
419
|
+
<div class="hint" id="zonesHint">Loading...</div>
|
|
420
|
+
<div class="buttons" style="margin-top:8px;">
|
|
421
|
+
<button id="btnZonesFilterOpen" type="button">OPEN</button>
|
|
422
|
+
<button id="btnZonesFilterAll" type="button">ALL</button>
|
|
423
|
+
</div>
|
|
424
|
+
<div style="overflow: auto; margin-top: 10px">
|
|
425
|
+
<table>
|
|
426
|
+
<thead>
|
|
427
|
+
<tr>
|
|
428
|
+
<th>Alarm</th>
|
|
313
429
|
<th>Zone</th>
|
|
314
430
|
<th>State</th>
|
|
315
431
|
<th>Type</th>
|
|
@@ -324,37 +440,55 @@
|
|
|
324
440
|
</main>
|
|
325
441
|
|
|
326
442
|
<script>
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
443
|
+
const els = {
|
|
444
|
+
nodeButtons: document.getElementById("nodeButtons"),
|
|
445
|
+
nodeHint: document.getElementById("nodeHint"),
|
|
446
|
+
selectionHint: document.getElementById("selectionHint"),
|
|
447
|
+
btnSelectAll: document.getElementById("btnSelectAll"),
|
|
448
|
+
nodeStatus: document.getElementById("nodeStatus"),
|
|
449
|
+
statusTitle: document.getElementById("statusTitle"),
|
|
450
|
+
statusMeta: document.getElementById("statusMeta"),
|
|
451
|
+
statusPill: document.getElementById("statusPill"),
|
|
452
|
+
zonesBody: document.getElementById("zonesBody"),
|
|
453
|
+
zonesHint: document.getElementById("zonesHint"),
|
|
454
|
+
btnZonesFilterOpen: document.getElementById("btnZonesFilterOpen"),
|
|
455
|
+
btnZonesFilterAll: document.getElementById("btnZonesFilterAll"),
|
|
456
|
+
code: document.getElementById("code"),
|
|
457
|
+
cmdStatus: document.getElementById("cmdStatus"),
|
|
458
|
+
btnArm: document.getElementById("btnArm"),
|
|
459
|
+
btnDisarm: document.getElementById("btnDisarm"),
|
|
460
|
+
};
|
|
341
461
|
|
|
342
462
|
const params = new URLSearchParams(window.location.search);
|
|
343
463
|
const preselectId = params.get("id") || "";
|
|
344
464
|
const embedMode = params.get("embed") === "1" || params.get("embed") === "true";
|
|
465
|
+
const view = (params.get("view") || "").toLowerCase();
|
|
345
466
|
|
|
346
467
|
if (embedMode) {
|
|
347
468
|
document.body.classList.add("embed");
|
|
348
469
|
}
|
|
349
470
|
|
|
350
|
-
|
|
351
|
-
|
|
471
|
+
if (view === "zones") {
|
|
472
|
+
document.body.classList.add("view-zones");
|
|
473
|
+
} else if (view === "keypad") {
|
|
474
|
+
document.body.classList.add("view-keypad");
|
|
475
|
+
}
|
|
352
476
|
|
|
353
|
-
let
|
|
354
|
-
let
|
|
355
|
-
let
|
|
356
|
-
let
|
|
357
|
-
let
|
|
477
|
+
let nodesList = [];
|
|
478
|
+
let selectedIds = new Set();
|
|
479
|
+
let pollTimer = null;
|
|
480
|
+
let nodeStateTimer = null;
|
|
481
|
+
let nodeStateById = new Map();
|
|
482
|
+
|
|
483
|
+
let audioCtx = null;
|
|
484
|
+
let armingBeepTimer = null;
|
|
485
|
+
let hasSeenState = false;
|
|
486
|
+
let lastMode = null;
|
|
487
|
+
let lastArmingActive = null;
|
|
488
|
+
let lastZonesSnapshot = [];
|
|
489
|
+
let zonesFilter = "open"; // "open" | "all"
|
|
490
|
+
let zonesFilterUserSelected = false;
|
|
491
|
+
let zonesFilterInitialized = false;
|
|
358
492
|
|
|
359
493
|
function getAudioContext() {
|
|
360
494
|
if (audioCtx) return audioCtx;
|
|
@@ -547,24 +681,99 @@
|
|
|
547
681
|
return { label: "DISARMED", cls: "" };
|
|
548
682
|
}
|
|
549
683
|
|
|
550
|
-
function
|
|
551
|
-
|
|
552
|
-
const
|
|
553
|
-
if (
|
|
554
|
-
|
|
555
|
-
|
|
684
|
+
function lastEventSummary(state) {
|
|
685
|
+
const log = state && Array.isArray(state.log) ? state.log : [];
|
|
686
|
+
const last = log.length ? log[log.length - 1] : null;
|
|
687
|
+
if (!last || typeof last !== "object") return "";
|
|
688
|
+
const e = typeof last.event === "string" ? last.event : "";
|
|
689
|
+
if (!e) return "";
|
|
690
|
+
if (e === "arm_blocked") {
|
|
691
|
+
const violations = Array.isArray(last.violations) ? last.violations : [];
|
|
692
|
+
const names = violations
|
|
693
|
+
.map((v) => (v && typeof v.name === "string" && v.name.trim() ? v.name.trim() : v && v.id ? String(v.id) : ""))
|
|
694
|
+
.filter(Boolean);
|
|
695
|
+
const shown = names.slice(0, 3).join(", ");
|
|
696
|
+
const suffix = names.length > 3 ? "…" : "";
|
|
697
|
+
return `arm_blocked${names.length ? ` (${shown}${suffix})` : ""}`;
|
|
698
|
+
}
|
|
699
|
+
if (e === "denied") {
|
|
700
|
+
const a = last.action ? String(last.action) : "";
|
|
701
|
+
return `denied${a ? ` (${a})` : ""}`;
|
|
702
|
+
}
|
|
703
|
+
if (e === "arming") {
|
|
704
|
+
const s = Number.isFinite(Number(last.seconds)) ? Number(last.seconds) : null;
|
|
705
|
+
return `arming${s !== null ? ` (${s}s)` : ""}`;
|
|
556
706
|
}
|
|
557
|
-
|
|
707
|
+
if (e === "entry_delay") {
|
|
708
|
+
const s = Number.isFinite(Number(last.seconds)) ? Number(last.seconds) : null;
|
|
709
|
+
return `entry_delay${s !== null ? ` (${s}s)` : ""}`;
|
|
710
|
+
}
|
|
711
|
+
if (e === "alarm") {
|
|
712
|
+
const k = last.kind ? String(last.kind) : "";
|
|
713
|
+
return `alarm${k ? ` (${k})` : ""}`;
|
|
714
|
+
}
|
|
715
|
+
return e;
|
|
716
|
+
}
|
|
558
717
|
|
|
559
|
-
|
|
560
|
-
|
|
561
|
-
|
|
562
|
-
|
|
563
|
-
|
|
564
|
-
|
|
718
|
+
function armingErrorPill(state) {
|
|
719
|
+
const log = state && Array.isArray(state.log) ? state.log : [];
|
|
720
|
+
const last = log.length ? log[log.length - 1] : null;
|
|
721
|
+
const e = last && typeof last.event === "string" ? last.event : "";
|
|
722
|
+
if (e === "arm_blocked") {
|
|
723
|
+
return { cls: "warn", label: "ARM BLOCKED", details: lastEventSummary(state) };
|
|
724
|
+
}
|
|
725
|
+
if (e === "denied" && last && String(last.action || "") === "arm") {
|
|
726
|
+
return { cls: "danger", label: "ARM DENIED", details: lastEventSummary(state) };
|
|
727
|
+
}
|
|
728
|
+
return null;
|
|
729
|
+
}
|
|
730
|
+
|
|
731
|
+
function updateZonesFilterButtons() {
|
|
732
|
+
if (!els.btnZonesFilterOpen || !els.btnZonesFilterAll) return;
|
|
733
|
+
els.btnZonesFilterOpen.classList.toggle("selected", zonesFilter === "open");
|
|
734
|
+
els.btnZonesFilterAll.classList.toggle("selected", zonesFilter === "all");
|
|
735
|
+
}
|
|
736
|
+
|
|
737
|
+
function setZonesFilter(next, opts) {
|
|
738
|
+
const options = opts && typeof opts === "object" ? opts : {};
|
|
739
|
+
const user = options.user === true;
|
|
740
|
+
zonesFilter = String(next || "").toLowerCase() === "all" ? "all" : "open";
|
|
741
|
+
if (user) zonesFilterUserSelected = true;
|
|
742
|
+
updateZonesFilterButtons();
|
|
743
|
+
renderZones(lastZonesSnapshot);
|
|
744
|
+
}
|
|
745
|
+
|
|
746
|
+
function renderZones(zones) {
|
|
747
|
+
els.zonesBody.innerHTML = "";
|
|
748
|
+
const list = Array.isArray(zones) ? zones : [];
|
|
749
|
+
lastZonesSnapshot = list;
|
|
750
|
+
const filtered = zonesFilter === "open" ? list.filter((z) => z && z.open) : list;
|
|
751
|
+
|
|
752
|
+
if (list.length === 0) {
|
|
753
|
+
els.zonesHint.textContent = "No zones configured.";
|
|
754
|
+
return;
|
|
755
|
+
}
|
|
756
|
+
if (filtered.length === 0) {
|
|
757
|
+
els.zonesHint.textContent = "No OPEN zones.";
|
|
758
|
+
return;
|
|
759
|
+
}
|
|
760
|
+
if (zonesFilter === "open") {
|
|
761
|
+
els.zonesHint.textContent = `${filtered.length} OPEN zones (of ${list.length})`;
|
|
762
|
+
} else {
|
|
763
|
+
els.zonesHint.textContent = `${list.length} zones`;
|
|
764
|
+
}
|
|
765
|
+
|
|
766
|
+
for (const z of filtered) {
|
|
767
|
+
const tr = document.createElement("tr");
|
|
768
|
+
const title = z.name ? `${z.name}` : z.id;
|
|
769
|
+
const stateText = z.bypassed ? "BYPASSED" : z.open ? "OPEN" : "CLOSED";
|
|
770
|
+
const stateClass = z.bypassed ? "zone-bypassed" : z.open ? "zone-open" : "zone-closed";
|
|
771
|
+
const topic = z.topic || z.topicPattern || "";
|
|
772
|
+
const alarmLabel = z.__alarmName || z.__alarmId || "";
|
|
565
773
|
|
|
566
774
|
tr.innerHTML = `
|
|
567
|
-
<td>${escapeHtml(
|
|
775
|
+
<td>${escapeHtml(alarmLabel)}</td>
|
|
776
|
+
<td>${escapeHtml(title)}</td>
|
|
568
777
|
<td class="${stateClass}">${escapeHtml(stateText)}</td>
|
|
569
778
|
<td>${escapeHtml(z.type || "")}</td>
|
|
570
779
|
<td>${escapeHtml(topic)}</td>
|
|
@@ -582,57 +791,269 @@
|
|
|
582
791
|
.replace(/'/g, "'");
|
|
583
792
|
}
|
|
584
793
|
|
|
585
|
-
|
|
586
|
-
|
|
587
|
-
|
|
588
|
-
|
|
589
|
-
|
|
590
|
-
|
|
591
|
-
|
|
592
|
-
|
|
593
|
-
|
|
594
|
-
|
|
595
|
-
|
|
596
|
-
|
|
597
|
-
|
|
598
|
-
|
|
599
|
-
|
|
600
|
-
|
|
794
|
+
function normalizePreselectIds(raw) {
|
|
795
|
+
const v = String(raw || "").trim();
|
|
796
|
+
if (!v) return [];
|
|
797
|
+
if (v.includes(",")) {
|
|
798
|
+
return v
|
|
799
|
+
.split(",")
|
|
800
|
+
.map((s) => s.trim())
|
|
801
|
+
.filter(Boolean);
|
|
802
|
+
}
|
|
803
|
+
return [v];
|
|
804
|
+
}
|
|
805
|
+
|
|
806
|
+
function persistSelectedIds() {
|
|
807
|
+
try {
|
|
808
|
+
localStorage.setItem("alarm-ultimate-panel:selectedIds", JSON.stringify(Array.from(selectedIds)));
|
|
809
|
+
} catch (_err) {}
|
|
810
|
+
}
|
|
811
|
+
|
|
812
|
+
function restoreSelectedIds() {
|
|
813
|
+
const idsFromUrl = normalizePreselectIds(preselectId);
|
|
814
|
+
if (idsFromUrl.length) return idsFromUrl;
|
|
815
|
+
try {
|
|
816
|
+
const raw = localStorage.getItem("alarm-ultimate-panel:selectedIds");
|
|
817
|
+
const parsed = JSON.parse(raw);
|
|
818
|
+
if (Array.isArray(parsed)) return parsed.map(String).filter(Boolean);
|
|
819
|
+
} catch (_err) {}
|
|
820
|
+
return [];
|
|
601
821
|
}
|
|
602
822
|
|
|
603
|
-
|
|
604
|
-
|
|
605
|
-
|
|
606
|
-
|
|
607
|
-
|
|
608
|
-
|
|
609
|
-
|
|
823
|
+
function updateSelectAllVisibility() {
|
|
824
|
+
if (!els.btnSelectAll) return;
|
|
825
|
+
const container = els.btnSelectAll.closest(".buttons") || els.btnSelectAll;
|
|
826
|
+
container.style.display = nodesList.length > 1 ? "" : "none";
|
|
827
|
+
}
|
|
828
|
+
|
|
829
|
+
function statusFromStateSafe(state) {
|
|
830
|
+
const st = statusFromState(state);
|
|
831
|
+
return st && typeof st === "object" ? st : { label: "Unknown", cls: "" };
|
|
832
|
+
}
|
|
833
|
+
|
|
834
|
+
function updateSelectionHint() {
|
|
835
|
+
const count = selectedIds.size;
|
|
836
|
+
if (!els.selectionHint) return;
|
|
837
|
+
if (count === 0) {
|
|
838
|
+
els.selectionHint.textContent = "Select at least one Alarm node.";
|
|
839
|
+
return;
|
|
840
|
+
}
|
|
841
|
+
els.selectionHint.innerHTML = `Selected: <span class="pill small">${count}</span> (click buttons to add/remove)`;
|
|
842
|
+
}
|
|
843
|
+
|
|
844
|
+
function setSelectedIds(next) {
|
|
845
|
+
const ids = Array.isArray(next) ? next.map(String).filter(Boolean) : [];
|
|
846
|
+
selectedIds = new Set(ids);
|
|
847
|
+
// enforce non-empty selection when possible
|
|
848
|
+
if (selectedIds.size === 0 && nodesList[0]) {
|
|
849
|
+
selectedIds.add(nodesList[0].id);
|
|
850
|
+
}
|
|
851
|
+
persistSelectedIds();
|
|
852
|
+
resetStateAudioTracking();
|
|
853
|
+
updateSelectionHint();
|
|
854
|
+
renderNodeButtons();
|
|
855
|
+
loadState().catch(() => {});
|
|
856
|
+
}
|
|
857
|
+
|
|
858
|
+
function selectAll() {
|
|
859
|
+
if (!nodesList.length) return;
|
|
860
|
+
setSelectedIds(nodesList.map((n) => n.id));
|
|
861
|
+
}
|
|
862
|
+
|
|
863
|
+
function toggleSelectedId(id) {
|
|
864
|
+
const key = String(id || "").trim();
|
|
865
|
+
if (!key) return;
|
|
866
|
+
const next = new Set(selectedIds);
|
|
867
|
+
if (next.has(key)) {
|
|
868
|
+
if (next.size === 1) return; // never allow empty
|
|
869
|
+
next.delete(key);
|
|
870
|
+
} else {
|
|
871
|
+
next.add(key);
|
|
872
|
+
}
|
|
873
|
+
setSelectedIds(Array.from(next));
|
|
874
|
+
}
|
|
875
|
+
|
|
876
|
+
function renderNodeButtons() {
|
|
877
|
+
if (!els.nodeButtons) return;
|
|
878
|
+
updateSelectAllVisibility();
|
|
879
|
+
els.nodeButtons.innerHTML = "";
|
|
880
|
+
if (!nodesList.length) return;
|
|
881
|
+
|
|
882
|
+
nodesList.forEach((n) => {
|
|
883
|
+
const state = nodeStateById.get(n.id);
|
|
884
|
+
const st = statusFromStateSafe(state);
|
|
885
|
+
const btn = document.createElement("button");
|
|
886
|
+
btn.type = "button";
|
|
887
|
+
btn.className = `node ${st.cls || ""} ${selectedIds.has(n.id) ? `selected ${st.cls || ""}` : ""}`.trim();
|
|
888
|
+
btn.dataset.id = n.id;
|
|
889
|
+
const name = n.name ? n.name : n.id;
|
|
890
|
+
btn.innerHTML = `<span class="dot"></span><span>${escapeHtml(name)}</span>`;
|
|
891
|
+
btn.addEventListener("click", () => toggleSelectedId(n.id));
|
|
892
|
+
els.nodeButtons.appendChild(btn);
|
|
893
|
+
});
|
|
894
|
+
}
|
|
895
|
+
|
|
896
|
+
async function loadNodes() {
|
|
897
|
+
const res = await fetch(apiUrl("/alarm-ultimate/alarm/nodes"), {
|
|
898
|
+
credentials: "same-origin",
|
|
899
|
+
headers: { ...authHeaders() },
|
|
900
|
+
});
|
|
901
|
+
if (!res.ok) throw new Error(`Unable to load nodes (${res.status})`);
|
|
902
|
+
const data = await res.json();
|
|
903
|
+
nodesList = Array.isArray(data.nodes) ? data.nodes : [];
|
|
904
|
+
updateSelectAllVisibility();
|
|
905
|
+
if (!nodesList.length) {
|
|
906
|
+
els.zonesHint.textContent = "No Alarm nodes found.";
|
|
907
|
+
setNodeHint("No Alarm nodes found. Deploy your flow and refresh.");
|
|
908
|
+
renderNodeButtons();
|
|
909
|
+
updateSelectionHint();
|
|
910
|
+
} else {
|
|
610
911
|
setNodeHint("");
|
|
912
|
+
const restored = restoreSelectedIds().filter((id) => nodesList.some((n) => n.id === id));
|
|
913
|
+
setSelectedIds(restored.length ? restored : [nodesList[0].id]);
|
|
611
914
|
}
|
|
612
915
|
}
|
|
613
916
|
|
|
614
|
-
|
|
615
|
-
|
|
616
|
-
|
|
617
|
-
|
|
618
|
-
|
|
619
|
-
|
|
620
|
-
|
|
621
|
-
|
|
622
|
-
|
|
623
|
-
|
|
624
|
-
|
|
625
|
-
|
|
626
|
-
|
|
627
|
-
|
|
628
|
-
|
|
629
|
-
|
|
630
|
-
|
|
631
|
-
|
|
632
|
-
|
|
633
|
-
|
|
634
|
-
|
|
635
|
-
|
|
917
|
+
async function fetchStateForNode(nodeId) {
|
|
918
|
+
const res = await fetch(apiUrl(`/alarm-ultimate/alarm/${encodeURIComponent(nodeId)}/state`), {
|
|
919
|
+
credentials: "same-origin",
|
|
920
|
+
headers: { ...authHeaders() },
|
|
921
|
+
});
|
|
922
|
+
if (!res.ok) throw new Error(`Unable to load state (${res.status})`);
|
|
923
|
+
return res.json();
|
|
924
|
+
}
|
|
925
|
+
|
|
926
|
+
async function loadNodeStatesForButtons() {
|
|
927
|
+
if (!nodesList.length) return;
|
|
928
|
+
const ids = nodesList.map((n) => n.id);
|
|
929
|
+
const results = await Promise.allSettled(ids.map((id) => fetchStateForNode(id)));
|
|
930
|
+
for (let i = 0; i < results.length; i += 1) {
|
|
931
|
+
const id = ids[i];
|
|
932
|
+
const r = results[i];
|
|
933
|
+
if (r.status === "fulfilled") {
|
|
934
|
+
nodeStateById.set(id, r.value && r.value.state ? r.value.state : null);
|
|
935
|
+
}
|
|
936
|
+
}
|
|
937
|
+
renderNodeButtons();
|
|
938
|
+
}
|
|
939
|
+
|
|
940
|
+
async function loadState() {
|
|
941
|
+
const ids = Array.from(selectedIds);
|
|
942
|
+
if (!ids.length) return;
|
|
943
|
+
|
|
944
|
+
const results = await Promise.allSettled(ids.map((id) => fetchStateForNode(id)));
|
|
945
|
+
const okStates = [];
|
|
946
|
+
for (let i = 0; i < results.length; i += 1) {
|
|
947
|
+
const id = ids[i];
|
|
948
|
+
const r = results[i];
|
|
949
|
+
if (r.status === "fulfilled") {
|
|
950
|
+
okStates.push({ id, data: r.value });
|
|
951
|
+
nodeStateById.set(id, r.value && r.value.state ? r.value.state : null);
|
|
952
|
+
}
|
|
953
|
+
}
|
|
954
|
+
|
|
955
|
+
renderNodeButtons();
|
|
956
|
+
updateSelectionHint();
|
|
957
|
+
|
|
958
|
+
if (okStates.length === 0) {
|
|
959
|
+
els.nodeStatus.style.display = "none";
|
|
960
|
+
els.zonesHint.textContent = "Unable to load selected alarm state.";
|
|
961
|
+
return;
|
|
962
|
+
}
|
|
963
|
+
|
|
964
|
+
// Combine status and zones.
|
|
965
|
+
const zones = [];
|
|
966
|
+
okStates.forEach(({ id, data }) => {
|
|
967
|
+
const alarmName = data && data.name ? data.name : id;
|
|
968
|
+
const list = Array.isArray(data && data.zones) ? data.zones : [];
|
|
969
|
+
list.forEach((z) => zones.push({ ...z, __alarmId: id, __alarmName: alarmName }));
|
|
970
|
+
});
|
|
971
|
+
|
|
972
|
+
zones.sort((a, b) => {
|
|
973
|
+
const an = String(a.__alarmName || "").localeCompare(String(b.__alarmName || ""));
|
|
974
|
+
if (an !== 0) return an;
|
|
975
|
+
return String(a.name || a.id || "").localeCompare(String(b.name || b.id || ""));
|
|
976
|
+
});
|
|
977
|
+
|
|
978
|
+
const multiple = okStates.length > 1;
|
|
979
|
+
if (multiple) {
|
|
980
|
+
stopArmingBeeps();
|
|
981
|
+
}
|
|
982
|
+
|
|
983
|
+
els.nodeStatus.style.display = "";
|
|
984
|
+
els.statusTitle.textContent = multiple
|
|
985
|
+
? `Selected alarms (${okStates.length})`
|
|
986
|
+
: okStates[0].data && okStates[0].data.name
|
|
987
|
+
? okStates[0].data.name
|
|
988
|
+
: okStates[0].id;
|
|
989
|
+
|
|
990
|
+
if (multiple) {
|
|
991
|
+
const lines = okStates
|
|
992
|
+
.map(({ id, data }) => {
|
|
993
|
+
const state = data && data.state ? data.state : null;
|
|
994
|
+
const st = statusFromStateSafe(state);
|
|
995
|
+
const name = data && data.name ? data.name : id;
|
|
996
|
+
const openCount = Array.isArray(data && data.zones)
|
|
997
|
+
? data.zones.filter((z) => z && z.open && !z.bypassed).length
|
|
998
|
+
: 0;
|
|
999
|
+
const lastEvt = lastEventSummary(state);
|
|
1000
|
+
const armErr = armingErrorPill(state);
|
|
1001
|
+
const details = [
|
|
1002
|
+
`state=${st.label}`,
|
|
1003
|
+
openCount ? `open=${openCount}` : "",
|
|
1004
|
+
lastEvt ? `last=${lastEvt}` : "",
|
|
1005
|
+
]
|
|
1006
|
+
.filter(Boolean)
|
|
1007
|
+
.join(" • ");
|
|
1008
|
+
|
|
1009
|
+
return `
|
|
1010
|
+
<div class="line">
|
|
1011
|
+
<div class="name">${escapeHtml(name)}</div>
|
|
1012
|
+
<div class="details">
|
|
1013
|
+
<span class="pill small ${st.cls || ""}">${escapeHtml(st.label)}</span>
|
|
1014
|
+
${
|
|
1015
|
+
armErr
|
|
1016
|
+
? ` <span class="pill small ${armErr.cls}">${escapeHtml(armErr.label)}</span>`
|
|
1017
|
+
: ""
|
|
1018
|
+
}
|
|
1019
|
+
${details ? ` ${escapeHtml(details)}` : ""}
|
|
1020
|
+
</div>
|
|
1021
|
+
</div>
|
|
1022
|
+
`;
|
|
1023
|
+
})
|
|
1024
|
+
.join("");
|
|
1025
|
+
|
|
1026
|
+
els.statusMeta.innerHTML = `<div class="lines">${lines}</div>`;
|
|
1027
|
+
els.statusPill.textContent = "MULTI";
|
|
1028
|
+
els.statusPill.className = "pill warn";
|
|
1029
|
+
} else {
|
|
1030
|
+
const state = okStates[0].data && okStates[0].data.state ? okStates[0].data.state : null;
|
|
1031
|
+
const st = statusFromStateSafe(state);
|
|
1032
|
+
const openCount = zones.filter((z) => z.open && !z.bypassed).length;
|
|
1033
|
+
const lastEvt = lastEventSummary(state);
|
|
1034
|
+
const armErr = armingErrorPill(state);
|
|
1035
|
+
const meta = [`${st.label}`, openCount ? `open=${openCount}` : "", lastEvt ? `last=${lastEvt}` : ""]
|
|
1036
|
+
.filter(Boolean)
|
|
1037
|
+
.join(" • ");
|
|
1038
|
+
els.statusMeta.textContent = meta;
|
|
1039
|
+
els.statusPill.textContent = st.label;
|
|
1040
|
+
els.statusPill.className = `pill ${st.cls}`;
|
|
1041
|
+
if (armErr && els.statusPill) {
|
|
1042
|
+
els.statusPill.textContent = armErr.label;
|
|
1043
|
+
els.statusPill.className = `pill ${armErr.cls}`;
|
|
1044
|
+
}
|
|
1045
|
+
}
|
|
1046
|
+
|
|
1047
|
+
if (!zonesFilterInitialized && !zonesFilterUserSelected) {
|
|
1048
|
+
zonesFilter = zones.some((z) => z && z.open) ? "open" : "all";
|
|
1049
|
+
zonesFilterInitialized = true;
|
|
1050
|
+
updateZonesFilterButtons();
|
|
1051
|
+
}
|
|
1052
|
+
renderZones(zones);
|
|
1053
|
+
if (!multiple) {
|
|
1054
|
+
handleStateBeeps(okStates[0].data && okStates[0].data.state);
|
|
1055
|
+
}
|
|
1056
|
+
}
|
|
636
1057
|
|
|
637
1058
|
function startPolling() {
|
|
638
1059
|
if (pollTimer) clearInterval(pollTimer);
|
|
@@ -643,18 +1064,85 @@
|
|
|
643
1064
|
}, 1000);
|
|
644
1065
|
}
|
|
645
1066
|
|
|
646
|
-
|
|
647
|
-
|
|
648
|
-
|
|
649
|
-
|
|
650
|
-
|
|
651
|
-
|
|
652
|
-
|
|
653
|
-
|
|
654
|
-
|
|
655
|
-
|
|
656
|
-
|
|
1067
|
+
function startNodeButtonPolling() {
|
|
1068
|
+
if (nodeStateTimer) clearInterval(nodeStateTimer);
|
|
1069
|
+
nodeStateTimer = setInterval(() => {
|
|
1070
|
+
loadNodeStatesForButtons().catch(() => {});
|
|
1071
|
+
}, 2500);
|
|
1072
|
+
}
|
|
1073
|
+
|
|
1074
|
+
function extractResultState(json) {
|
|
1075
|
+
const root = json && typeof json === "object" ? json : null;
|
|
1076
|
+
const result = root && root.result && typeof root.result === "object" ? root.result : null;
|
|
1077
|
+
return result && result.state ? result.state : null;
|
|
657
1078
|
}
|
|
1079
|
+
|
|
1080
|
+
function extractResultName(json, fallbackId) {
|
|
1081
|
+
const root = json && typeof json === "object" ? json : null;
|
|
1082
|
+
const result = root && root.result && typeof root.result === "object" ? root.result : null;
|
|
1083
|
+
return (result && result.name) || fallbackId || "";
|
|
1084
|
+
}
|
|
1085
|
+
|
|
1086
|
+
function isArmSuccess(state) {
|
|
1087
|
+
if (!state) return false;
|
|
1088
|
+
if (state.mode === "armed") return true;
|
|
1089
|
+
return Boolean(state.arming && state.arming.active);
|
|
1090
|
+
}
|
|
1091
|
+
|
|
1092
|
+
function isDisarmSuccess(state) {
|
|
1093
|
+
if (!state) return false;
|
|
1094
|
+
return state.mode === "disarmed";
|
|
1095
|
+
}
|
|
1096
|
+
|
|
1097
|
+
async function sendCommand(payload) {
|
|
1098
|
+
const ids = Array.from(selectedIds);
|
|
1099
|
+
if (!ids.length) return;
|
|
1100
|
+
const results = await Promise.allSettled(
|
|
1101
|
+
ids.map(async (id) => {
|
|
1102
|
+
const res = await fetch(apiUrl(`/alarm-ultimate/alarm/${encodeURIComponent(id)}/command`), {
|
|
1103
|
+
method: "POST",
|
|
1104
|
+
headers: { "content-type": "application/json", ...authHeaders() },
|
|
1105
|
+
body: JSON.stringify(payload),
|
|
1106
|
+
credentials: "same-origin",
|
|
1107
|
+
});
|
|
1108
|
+
if (!res.ok) {
|
|
1109
|
+
const text = await res.text();
|
|
1110
|
+
throw new Error(`(${id}) ${res.status}: ${text}`);
|
|
1111
|
+
}
|
|
1112
|
+
const json = await res.json().catch(() => null);
|
|
1113
|
+
const state = extractResultState(json);
|
|
1114
|
+
if (state) {
|
|
1115
|
+
nodeStateById.set(id, state);
|
|
1116
|
+
}
|
|
1117
|
+
return {
|
|
1118
|
+
id,
|
|
1119
|
+
name: extractResultName(json, id),
|
|
1120
|
+
state,
|
|
1121
|
+
};
|
|
1122
|
+
}),
|
|
1123
|
+
);
|
|
1124
|
+
const ok = results.filter((r) => r.status === "fulfilled").map((r) => r.value);
|
|
1125
|
+
const err = results
|
|
1126
|
+
.filter((r) => r.status === "rejected")
|
|
1127
|
+
.map((r) => (r.reason && r.reason.message ? r.reason.message : String(r.reason)));
|
|
1128
|
+
|
|
1129
|
+
// Heuristic feedback: HTTP 200 doesn't mean the Alarm accepted the command.
|
|
1130
|
+
const cmd = payload && typeof payload.command === "string" ? payload.command : "";
|
|
1131
|
+
const check = cmd === "disarm" ? isDisarmSuccess : cmd === "arm" ? isArmSuccess : null;
|
|
1132
|
+
const okApplied = check ? ok.filter((r) => check(r && r.state)).map((r) => r.name || r.id) : [];
|
|
1133
|
+
const maybeRejected = check ? ok.filter((r) => !check(r && r.state)).map((r) => r.name || r.id) : [];
|
|
1134
|
+
|
|
1135
|
+
renderNodeButtons();
|
|
1136
|
+
|
|
1137
|
+
if (err.length) {
|
|
1138
|
+
throw new Error(`Sent to ${ok.length}/${ids.length}. ${err[0]}`);
|
|
1139
|
+
}
|
|
1140
|
+
if (check && maybeRejected.length) {
|
|
1141
|
+
showCmdStatus(
|
|
1142
|
+
`${cmd} sent to ${ids.length}. Applied: ${okApplied.length}/${ids.length}. Not applied: ${maybeRejected.slice(0, 3).join(", ")}${maybeRejected.length > 3 ? "…" : ""}`,
|
|
1143
|
+
"warn",
|
|
1144
|
+
);
|
|
1145
|
+
}
|
|
658
1146
|
}
|
|
659
1147
|
|
|
660
1148
|
function codeValue() {
|
|
@@ -662,17 +1150,24 @@
|
|
|
662
1150
|
return v.length ? v : undefined;
|
|
663
1151
|
}
|
|
664
1152
|
|
|
665
|
-
|
|
666
|
-
|
|
667
|
-
|
|
668
|
-
|
|
669
|
-
|
|
1153
|
+
// No select: buttons manage selection.
|
|
1154
|
+
if (els.btnSelectAll) {
|
|
1155
|
+
els.btnSelectAll.addEventListener("click", () => selectAll());
|
|
1156
|
+
}
|
|
1157
|
+
if (els.btnZonesFilterOpen) {
|
|
1158
|
+
els.btnZonesFilterOpen.addEventListener("click", () => setZonesFilter("open", { user: true }));
|
|
1159
|
+
}
|
|
1160
|
+
if (els.btnZonesFilterAll) {
|
|
1161
|
+
els.btnZonesFilterAll.addEventListener("click", () => setZonesFilter("all", { user: true }));
|
|
1162
|
+
}
|
|
670
1163
|
|
|
671
|
-
|
|
672
|
-
|
|
673
|
-
|
|
1164
|
+
els.btnArm.addEventListener("click", async () => {
|
|
1165
|
+
playKeyClick("action");
|
|
1166
|
+
try {
|
|
674
1167
|
await sendCommand({ command: "arm", code: codeValue() });
|
|
675
|
-
|
|
1168
|
+
if (els.cmdStatus.style.display === "none") {
|
|
1169
|
+
showCmdStatus(`Arm sent to ${selectedIds.size} node(s).`, "ok");
|
|
1170
|
+
}
|
|
676
1171
|
} catch (err) {
|
|
677
1172
|
showCmdStatus(err.message, "err");
|
|
678
1173
|
}
|
|
@@ -681,7 +1176,9 @@
|
|
|
681
1176
|
playKeyClick("action");
|
|
682
1177
|
try {
|
|
683
1178
|
await sendCommand({ command: "disarm", code: codeValue() });
|
|
684
|
-
|
|
1179
|
+
if (els.cmdStatus.style.display === "none") {
|
|
1180
|
+
showCmdStatus(`Disarm sent to ${selectedIds.size} node(s).`, "ok");
|
|
1181
|
+
}
|
|
685
1182
|
} catch (err) {
|
|
686
1183
|
showCmdStatus(err.message, "err");
|
|
687
1184
|
}
|
|
@@ -716,8 +1213,10 @@
|
|
|
716
1213
|
try {
|
|
717
1214
|
setNodeHint(`API root: ${httpAdminRoot()}`);
|
|
718
1215
|
await loadNodes();
|
|
1216
|
+
await loadNodeStatesForButtons().catch(() => {});
|
|
719
1217
|
await loadState();
|
|
720
1218
|
startPolling();
|
|
1219
|
+
startNodeButtonPolling();
|
|
721
1220
|
} catch (err) {
|
|
722
1221
|
setNodeHint(err.message);
|
|
723
1222
|
els.zonesHint.textContent = err.message;
|