node-red-contrib-alarm-ultimate 0.1.0 → 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.
Files changed (35) hide show
  1. package/README.md +87 -13
  2. package/docs/images/alarm-panel-mock.svg +114 -0
  3. package/docs/images/banner.svg +63 -0
  4. package/docs/images/flow-overview.svg +85 -0
  5. package/examples/README.md +32 -11
  6. package/examples/alarm-ultimate-basic.json +0 -1
  7. package/examples/alarm-ultimate-dashboard-controls.json +575 -0
  8. package/examples/alarm-ultimate-dashboard-v2.json +762 -0
  9. package/examples/alarm-ultimate-dashboard.json +3 -3
  10. package/flowfuse-node-red-dashboard-1.30.2.tgz +0 -0
  11. package/nodes/AlarmSystemUltimate.html +174 -85
  12. package/nodes/AlarmSystemUltimate.js +39 -8
  13. package/nodes/AlarmUltimateInputAdapter.html +304 -0
  14. package/nodes/AlarmUltimateInputAdapter.js +188 -0
  15. package/nodes/AlarmUltimateSiren.html +3 -3
  16. package/nodes/AlarmUltimateSiren.js +6 -2
  17. package/nodes/AlarmUltimateState.html +3 -3
  18. package/nodes/AlarmUltimateState.js +6 -2
  19. package/nodes/AlarmUltimateZone.html +11 -6
  20. package/nodes/AlarmUltimateZone.js +27 -6
  21. package/nodes/icons/alarm-ultimate-siren.svg +6 -0
  22. package/nodes/icons/alarm-ultimate-state.svg +5 -0
  23. package/nodes/icons/alarm-ultimate-zone.svg +5 -0
  24. package/nodes/icons/alarm-ultimate.svg +6 -0
  25. package/nodes/presets/input-adapter/ax-pro-hikvision-ultimate.js +34 -0
  26. package/nodes/presets/input-adapter/boolean-from-payload.js +10 -0
  27. package/nodes/presets/input-adapter/ha-on-off.js +24 -0
  28. package/nodes/presets/input-adapter/knx-ultimate.js +29 -0
  29. package/nodes/presets/input-adapter/passthrough.js +7 -0
  30. package/package.json +5 -4
  31. package/test/alarm-system.spec.js +51 -0
  32. package/test/input-adapter.spec.js +243 -0
  33. package/test/output-nodes.spec.js +3 -0
  34. package/tools/alarm-json-mapper.html +1882 -460
  35. package/tools/alarm-panel.html +630 -131
@@ -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.5);
150
- background: rgba(110, 168, 254, 0.22);
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
- <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>
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
- <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>
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
- </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>
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
- 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
- };
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
- let selectedId = "";
351
- let pollTimer = null;
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 audioCtx = null;
354
- let armingBeepTimer = null;
355
- let hasSeenState = false;
356
- let lastMode = null;
357
- let lastArmingActive = null;
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 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;
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
- els.zonesHint.textContent = `${list.length} zones`;
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
- 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 || "";
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(title)}<div style="color:var(--muted); font-size:11px">${escapeHtml(z.id || "")}</div></td>
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, "&#039;");
583
792
  }
584
793
 
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);
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
- 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 {
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
- 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
- }
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 ? `&nbsp;${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
- 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}`);
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
- els.nodeSelect.addEventListener("change", () => {
666
- selectedId = els.nodeSelect.value;
667
- resetStateAudioTracking();
668
- loadState().catch(() => {});
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
- els.btnArm.addEventListener("click", async () => {
672
- playKeyClick("action");
673
- try {
1164
+ els.btnArm.addEventListener("click", async () => {
1165
+ playKeyClick("action");
1166
+ try {
674
1167
  await sendCommand({ command: "arm", code: codeValue() });
675
- showCmdStatus("Arm sent.", "ok");
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
- showCmdStatus("Disarm sent.", "ok");
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;