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.
@@ -142,11 +142,26 @@
142
142
  padding: 8px 10px;
143
143
  cursor: pointer;
144
144
  font-size: 12px;
145
+ transition: background-color 120ms ease, border-color 120ms ease, transform 80ms ease;
145
146
  }
146
147
 
147
148
  button.primary {
148
- border-color: rgba(110, 168, 254, 0.5);
149
- background: rgba(110, 168, 254, 0.22);
149
+ border-color: rgba(110, 168, 254, 0.85);
150
+ background: var(--accent);
151
+ color: #ffffff;
152
+ }
153
+
154
+ button:hover {
155
+ background: rgba(110, 168, 254, 0.18);
156
+ border-color: rgba(110, 168, 254, 0.35);
157
+ }
158
+
159
+ button.primary:hover {
160
+ filter: brightness(1.05);
161
+ }
162
+
163
+ button:active {
164
+ transform: translateY(1px);
150
165
  }
151
166
 
152
167
  button:disabled {
@@ -207,6 +222,77 @@
207
222
  grid-template-columns: 1fr;
208
223
  }
209
224
  }
225
+
226
+ table {
227
+ width: 100%;
228
+ border-collapse: collapse;
229
+ font-family: var(--mono);
230
+ font-size: 12px;
231
+ }
232
+
233
+ th,
234
+ td {
235
+ border-bottom: 1px solid var(--border);
236
+ padding: 8px 6px;
237
+ text-align: left;
238
+ vertical-align: top;
239
+ }
240
+
241
+ th {
242
+ color: var(--muted);
243
+ font-weight: 600;
244
+ font-size: 11px;
245
+ letter-spacing: 0.2px;
246
+ }
247
+
248
+ td input[type="text"],
249
+ td select {
250
+ width: 100%;
251
+ padding: 6px 8px;
252
+ font-size: 12px;
253
+ }
254
+
255
+ .btn-danger {
256
+ border-color: rgba(255, 107, 107, 0.6);
257
+ background: rgba(255, 107, 107, 0.12);
258
+ color: var(--text);
259
+ }
260
+
261
+ .btn-danger:hover {
262
+ background: rgba(255, 107, 107, 0.18);
263
+ border-color: rgba(255, 107, 107, 0.7);
264
+ }
265
+
266
+ .pill {
267
+ display: inline-block;
268
+ padding: 2px 8px;
269
+ border-radius: 999px;
270
+ border: 1px solid var(--border);
271
+ font-size: 11px;
272
+ color: var(--muted);
273
+ font-family: var(--mono);
274
+ }
275
+
276
+ .pill.ok {
277
+ border-color: rgba(47, 191, 113, 0.55);
278
+ color: var(--ok);
279
+ }
280
+
281
+ .pill.warn {
282
+ border-color: rgba(255, 204, 102, 0.55);
283
+ color: var(--warn);
284
+ }
285
+
286
+ .pill.err {
287
+ border-color: rgba(255, 107, 107, 0.55);
288
+ color: var(--danger);
289
+ }
290
+
291
+ .disabled {
292
+ opacity: 0.55;
293
+ pointer-events: none;
294
+ filter: grayscale(0.15);
295
+ }
210
296
  </style>
211
297
  </head>
212
298
 
@@ -221,139 +307,160 @@
221
307
  </header>
222
308
 
223
309
  <main>
224
- <div class="grid">
225
- <section class="card">
226
- <h2>1) Input message</h2>
227
- <textarea id="input" spellcheck="false"
228
- placeholder="Paste JSON here (strict JSON).&#10;&#10;Tip: this tool also accepts JS-style objects with unquoted keys and // comments."></textarea>
229
- <div class="buttons">
230
- <button id="btn-load-knx">Load JSON sample</button>
231
- <button id="btn-load-ets">Load ETS sample</button>
232
- <button id="btn-load-ha">Load HA sample</button>
233
- <button id="btn-load-ha-registry">Load HA registry sample</button>
234
- <button id="btn-clear">Clear</button>
235
- <button class="primary" id="btn-parse">Parse</button>
236
- </div>
237
- <p class="hint">
238
- If your input is not strict JSON (comments, unquoted keys), click
239
- Parse anyway: the tool will try to normalize it.
240
- </p>
241
- <div id="parse-status" class="status warn" style="display: none"></div>
242
- </section>
243
-
244
- <section class="card">
245
- <h2>2) Field mapping</h2>
246
- <div id="ets-hint" class="status ok" style="display: none"></div>
247
- <div id="ha-hint" class="status ok" style="display: none"></div>
248
- <div id="ets-mapping" style="display: none">
249
- <div class="row">
250
- <label for="etsAddressFilter">ETS address filter</label>
251
- <input
252
- type="text"
253
- id="etsAddressFilter"
254
- placeholder="Examples: 0/0/* • 0/1/?? • */7/* (comma or newline separated)"
255
- />
256
- </div>
257
- <div class="row">
258
- <label for="etsNameFilter">ETS name filter</label>
259
- <input
260
- type="text"
261
- id="etsNameFilter"
262
- placeholder='Optional. Examples: Front door • *garage* (supports * and ?)'
263
- />
310
+ <section class="card">
311
+ <h2>Zones</h2>
312
+ <div class="buttons">
313
+ <button id="btn-zone-add">Add zone</button>
314
+ <button class="primary" id="btn-wizard-toggle">Import zones wizard</button>
315
+ </div>
316
+ <p id="zone-context" class="hint"></p>
317
+ <p class="hint" style="margin-top:4px;">
318
+ Connection: <span id="zone-connection" class="pill">Not connected</span>
319
+ </p>
320
+ <p id="zone-autosave" class="hint" style="display:none;"></p>
321
+ <div style="overflow:auto; border:1px solid var(--border); border-radius:10px;">
322
+ <table>
323
+ <thead>
324
+ <tr>
325
+ <th>Topic / Pattern</th>
326
+ <th style="width: 140px;">Match</th>
327
+ <th style="width: 220px;">Name</th>
328
+ <th style="width: 140px;">Kind</th>
329
+ <th style="width: 90px;">Actions</th>
330
+ </tr>
331
+ </thead>
332
+ <tbody id="zone-table-body"></tbody>
333
+ </table>
334
+ </div>
335
+ <div id="zone-list-status" class="status warn" style="display:none; margin-top:10px;"></div>
336
+ </section>
337
+
338
+ <div id="wizard" style="display:none;">
339
+ <div class="grid">
340
+ <section class="card" id="wizard-step1">
341
+ <h2>Step 1 — Paste data</h2>
342
+ <textarea id="input" spellcheck="false"
343
+ placeholder="Paste JSON here (message/HA export) or TSV (ETS export).&#10;&#10;Tip: this tool also accepts JS-style objects with unquoted keys and // comments."></textarea>
344
+ <div class="buttons">
345
+ <select id="sampleKind" style="max-width: 260px;">
346
+ <option value="">(optional) load a sample…</option>
347
+ <option value="knx">JSON message (KNX)</option>
348
+ <option value="ets">ETS export (TSV)</option>
349
+ <option value="ha">Home Assistant states (JSON)</option>
350
+ <option value="ha_registry">Home Assistant entity registry (JSON)</option>
351
+ </select>
352
+ <button id="btn-load-sample">Load sample</button>
353
+ <button id="btn-clear">Clear</button>
354
+ <button class="primary" id="btn-parse">Parse / detect</button>
264
355
  </div>
265
356
  <p class="hint">
266
- Address patterns are matched against the ETS <span class="mono">Address</span> column (e.g.
267
- <span class="mono">0/0/1</span>). Name filter is matched against <span class="mono">Description</span>,
268
- fallback to <span class="mono">Group name</span>.
357
+ Parse detects the format and unlocks the next step.
269
358
  </p>
270
- </div>
271
- <div id="ha-mapping" style="display: none">
272
- <div class="row">
273
- <label for="haEntityFilter">HA entity filter</label>
274
- <input
275
- type="text"
276
- id="haEntityFilter"
277
- placeholder="Examples: binary_sensor.*door* • *_pir • switch.* (comma or newline separated)"
278
- />
279
- </div>
280
- <div class="row">
281
- <label for="haNameFilter">HA name filter</label>
282
- <input
283
- type="text"
284
- id="haNameFilter"
285
- placeholder='Optional. Examples: Front door • *garage* (supports * and ?)'
286
- />
359
+ <div id="parse-status" class="status warn" style="display: none"></div>
360
+ </section>
361
+
362
+ <section class="card disabled" id="wizard-step2">
363
+ <h2>Step 2 — Map & generate</h2>
364
+ <div id="ets-hint" class="status ok" style="display: none"></div>
365
+ <div id="ha-hint" class="status ok" style="display: none"></div>
366
+ <div id="ets-mapping" style="display: none">
367
+ <div class="row">
368
+ <label for="etsAddressFilter">ETS address filter</label>
369
+ <input
370
+ type="text"
371
+ id="etsAddressFilter"
372
+ placeholder="Examples: 0/0/* • 0/1/?? • */7/* (comma or newline separated)"
373
+ />
374
+ </div>
375
+ <div class="row">
376
+ <label for="etsNameFilter">ETS name filter</label>
377
+ <input
378
+ type="text"
379
+ id="etsNameFilter"
380
+ placeholder='Optional. Examples: Front door • *garage* (supports * and ?)'
381
+ />
382
+ </div>
383
+ <p class="hint">
384
+ Address patterns are matched against the ETS <span class="mono">Address</span> column (e.g.
385
+ <span class="mono">0/0/1</span>). Name filter is matched against <span class="mono">Description</span>,
386
+ fallback to <span class="mono">Group name</span>.
387
+ </p>
287
388
  </div>
288
- <div class="row">
289
- <label for="haDomains">HA domains</label>
290
- <input
291
- type="text"
292
- id="haDomains"
293
- placeholder="Optional. Example: binary_sensor,input_boolean,switch (default: boolean-like domains)"
294
- />
389
+ <div id="ha-mapping" style="display: none">
390
+ <div class="row">
391
+ <label for="haEntityFilter">HA entity filter</label>
392
+ <input
393
+ type="text"
394
+ id="haEntityFilter"
395
+ placeholder="Examples: binary_sensor.*door* • *_pir • switch.* (comma or newline separated)"
396
+ />
397
+ </div>
398
+ <div class="row">
399
+ <label for="haNameFilter">HA name filter</label>
400
+ <input
401
+ type="text"
402
+ id="haNameFilter"
403
+ placeholder='Optional. Examples: Front door • *garage* (supports * and ?)'
404
+ />
405
+ </div>
406
+ <div class="row">
407
+ <label for="haDomains">HA domains</label>
408
+ <input
409
+ type="text"
410
+ id="haDomains"
411
+ placeholder="Optional. Example: binary_sensor,input_boolean,switch (default: boolean-like domains)"
412
+ />
413
+ </div>
414
+ <div class="row">
415
+ <label for="haIncludeDisabled">Include disabled</label>
416
+ <input type="checkbox" id="haIncludeDisabled" style="width:auto; margin-top:7px;" />
417
+ </div>
418
+ <p class="hint">
419
+ Paste Home Assistant JSON (e.g. the array returned by <span class="mono">/api/states</span>). The zone topic
420
+ will be the <span class="mono">entity_id</span>. Names use <span class="mono">attributes.friendly_name</span>
421
+ if present.
422
+ </p>
295
423
  </div>
296
- <div class="row">
297
- <label for="haIncludeDisabled">Include disabled</label>
298
- <input type="checkbox" id="haIncludeDisabled" style="width:auto; margin-top:7px;" />
424
+ <div id="json-mapping">
425
+ <div class="row">
426
+ <label for="topicPath">Topic path</label>
427
+ <select id="topicPath"></select>
428
+ </div>
429
+ <div class="row">
430
+ <label for="valuePath">Value path</label>
431
+ <select id="valuePath"></select>
432
+ </div>
433
+ <div class="row">
434
+ <label for="namePath">Zone name path</label>
435
+ <select id="namePath"></select>
436
+ </div>
299
437
  </div>
300
438
  <p class="hint">
301
- Paste Home Assistant JSON (e.g. the array returned by <span class="mono">/api/states</span>). The zone topic
302
- will be the <span class="mono">entity_id</span>. Names use <span class="mono">attributes.friendly_name</span>
303
- if present.
439
+ Alarm expects zone messages with
440
+ <span class="mono">msg.topic</span> and a boolean value in the
441
+ configured “With Input” property (default
442
+ <span class="mono">payload</span>).
304
443
  </p>
305
- </div>
306
- <div id="json-mapping">
307
- <div class="row">
308
- <label for="topicPath">Topic path</label>
309
- <select id="topicPath"></select>
444
+ <div class="buttons">
445
+ <label class="mono" style="display:flex; align-items:center; gap:8px;">
446
+ <input type="checkbox" id="appendZones" checked />
447
+ Append to zone list
448
+ </label>
449
+ <button class="primary" id="btn-generate">Generate zones</button>
310
450
  </div>
311
- <div class="row">
312
- <label for="valuePath">Value path</label>
313
- <select id="valuePath"></select>
314
- </div>
315
- <div class="row">
316
- <label for="namePath">Zone name path</label>
317
- <select id="namePath"></select>
318
- </div>
319
- </div>
320
- <p class="hint">
321
- Alarm expects zone messages with
322
- <span class="mono">msg.topic</span> and a boolean value in the
323
- configured “With Input” property (default
324
- <span class="mono">payload</span>).
325
- </p>
326
- <div class="buttons">
327
- <button class="primary" id="btn-generate">Generate output</button>
328
- </div>
329
- <div id="gen-status" class="status warn" style="display: none"></div>
330
- </section>
331
- </div>
332
-
333
- <section class="card">
334
- <h2>3) Zone JSON template (for Alarm node)</h2>
335
- <div class="buttons">
336
- <button id="btn-copy-zone" disabled>Copy</button>
451
+ <div id="gen-status" class="status warn" style="display: none"></div>
452
+ </section>
337
453
  </div>
338
- <textarea id="zone-output" spellcheck="false"
339
- placeholder="Click “Generate output” to create a zone template, then edit it here if needed."></textarea>
340
- <p class="hint">
341
- This creates a single zone object using the mapped topic and optional
342
- name. Paste it into the Zones field (legacy: one JSON object per line,
343
- or formatted: JSON array).
344
- </p>
345
- </section>
454
+ </div>
346
455
  </main>
347
456
 
348
457
  <script>
349
- const els = {
350
- input: document.getElementById("input"),
351
- btnParse: document.getElementById("btn-parse"),
352
- btnLoadKnx: document.getElementById("btn-load-knx"),
353
- btnLoadEts: document.getElementById("btn-load-ets"),
354
- btnLoadHa: document.getElementById("btn-load-ha"),
355
- btnLoadHaRegistry: document.getElementById("btn-load-ha-registry"),
356
- btnClear: document.getElementById("btn-clear"),
458
+ const els = {
459
+ input: document.getElementById("input"),
460
+ btnParse: document.getElementById("btn-parse"),
461
+ sampleKind: document.getElementById("sampleKind"),
462
+ btnLoadSample: document.getElementById("btn-load-sample"),
463
+ btnClear: document.getElementById("btn-clear"),
357
464
  parseStatus: document.getElementById("parse-status"),
358
465
  genStatus: document.getElementById("gen-status"),
359
466
  topicPath: document.getElementById("topicPath"),
@@ -369,11 +476,19 @@
369
476
  haNameFilter: document.getElementById("haNameFilter"),
370
477
  haDomains: document.getElementById("haDomains"),
371
478
  haIncludeDisabled: document.getElementById("haIncludeDisabled"),
372
- jsonMapping: document.getElementById("json-mapping"),
373
- btnGenerate: document.getElementById("btn-generate"),
374
- zoneOutput: document.getElementById("zone-output"),
375
- btnCopyZone: document.getElementById("btn-copy-zone"),
376
- };
479
+ jsonMapping: document.getElementById("json-mapping"),
480
+ appendZones: document.getElementById("appendZones"),
481
+ btnGenerate: document.getElementById("btn-generate"),
482
+ btnZoneAdd: document.getElementById("btn-zone-add"),
483
+ btnWizardToggle: document.getElementById("btn-wizard-toggle"),
484
+ zoneContext: document.getElementById("zone-context"),
485
+ zoneAutosave: document.getElementById("zone-autosave"),
486
+ zoneConnection: document.getElementById("zone-connection"),
487
+ zoneTableBody: document.getElementById("zone-table-body"),
488
+ zoneListStatus: document.getElementById("zone-list-status"),
489
+ wizardStep2: document.getElementById("wizard-step2"),
490
+ wizard: document.getElementById("wizard"),
491
+ };
377
492
 
378
493
  const KNX_SAMPLE = `{
379
494
  topic: "0/1/2",
@@ -460,8 +575,458 @@
460
575
  "version": 1
461
576
  }`;
462
577
 
463
- let parsedObject = null;
464
- let lastGenerated = null;
578
+ const page = {
579
+ origin: window.location.origin,
580
+ params: new URLSearchParams(window.location.search),
581
+ get alarmNodeId() {
582
+ return String(this.params.get("id") || "").trim();
583
+ },
584
+ get alarmNodeName() {
585
+ return String(this.params.get("name") || "").trim();
586
+ },
587
+ get hasOpener() {
588
+ try {
589
+ return Boolean(window.opener && !window.opener.closed);
590
+ } catch (_err) {
591
+ return false;
592
+ }
593
+ },
594
+ };
595
+
596
+ const bc =
597
+ typeof BroadcastChannel === "function"
598
+ ? new BroadcastChannel("alarm-ultimate-zones")
599
+ : null;
600
+
601
+ let parsedObject = null;
602
+ let lastGenerated = null;
603
+ let zonesModel = [];
604
+ let autoSaveTimer = null;
605
+ let lastAutoSavedJson = null;
606
+ let zonesJsonText = "[]";
607
+ let editorConnected = false;
608
+ let isEditingZoneTable = false;
609
+
610
+ function setAutosaveHint(message) {
611
+ if (!els.zoneAutosave) return;
612
+ if (!message) {
613
+ els.zoneAutosave.style.display = "none";
614
+ els.zoneAutosave.textContent = "";
615
+ return;
616
+ }
617
+ els.zoneAutosave.style.display = "";
618
+ els.zoneAutosave.textContent = message;
619
+ }
620
+
621
+ function setConnectionState(state) {
622
+ if (!els.zoneConnection) return;
623
+ const s = String(state || "").trim();
624
+ if (s === "connected") {
625
+ els.zoneConnection.textContent = "Connected";
626
+ els.zoneConnection.className = "pill ok";
627
+ return;
628
+ }
629
+ if (s === "connecting") {
630
+ els.zoneConnection.textContent = "Connecting…";
631
+ els.zoneConnection.className = "pill warn";
632
+ return;
633
+ }
634
+ els.zoneConnection.textContent = "Not connected";
635
+ els.zoneConnection.className = "pill err";
636
+ }
637
+
638
+ function canTalkToEditor() {
639
+ return Boolean(page.alarmNodeId) && (page.hasOpener || Boolean(bc));
640
+ }
641
+
642
+ function isConnectedToEditor() {
643
+ return Boolean(page.alarmNodeId) && editorConnected === true;
644
+ }
645
+
646
+ function normalizeZonesJson(text) {
647
+ const raw = String(text || "").trim();
648
+ if (!raw) return [];
649
+ const parsed = JSON.parse(raw);
650
+ if (Array.isArray(parsed)) return parsed.filter((z) => z && typeof z === "object");
651
+ if (parsed && typeof parsed === "object") return [parsed];
652
+ return [];
653
+ }
654
+
655
+ function sanitizeId(value) {
656
+ const id = String(value || "")
657
+ .trim()
658
+ .replace(/[^\w]+/g, "_")
659
+ .replace(/^_+|_+$/g, "")
660
+ .toLowerCase();
661
+ return id || "";
662
+ }
663
+
664
+ function buildDefaultZone(index) {
665
+ return {
666
+ id: `zone_${index + 1}`,
667
+ name: `Zone ${index + 1}`,
668
+ topic: "",
669
+ type: "perimeter",
670
+ entry: false,
671
+ bypassable: true,
672
+ chime: false,
673
+ };
674
+ }
675
+
676
+ function zoneKind(zone) {
677
+ if (zone && zone.entry === true) return "entry";
678
+ const type = zone && typeof zone.type === "string" ? zone.type : "perimeter";
679
+ if (["fire", "tamper", "24h"].includes(type)) return type;
680
+ return "perimeter";
681
+ }
682
+
683
+ function applyZoneKind(zone, kind) {
684
+ const k = String(kind || "").trim().toLowerCase();
685
+ if (k === "entry") {
686
+ zone.type = "perimeter";
687
+ zone.entry = true;
688
+ return;
689
+ }
690
+ zone.type = ["fire", "tamper", "24h"].includes(k) ? k : "perimeter";
691
+ zone.entry = false;
692
+ }
693
+
694
+ function cleanZoneForJson(zone, index) {
695
+ const z = zone && typeof zone === "object" ? { ...zone } : {};
696
+ const name = String(z.name || "").trim();
697
+
698
+ // Preserve explicit ids coming from existing Alarm config.
699
+ const explicitId = typeof z.id === "string" ? z.id.trim() : "";
700
+
701
+ if (typeof z.topic === "string") z.topic = z.topic.trim();
702
+ if (typeof z.topicPattern === "string") z.topicPattern = z.topicPattern.trim();
703
+
704
+ if (z.topicPattern && !z.topicPattern.trim()) delete z.topicPattern;
705
+ if (z.topic && !z.topic.trim()) delete z.topic;
706
+
707
+ if (!z.topic && !z.topicPattern) {
708
+ z.topic = "";
709
+ }
710
+
711
+ if (explicitId) {
712
+ z.id = explicitId;
713
+ } else {
714
+ const seed = sanitizeId(z.topic || z.topicPattern || name || `zone_${index + 1}`);
715
+ z.id = seed || `zone_${index + 1}`;
716
+ }
717
+
718
+ z.name = name || z.id;
719
+
720
+ const type = typeof z.type === "string" ? z.type.trim().toLowerCase() : "perimeter";
721
+ z.type = type || "perimeter";
722
+
723
+ z.entry = z.entry === true;
724
+ z.bypassable = z.bypassable !== false;
725
+ z.chime = z.chime === true;
726
+
727
+ // Drop UI-only fields if any.
728
+ delete z.__errors;
729
+ delete z.__idExplicit;
730
+ delete z.__idSource;
731
+ return z;
732
+ }
733
+
734
+ function syncJsonFromModel() {
735
+ const payload = zonesModel
736
+ .map((z, idx) => {
737
+ const hadId = Boolean(z && typeof z === "object" && z.__idExplicit === true);
738
+ const cleaned = cleanZoneForJson(z, idx);
739
+ cleaned.__idSource = hadId ? "explicit" : "generated";
740
+ return cleaned;
741
+ })
742
+ .filter((z) => z && typeof z === "object");
743
+
744
+ // Ensure generated ids are unique (keep explicit duplicates as validation errors).
745
+ const used = new Set();
746
+ for (const z of payload) {
747
+ const base = String(z.id || "").trim();
748
+ const key = base.toLowerCase();
749
+ if (!base) continue;
750
+ if (!used.has(key)) {
751
+ used.add(key);
752
+ continue;
753
+ }
754
+ if (z.__idSource === "explicit") {
755
+ continue;
756
+ }
757
+ let suffix = 2;
758
+ while (used.has(`${key}_${suffix}`)) suffix += 1;
759
+ z.id = `${base}_${suffix}`;
760
+ used.add(`${key}_${suffix}`);
761
+ }
762
+
763
+ payload.forEach((z) => {
764
+ delete z.__idSource;
765
+ });
766
+
767
+ zonesJsonText = payload.length === 1 ? JSON.stringify(payload[0], null, 2) : JSON.stringify(payload, null, 2);
768
+ return payload;
769
+ }
770
+
771
+ function scheduleAutosave(errors) {
772
+ if (isEditingZoneTable) {
773
+ if (autoSaveTimer) window.clearTimeout(autoSaveTimer);
774
+ autoSaveTimer = null;
775
+ setAutosaveHint("Autosave paused while editing zones.");
776
+ return;
777
+ }
778
+ if (!page.alarmNodeId) {
779
+ setAutosaveHint("");
780
+ setConnectionState("disconnected");
781
+ return;
782
+ }
783
+ if (!canTalkToEditor()) {
784
+ setAutosaveHint("Autosave unavailable: open this page from the Alarm node editor.");
785
+ setConnectionState("disconnected");
786
+ return;
787
+ }
788
+ if (!isConnectedToEditor()) {
789
+ setConnectionState("connecting");
790
+ }
791
+ if (errors && errors.length > 0) {
792
+ setAutosaveHint("Autosave paused: fix zone errors to save.");
793
+ return;
794
+ }
795
+ if (autoSaveTimer) window.clearTimeout(autoSaveTimer);
796
+ autoSaveTimer = window.setTimeout(() => {
797
+ autoSaveTimer = null;
798
+ sendZonesToEditor(true);
799
+ }, 80);
800
+ }
801
+
802
+ function flushZonesToEditor(quiet) {
803
+ if (!page.alarmNodeId) return;
804
+ if (!canTalkToEditor()) return;
805
+ const errors = validateModel();
806
+ if (errors && errors.length > 0) return;
807
+ if (autoSaveTimer) window.clearTimeout(autoSaveTimer);
808
+ autoSaveTimer = null;
809
+ sendZonesToEditor(quiet !== false);
810
+ }
811
+
812
+ function validateModel() {
813
+ const errors = [];
814
+ zonesModel.forEach((zone, idx) => {
815
+ zone.__errors = [];
816
+ const topic = String(zone.topic || "").trim();
817
+ const topicPattern = String(zone.topicPattern || "").trim();
818
+
819
+ if (!topic && !topicPattern) zone.__errors.push("Missing topic/pattern.");
820
+ if (topic && topicPattern) zone.__errors.push("Choose topic OR pattern (not both).");
821
+ });
822
+
823
+ // Detect duplicates for explicit ids only.
824
+ const seenExplicit = new Map();
825
+ zonesModel.forEach((z, idx) => {
826
+ if (!(z && typeof z === "object" && z.__idExplicit === true)) return;
827
+ const id = typeof z.id === "string" ? z.id.trim() : "";
828
+ if (!id) return;
829
+ const key = id.toLowerCase();
830
+ if (!seenExplicit.has(key)) seenExplicit.set(key, []);
831
+ seenExplicit.get(key).push(idx);
832
+ });
833
+ for (const [key, indexes] of seenExplicit.entries()) {
834
+ if (indexes.length <= 1) continue;
835
+ indexes.forEach((idx) => {
836
+ zonesModel[idx].__errors.push(`Duplicate id (from JSON): ${key}`);
837
+ });
838
+ }
839
+
840
+ zonesModel.forEach((z) => {
841
+ if (z.__errors && z.__errors.length) errors.push(...z.__errors);
842
+ });
843
+ return errors;
844
+ }
845
+
846
+ function refreshZonesMeta() {
847
+ const errors = validateModel();
848
+ syncJsonFromModel();
849
+ if (errors.length === 0) {
850
+ hideStatus(els.zoneListStatus);
851
+ } else {
852
+ showStatus(
853
+ els.zoneListStatus,
854
+ "warn",
855
+ `Zones: ${errors[0]}${errors.length > 1 ? ` (+${errors.length - 1} more)` : ""}`,
856
+ );
857
+ }
858
+ scheduleAutosave(errors);
859
+ }
860
+
861
+ function renderZones() {
862
+ els.zoneTableBody.innerHTML = "";
863
+
864
+ zonesModel.forEach((zone, index) => {
865
+ const tr = document.createElement("tr");
866
+ tr.dataset.index = String(index);
867
+
868
+ const tdValue = document.createElement("td");
869
+ const valueInput = document.createElement("input");
870
+ valueInput.type = "text";
871
+ valueInput.value = String(zone.topicPattern || zone.topic || "");
872
+ valueInput.placeholder = "sensor/frontdoor or ^sensor/.*_door$";
873
+ valueInput.dataset.field = "topicValue";
874
+ tdValue.appendChild(valueInput);
875
+
876
+ const tdMatch = document.createElement("td");
877
+ const matchSelect = document.createElement("select");
878
+ matchSelect.dataset.field = "match";
879
+ const optTopic = document.createElement("option");
880
+ optTopic.value = "topic";
881
+ optTopic.textContent = "Topic";
882
+ const optPattern = document.createElement("option");
883
+ optPattern.value = "topicPattern";
884
+ optPattern.textContent = "Regex";
885
+ matchSelect.appendChild(optTopic);
886
+ matchSelect.appendChild(optPattern);
887
+ matchSelect.value = zone.topicPattern ? "topicPattern" : "topic";
888
+ tdMatch.appendChild(matchSelect);
889
+
890
+ const tdName = document.createElement("td");
891
+ const nameInput = document.createElement("input");
892
+ nameInput.type = "text";
893
+ nameInput.value = String(zone.name || "");
894
+ nameInput.placeholder = "Front door";
895
+ nameInput.dataset.field = "name";
896
+ tdName.appendChild(nameInput);
897
+
898
+ const tdKind = document.createElement("td");
899
+ const kindSelect = document.createElement("select");
900
+ kindSelect.dataset.field = "kind";
901
+ [
902
+ ["perimeter", "Perimeter"],
903
+ ["entry", "Entry"],
904
+ ["24h", "24h"],
905
+ ["tamper", "Tamper"],
906
+ ["fire", "Fire"],
907
+ ].forEach(([value, label]) => {
908
+ const opt = document.createElement("option");
909
+ opt.value = value;
910
+ opt.textContent = label;
911
+ kindSelect.appendChild(opt);
912
+ });
913
+ kindSelect.value = zoneKind(zone);
914
+ tdKind.appendChild(kindSelect);
915
+
916
+ const tdActions = document.createElement("td");
917
+ const delBtn = document.createElement("button");
918
+ delBtn.type = "button";
919
+ delBtn.textContent = "Delete";
920
+ delBtn.className = "btn-danger";
921
+ delBtn.dataset.action = "delete";
922
+ tdActions.appendChild(delBtn);
923
+
924
+ tr.appendChild(tdValue);
925
+ tr.appendChild(tdMatch);
926
+ tr.appendChild(tdName);
927
+ tr.appendChild(tdKind);
928
+ tr.appendChild(tdActions);
929
+
930
+ els.zoneTableBody.appendChild(tr);
931
+ });
932
+
933
+ refreshZonesMeta();
934
+ }
935
+
936
+ function setZonesFromJson(text) {
937
+ const zones = normalizeZonesJson(text);
938
+ zonesModel = zones.map((z, idx) => {
939
+ const hasExplicitId = Boolean(z && typeof z === "object" && typeof z.id === "string" && z.id.trim().length > 0);
940
+ const cleaned = cleanZoneForJson(z, idx);
941
+ cleaned.__idExplicit = hasExplicitId;
942
+ return cleaned;
943
+ });
944
+ renderZones();
945
+ }
946
+
947
+ function addZone(zone) {
948
+ const next = zone && typeof zone === "object" ? cleanZoneForJson(zone, zonesModel.length) : buildDefaultZone(zonesModel.length);
949
+ // Let the id be generated automatically (or preserved if present).
950
+ if (next && typeof next === "object" && typeof next.id === "string") {
951
+ if (next.id.startsWith("zone_")) next.id = "";
952
+ }
953
+ if (next && typeof next === "object") next.__idExplicit = false;
954
+ zonesModel.push(next);
955
+ renderZones();
956
+ // Focus new row topic field.
957
+ try {
958
+ const rows = els.zoneTableBody.querySelectorAll("tr");
959
+ const last = rows[rows.length - 1];
960
+ const input = last ? last.querySelector('input[data-field="topicValue"]') : null;
961
+ if (input) input.focus();
962
+ } catch (_err) {}
963
+ }
964
+
965
+ function sendZonesToEditor(quiet) {
966
+ if (!page.alarmNodeId) return;
967
+ if (!canTalkToEditor()) {
968
+ setAutosaveHint("Autosave unavailable: open this page from the Alarm node editor.");
969
+ setConnectionState("disconnected");
970
+ return;
971
+ }
972
+ if (!isConnectedToEditor()) {
973
+ setConnectionState("connecting");
974
+ }
975
+
976
+ const payload = syncJsonFromModel();
977
+ const json = zonesJsonText;
978
+ if (lastAutoSavedJson === json) {
979
+ setAutosaveHint(`Autosave: saved ${payload.length} zones.`);
980
+ return;
981
+ }
982
+ try {
983
+ if (page.hasOpener) {
984
+ window.opener.postMessage(
985
+ { type: "alarm-ultimate-zones", nodeId: page.alarmNodeId, zonesJson: json, zones: payload },
986
+ page.origin,
987
+ );
988
+ }
989
+ } catch (_err) {
990
+ // ignore
991
+ }
992
+ try {
993
+ if (bc) {
994
+ bc.postMessage({ type: "alarm-ultimate-zones", nodeId: page.alarmNodeId, zonesJson: json, zones: payload });
995
+ }
996
+ } catch (_err) {
997
+ // ignore
998
+ }
999
+ lastAutoSavedJson = json;
1000
+ setAutosaveHint(`Autosave: saved ${payload.length} zones.`);
1001
+ if (!quiet) {
1002
+ showStatus(els.genStatus, "ok", `Saved ${payload.length} zones to Alarm editor.`);
1003
+ }
1004
+ }
1005
+
1006
+ function requestZonesFromEditor() {
1007
+ if (!page.alarmNodeId) return;
1008
+ if (!canTalkToEditor()) {
1009
+ setAutosaveHint("Autosave unavailable: open this page from the Alarm node editor.");
1010
+ setConnectionState("disconnected");
1011
+ return;
1012
+ }
1013
+ setConnectionState("connecting");
1014
+ try {
1015
+ if (page.hasOpener) {
1016
+ window.opener.postMessage({ type: "alarm-ultimate-request-zones", nodeId: page.alarmNodeId }, page.origin);
1017
+ }
1018
+ } catch (_err) {
1019
+ // ignore
1020
+ }
1021
+ try {
1022
+ if (bc) {
1023
+ bc.postMessage({ type: "alarm-ultimate-request-zones", nodeId: page.alarmNodeId });
1024
+ }
1025
+ } catch (_err) {
1026
+ // ignore
1027
+ }
1028
+ setAutosaveHint("Autosave: loading zones from Alarm...");
1029
+ }
465
1030
 
466
1031
  function showStatus(el, kind, message) {
467
1032
  el.style.display = "";
@@ -874,11 +1439,10 @@
874
1439
  hideStatus(els.genStatus);
875
1440
  hideStatus(els.etsHint);
876
1441
  hideStatus(els.haHint);
1442
+ if (els.wizardStep2) els.wizardStep2.classList.add("disabled");
877
1443
  els.jsonMapping.style.display = "";
878
1444
  els.etsMapping.style.display = "none";
879
1445
  els.haMapping.style.display = "none";
880
- els.zoneOutput.value = "";
881
- els.btnCopyZone.disabled = true;
882
1446
  lastGenerated = null;
883
1447
 
884
1448
  const result = parseInput(els.input.value);
@@ -892,6 +1456,8 @@
892
1456
  return;
893
1457
  }
894
1458
 
1459
+ if (els.wizardStep2) els.wizardStep2.classList.remove("disabled");
1460
+
895
1461
  if (
896
1462
  result.value &&
897
1463
  result.value.__ets &&
@@ -988,6 +1554,7 @@
988
1554
  showStatus(els.genStatus, "err", "Parse a valid message first.");
989
1555
  return;
990
1556
  }
1557
+ const append = els.appendZones ? els.appendZones.checked === true : true;
991
1558
 
992
1559
  if (
993
1560
  parsedObject &&
@@ -1041,15 +1608,31 @@
1041
1608
  };
1042
1609
  });
1043
1610
 
1044
- els.zoneOutput.value = JSON.stringify(zones, null, 2);
1045
- els.btnCopyZone.disabled = zones.length === 0;
1611
+ if (append) {
1612
+ const start = zonesModel.length;
1613
+ zonesModel = zonesModel.concat(
1614
+ zones.map((z, idx) => {
1615
+ const cleaned = cleanZoneForJson(z, start + idx);
1616
+ cleaned.__idExplicit = false;
1617
+ return cleaned;
1618
+ }),
1619
+ );
1620
+ renderZones();
1621
+ } else {
1622
+ zonesModel = zones.map((z, idx) => {
1623
+ const cleaned = cleanZoneForJson(z, idx);
1624
+ cleaned.__idExplicit = false;
1625
+ return cleaned;
1626
+ });
1627
+ renderZones();
1628
+ }
1046
1629
  const skippedGroups = allRows.length - leafRows.length;
1047
1630
  const skippedNonBoolean = leafRows.length - booleanRows.length;
1048
1631
  const skippedByFilters = booleanRows.length - filteredRows.length;
1049
1632
  showStatus(
1050
1633
  els.genStatus,
1051
1634
  zones.length ? "ok" : "warn",
1052
- `Generated ${zones.length} zones from ETS (skipped ${skippedGroups} group rows, ${skippedNonBoolean} non-boolean datapoints, ${skippedByFilters} filtered out).`,
1635
+ `${append ? "Appended" : "Replaced with"} ${zones.length} zones from ETS (skipped ${skippedGroups} group rows, ${skippedNonBoolean} non-boolean datapoints, ${skippedByFilters} filtered out).`,
1053
1636
  );
1054
1637
  return;
1055
1638
  }
@@ -1089,8 +1672,24 @@
1089
1672
  };
1090
1673
  });
1091
1674
 
1092
- els.zoneOutput.value = JSON.stringify(zones, null, 2);
1093
- els.btnCopyZone.disabled = zones.length === 0;
1675
+ if (append) {
1676
+ const start = zonesModel.length;
1677
+ zonesModel = zonesModel.concat(
1678
+ zones.map((z, idx) => {
1679
+ const cleaned = cleanZoneForJson(z, start + idx);
1680
+ cleaned.__idExplicit = false;
1681
+ return cleaned;
1682
+ }),
1683
+ );
1684
+ renderZones();
1685
+ } else {
1686
+ zonesModel = zones.map((z, idx) => {
1687
+ const cleaned = cleanZoneForJson(z, idx);
1688
+ cleaned.__idExplicit = false;
1689
+ return cleaned;
1690
+ });
1691
+ renderZones();
1692
+ }
1094
1693
  const skippedNonEntity = states.length - eligible.length;
1095
1694
  const skippedDomain = eligible.length - domainFiltered.length;
1096
1695
  const skippedByFilters = domainFiltered.length - filtered.length;
@@ -1139,8 +1738,24 @@
1139
1738
  };
1140
1739
  });
1141
1740
 
1142
- els.zoneOutput.value = JSON.stringify(zones, null, 2);
1143
- els.btnCopyZone.disabled = zones.length === 0;
1741
+ if (append) {
1742
+ const start = zonesModel.length;
1743
+ zonesModel = zonesModel.concat(
1744
+ zones.map((z, idx) => {
1745
+ const cleaned = cleanZoneForJson(z, start + idx);
1746
+ cleaned.__idExplicit = false;
1747
+ return cleaned;
1748
+ }),
1749
+ );
1750
+ renderZones();
1751
+ } else {
1752
+ zonesModel = zones.map((z, idx) => {
1753
+ const cleaned = cleanZoneForJson(z, idx);
1754
+ cleaned.__idExplicit = false;
1755
+ return cleaned;
1756
+ });
1757
+ renderZones();
1758
+ }
1144
1759
  const skippedNonEntity = entities.length - eligible.length;
1145
1760
  const skippedDisabled = eligible.length - enabledOnly.length;
1146
1761
  const skippedDomain = enabledOnly.length - domainFiltered.length;
@@ -1175,16 +1790,21 @@
1175
1790
  topic: String(topicValue),
1176
1791
  payload: valueValue,
1177
1792
  };
1178
- const zone = buildZoneTemplate(normalized, nameValue);
1179
- els.zoneOutput.value = JSON.stringify(zone, null, 2);
1180
-
1181
- els.btnCopyZone.disabled = false;
1793
+ const zone = buildZoneTemplate(normalized, nameValue);
1794
+ if (append) {
1795
+ addZone(zone);
1796
+ } else {
1797
+ const cleaned = cleanZoneForJson(zone, 0);
1798
+ cleaned.__idExplicit = false;
1799
+ zonesModel = [cleaned];
1800
+ renderZones();
1801
+ }
1182
1802
 
1183
1803
  lastGenerated = { normalized, zone };
1184
1804
  showStatus(
1185
1805
  els.genStatus,
1186
1806
  "ok",
1187
- `Generated using topic="${toOneLine(topicValue)}" and value="${valuePath}".`,
1807
+ `${append ? "Appended" : "Replaced with"} 1 zone using topic="${toOneLine(topicValue)}" and value="${valuePath}".`,
1188
1808
  );
1189
1809
  }
1190
1810
 
@@ -1198,6 +1818,7 @@
1198
1818
  hideStatus(els.genStatus);
1199
1819
  hideStatus(els.etsHint);
1200
1820
  hideStatus(els.haHint);
1821
+ if (els.wizardStep2) els.wizardStep2.classList.add("disabled");
1201
1822
  els.jsonMapping.style.display = "";
1202
1823
  els.etsMapping.style.display = "none";
1203
1824
  if (els.etsAddressFilter) els.etsAddressFilter.value = "";
@@ -1207,8 +1828,6 @@
1207
1828
  if (els.haNameFilter) els.haNameFilter.value = "";
1208
1829
  if (els.haDomains) els.haDomains.value = "";
1209
1830
  if (els.haIncludeDisabled) els.haIncludeDisabled.checked = false;
1210
- els.zoneOutput.value = "";
1211
- els.btnCopyZone.disabled = true;
1212
1831
  els.topicPath.innerHTML = "";
1213
1832
  els.valuePath.innerHTML = "";
1214
1833
  els.namePath.innerHTML = "";
@@ -1216,33 +1835,202 @@
1216
1835
  els.valuePath.disabled = false;
1217
1836
  els.namePath.disabled = false;
1218
1837
  });
1219
- els.btnLoadKnx.addEventListener("click", () => {
1220
- els.input.value = KNX_SAMPLE;
1221
- parseAndPopulate();
1222
- });
1223
- els.btnLoadEts.addEventListener("click", () => {
1224
- els.input.value = ETS_SAMPLE;
1225
- parseAndPopulate();
1226
- });
1227
- els.btnLoadHa.addEventListener("click", () => {
1228
- els.input.value = HA_SAMPLE;
1838
+
1839
+ function loadSample(kind) {
1840
+ const k = String(kind || "").trim();
1841
+ if (k === "knx") els.input.value = KNX_SAMPLE;
1842
+ else if (k === "ets") els.input.value = ETS_SAMPLE;
1843
+ else if (k === "ha") els.input.value = HA_SAMPLE;
1844
+ else if (k === "ha_registry") els.input.value = HA_REGISTRY_SAMPLE;
1845
+ else return;
1229
1846
  parseAndPopulate();
1847
+ }
1848
+
1849
+ els.btnLoadSample.addEventListener("click", () => loadSample(els.sampleKind ? els.sampleKind.value : ""));
1850
+
1851
+ els.btnZoneAdd.addEventListener("click", () => addZone(null));
1852
+
1853
+ function toggleWizard(forceOpen) {
1854
+ if (!els.wizard) return;
1855
+ const isOpen = els.wizard.style.display !== "none";
1856
+ const nextOpen = forceOpen === true ? true : forceOpen === false ? false : !isOpen;
1857
+ els.wizard.style.display = nextOpen ? "" : "none";
1858
+ if (els.btnWizardToggle) {
1859
+ els.btnWizardToggle.textContent = nextOpen ? "Hide import wizard" : "Import zones wizard";
1860
+ }
1861
+ if (nextOpen) {
1862
+ setTimeout(() => {
1863
+ try {
1864
+ els.wizard.scrollIntoView({ behavior: "smooth", block: "start" });
1865
+ if (els.input) els.input.focus();
1866
+ } catch (_err) {
1867
+ // ignore
1868
+ }
1869
+ }, 0);
1870
+ }
1871
+ }
1872
+
1873
+ if (els.btnWizardToggle) {
1874
+ els.btnWizardToggle.addEventListener("click", () => toggleWizard());
1875
+ }
1876
+
1877
+ // Use input for text fields, change for selects.
1878
+ els.zoneTableBody.addEventListener("input", (evt) => {
1879
+ const target = evt.target;
1880
+ if (!target) return;
1881
+ const tr = target.closest("tr");
1882
+ if (!tr) return;
1883
+ const index = Number(tr.dataset.index);
1884
+ if (!Number.isInteger(index) || index < 0 || index >= zonesModel.length) return;
1885
+ const zone = zonesModel[index];
1886
+
1887
+ const field = target.dataset.field;
1888
+ if (field === "name") {
1889
+ zone.name = String(target.value || "");
1890
+ } else if (field === "topicValue") {
1891
+ const modeEl = tr.querySelector('select[data-field="match"]');
1892
+ const mode = modeEl ? String(modeEl.value || "topic") : "topic";
1893
+ if (mode === "topicPattern") {
1894
+ zone.topicPattern = String(target.value || "");
1895
+ delete zone.topic;
1896
+ } else {
1897
+ zone.topic = String(target.value || "");
1898
+ delete zone.topicPattern;
1899
+ }
1900
+ } else if (field === "kind") {
1901
+ applyZoneKind(zone, target.value);
1902
+ }
1903
+
1904
+ refreshZonesMeta();
1230
1905
  });
1231
- els.btnLoadHaRegistry.addEventListener("click", () => {
1232
- els.input.value = HA_REGISTRY_SAMPLE;
1233
- parseAndPopulate();
1906
+
1907
+ els.zoneTableBody.addEventListener("change", (evt) => {
1908
+ const target = evt.target;
1909
+ if (!target) return;
1910
+ const tr = target.closest("tr");
1911
+ if (!tr) return;
1912
+ const index = Number(tr.dataset.index);
1913
+ if (!Number.isInteger(index) || index < 0 || index >= zonesModel.length) return;
1914
+ const zone = zonesModel[index];
1915
+ const field = target.dataset.field;
1916
+
1917
+ if (field === "match") {
1918
+ const mode = String(target.value || "topic");
1919
+ const valueEl = tr.querySelector('input[data-field="topicValue"]');
1920
+ const rawValue = valueEl ? String(valueEl.value || "") : "";
1921
+ if (mode === "topicPattern") {
1922
+ zone.topicPattern = rawValue;
1923
+ delete zone.topic;
1924
+ } else {
1925
+ zone.topic = rawValue;
1926
+ delete zone.topicPattern;
1927
+ }
1928
+ refreshZonesMeta();
1929
+ }
1930
+
1931
+ if (field === "kind") {
1932
+ applyZoneKind(zone, target.value);
1933
+ refreshZonesMeta();
1934
+ }
1234
1935
  });
1235
1936
 
1236
- els.btnCopyZone.addEventListener("click", async () => {
1237
- const text = String(els.zoneOutput.value || "").trim();
1238
- if (!text) return;
1239
- const ok = await copyToClipboard(text);
1240
- showStatus(
1241
- els.genStatus,
1242
- ok ? "ok" : "warn",
1243
- ok ? "Zone JSON copied." : "Copy failed.",
1244
- );
1937
+ // Track editing state to avoid autosave while typing.
1938
+ els.zoneTableBody.addEventListener("focusin", () => {
1939
+ isEditingZoneTable = true;
1940
+ if (autoSaveTimer) window.clearTimeout(autoSaveTimer);
1941
+ autoSaveTimer = null;
1942
+ setAutosaveHint("Autosave paused while editing zones.");
1943
+ });
1944
+
1945
+ els.zoneTableBody.addEventListener("focusout", () => {
1946
+ setTimeout(() => {
1947
+ const active = document.activeElement;
1948
+ const stillInside =
1949
+ active && els.zoneTableBody && els.zoneTableBody.contains(active);
1950
+ if (stillInside) return;
1951
+ isEditingZoneTable = false;
1952
+ refreshZonesMeta();
1953
+ // Flush as soon as the user leaves the zone table, to reduce lost changes.
1954
+ flushZonesToEditor(true);
1955
+ }, 0);
1956
+ });
1957
+
1958
+ els.zoneTableBody.addEventListener("click", (evt) => {
1959
+ const target = evt.target;
1960
+ if (!target) return;
1961
+ if (target.dataset.action !== "delete") return;
1962
+ const tr = target.closest("tr");
1963
+ if (!tr) return;
1964
+ const index = Number(tr.dataset.index);
1965
+ if (!Number.isInteger(index) || index < 0 || index >= zonesModel.length) return;
1966
+ zonesModel.splice(index, 1);
1967
+ renderZones();
1245
1968
  });
1969
+
1970
+ function renderZoneContext(nodeName) {
1971
+ const name = String(nodeName || "").trim() || page.alarmNodeName;
1972
+ if (page.alarmNodeId) {
1973
+ const label = name ? `${name}` : "(unnamed Alarm)";
1974
+ els.zoneContext.innerHTML = `Target Alarm: <span class="pill">${label}</span> • synced automatically (click Done in Node-RED editor to apply)`;
1975
+ return;
1976
+ }
1977
+ els.zoneContext.textContent =
1978
+ "Tip: open this page from the Alarm node editor to load/save zones automatically.";
1979
+ }
1980
+ renderZoneContext();
1981
+
1982
+ if (canTalkToEditor()) {
1983
+ requestZonesFromEditor();
1984
+ }
1985
+
1986
+ function handleEditorZonesMessage(data) {
1987
+ if (!data || data.type !== "alarm-ultimate-zones") return;
1988
+ if (page.alarmNodeId && data.nodeId !== page.alarmNodeId) return;
1989
+ if (typeof data.zonesJson !== "string") return;
1990
+ try {
1991
+ editorConnected = true;
1992
+ setConnectionState("connected");
1993
+ if (isEditingZoneTable) {
1994
+ // Avoid clobbering the user's focus/typing due to background sync.
1995
+ return;
1996
+ }
1997
+ setZonesFromJson(data.zonesJson);
1998
+ // Align lastAutosaved with the normalized JSON to avoid immediate re-save.
1999
+ lastAutoSavedJson = zonesJsonText;
2000
+ if (typeof data.nodeName === "string") {
2001
+ renderZoneContext(data.nodeName);
2002
+ }
2003
+ showStatus(els.genStatus, "ok", `Loaded ${zonesModel.length} zones from Alarm editor.`);
2004
+ setAutosaveHint(`Autosave: loaded ${zonesModel.length} zones.`);
2005
+ } catch (err) {
2006
+ showStatus(els.genStatus, "err", `Load failed: ${String(err && err.message ? err.message : err)}`);
2007
+ setAutosaveHint("Autosave: load failed.");
2008
+ }
2009
+ }
2010
+
2011
+ window.addEventListener("message", (evt) => {
2012
+ if (evt.origin !== page.origin) return;
2013
+ const data = evt.data && typeof evt.data === "object" ? evt.data : null;
2014
+ handleEditorZonesMessage(data);
2015
+ });
2016
+
2017
+ // Best-effort flush on close/navigation away, so edits are not lost if the user closes quickly.
2018
+ window.addEventListener("beforeunload", () => flushZonesToEditor(true));
2019
+ window.addEventListener("pagehide", () => flushZonesToEditor(true));
2020
+ document.addEventListener("visibilitychange", () => {
2021
+ if (document.hidden) flushZonesToEditor(true);
2022
+ });
2023
+
2024
+ if (bc) {
2025
+ bc.addEventListener("message", (evt) => {
2026
+ const data = evt && evt.data && typeof evt.data === "object" ? evt.data : null;
2027
+ handleEditorZonesMessage(data);
2028
+ });
2029
+ }
2030
+
2031
+ // Initial render.
2032
+ renderZones();
2033
+ setConnectionState(canTalkToEditor() ? "connecting" : "disconnected");
1246
2034
  </script>
1247
2035
  </body>
1248
2036