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.
@@ -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
- />
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>
279
388
  </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
- />
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>
287
423
  </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
- />
295
- </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>
310
- </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>
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>
318
450
  </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,448 @@
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
+ }, 250);
800
+ }
801
+
802
+ function validateModel() {
803
+ const errors = [];
804
+ zonesModel.forEach((zone, idx) => {
805
+ zone.__errors = [];
806
+ const topic = String(zone.topic || "").trim();
807
+ const topicPattern = String(zone.topicPattern || "").trim();
808
+
809
+ if (!topic && !topicPattern) zone.__errors.push("Missing topic/pattern.");
810
+ if (topic && topicPattern) zone.__errors.push("Choose topic OR pattern (not both).");
811
+ });
812
+
813
+ // Detect duplicates for explicit ids only.
814
+ const seenExplicit = new Map();
815
+ zonesModel.forEach((z, idx) => {
816
+ if (!(z && typeof z === "object" && z.__idExplicit === true)) return;
817
+ const id = typeof z.id === "string" ? z.id.trim() : "";
818
+ if (!id) return;
819
+ const key = id.toLowerCase();
820
+ if (!seenExplicit.has(key)) seenExplicit.set(key, []);
821
+ seenExplicit.get(key).push(idx);
822
+ });
823
+ for (const [key, indexes] of seenExplicit.entries()) {
824
+ if (indexes.length <= 1) continue;
825
+ indexes.forEach((idx) => {
826
+ zonesModel[idx].__errors.push(`Duplicate id (from JSON): ${key}`);
827
+ });
828
+ }
829
+
830
+ zonesModel.forEach((z) => {
831
+ if (z.__errors && z.__errors.length) errors.push(...z.__errors);
832
+ });
833
+ return errors;
834
+ }
835
+
836
+ function refreshZonesMeta() {
837
+ const errors = validateModel();
838
+ syncJsonFromModel();
839
+ if (errors.length === 0) {
840
+ hideStatus(els.zoneListStatus);
841
+ } else {
842
+ showStatus(
843
+ els.zoneListStatus,
844
+ "warn",
845
+ `Zones: ${errors[0]}${errors.length > 1 ? ` (+${errors.length - 1} more)` : ""}`,
846
+ );
847
+ }
848
+ scheduleAutosave(errors);
849
+ }
850
+
851
+ function renderZones() {
852
+ els.zoneTableBody.innerHTML = "";
853
+
854
+ zonesModel.forEach((zone, index) => {
855
+ const tr = document.createElement("tr");
856
+ tr.dataset.index = String(index);
857
+
858
+ const tdValue = document.createElement("td");
859
+ const valueInput = document.createElement("input");
860
+ valueInput.type = "text";
861
+ valueInput.value = String(zone.topicPattern || zone.topic || "");
862
+ valueInput.placeholder = "sensor/frontdoor or ^sensor/.*_door$";
863
+ valueInput.dataset.field = "topicValue";
864
+ tdValue.appendChild(valueInput);
865
+
866
+ const tdMatch = document.createElement("td");
867
+ const matchSelect = document.createElement("select");
868
+ matchSelect.dataset.field = "match";
869
+ const optTopic = document.createElement("option");
870
+ optTopic.value = "topic";
871
+ optTopic.textContent = "Topic";
872
+ const optPattern = document.createElement("option");
873
+ optPattern.value = "topicPattern";
874
+ optPattern.textContent = "Regex";
875
+ matchSelect.appendChild(optTopic);
876
+ matchSelect.appendChild(optPattern);
877
+ matchSelect.value = zone.topicPattern ? "topicPattern" : "topic";
878
+ tdMatch.appendChild(matchSelect);
879
+
880
+ const tdName = document.createElement("td");
881
+ const nameInput = document.createElement("input");
882
+ nameInput.type = "text";
883
+ nameInput.value = String(zone.name || "");
884
+ nameInput.placeholder = "Front door";
885
+ nameInput.dataset.field = "name";
886
+ tdName.appendChild(nameInput);
887
+
888
+ const tdKind = document.createElement("td");
889
+ const kindSelect = document.createElement("select");
890
+ kindSelect.dataset.field = "kind";
891
+ [
892
+ ["perimeter", "Perimeter"],
893
+ ["entry", "Entry"],
894
+ ["24h", "24h"],
895
+ ["tamper", "Tamper"],
896
+ ["fire", "Fire"],
897
+ ].forEach(([value, label]) => {
898
+ const opt = document.createElement("option");
899
+ opt.value = value;
900
+ opt.textContent = label;
901
+ kindSelect.appendChild(opt);
902
+ });
903
+ kindSelect.value = zoneKind(zone);
904
+ tdKind.appendChild(kindSelect);
905
+
906
+ const tdActions = document.createElement("td");
907
+ const delBtn = document.createElement("button");
908
+ delBtn.type = "button";
909
+ delBtn.textContent = "Delete";
910
+ delBtn.className = "btn-danger";
911
+ delBtn.dataset.action = "delete";
912
+ tdActions.appendChild(delBtn);
913
+
914
+ tr.appendChild(tdValue);
915
+ tr.appendChild(tdMatch);
916
+ tr.appendChild(tdName);
917
+ tr.appendChild(tdKind);
918
+ tr.appendChild(tdActions);
919
+
920
+ els.zoneTableBody.appendChild(tr);
921
+ });
922
+
923
+ refreshZonesMeta();
924
+ }
925
+
926
+ function setZonesFromJson(text) {
927
+ const zones = normalizeZonesJson(text);
928
+ zonesModel = zones.map((z, idx) => {
929
+ const hasExplicitId = Boolean(z && typeof z === "object" && typeof z.id === "string" && z.id.trim().length > 0);
930
+ const cleaned = cleanZoneForJson(z, idx);
931
+ cleaned.__idExplicit = hasExplicitId;
932
+ return cleaned;
933
+ });
934
+ renderZones();
935
+ }
936
+
937
+ function addZone(zone) {
938
+ const next = zone && typeof zone === "object" ? cleanZoneForJson(zone, zonesModel.length) : buildDefaultZone(zonesModel.length);
939
+ // Let the id be generated automatically (or preserved if present).
940
+ if (next && typeof next === "object" && typeof next.id === "string") {
941
+ if (next.id.startsWith("zone_")) next.id = "";
942
+ }
943
+ if (next && typeof next === "object") next.__idExplicit = false;
944
+ zonesModel.push(next);
945
+ renderZones();
946
+ // Focus new row topic field.
947
+ try {
948
+ const rows = els.zoneTableBody.querySelectorAll("tr");
949
+ const last = rows[rows.length - 1];
950
+ const input = last ? last.querySelector('input[data-field="topicValue"]') : null;
951
+ if (input) input.focus();
952
+ } catch (_err) {}
953
+ }
954
+
955
+ function sendZonesToEditor(quiet) {
956
+ if (!page.alarmNodeId) return;
957
+ if (!canTalkToEditor()) {
958
+ setAutosaveHint("Autosave unavailable: open this page from the Alarm node editor.");
959
+ setConnectionState("disconnected");
960
+ return;
961
+ }
962
+ if (!isConnectedToEditor()) {
963
+ setConnectionState("connecting");
964
+ }
965
+
966
+ const payload = syncJsonFromModel();
967
+ const json = zonesJsonText;
968
+ if (lastAutoSavedJson === json) {
969
+ setAutosaveHint(`Autosave: saved ${payload.length} zones.`);
970
+ return;
971
+ }
972
+ try {
973
+ if (page.hasOpener) {
974
+ window.opener.postMessage(
975
+ { type: "alarm-ultimate-zones", nodeId: page.alarmNodeId, zonesJson: json, zones: payload },
976
+ page.origin,
977
+ );
978
+ }
979
+ } catch (_err) {
980
+ // ignore
981
+ }
982
+ try {
983
+ if (bc) {
984
+ bc.postMessage({ type: "alarm-ultimate-zones", nodeId: page.alarmNodeId, zonesJson: json, zones: payload });
985
+ }
986
+ } catch (_err) {
987
+ // ignore
988
+ }
989
+ lastAutoSavedJson = json;
990
+ setAutosaveHint(`Autosave: saved ${payload.length} zones.`);
991
+ if (!quiet) {
992
+ showStatus(els.genStatus, "ok", `Saved ${payload.length} zones to Alarm editor.`);
993
+ }
994
+ }
995
+
996
+ function requestZonesFromEditor() {
997
+ if (!page.alarmNodeId) return;
998
+ if (!canTalkToEditor()) {
999
+ setAutosaveHint("Autosave unavailable: open this page from the Alarm node editor.");
1000
+ setConnectionState("disconnected");
1001
+ return;
1002
+ }
1003
+ setConnectionState("connecting");
1004
+ try {
1005
+ if (page.hasOpener) {
1006
+ window.opener.postMessage({ type: "alarm-ultimate-request-zones", nodeId: page.alarmNodeId }, page.origin);
1007
+ }
1008
+ } catch (_err) {
1009
+ // ignore
1010
+ }
1011
+ try {
1012
+ if (bc) {
1013
+ bc.postMessage({ type: "alarm-ultimate-request-zones", nodeId: page.alarmNodeId });
1014
+ }
1015
+ } catch (_err) {
1016
+ // ignore
1017
+ }
1018
+ setAutosaveHint("Autosave: loading zones from Alarm...");
1019
+ }
465
1020
 
466
1021
  function showStatus(el, kind, message) {
467
1022
  el.style.display = "";
@@ -874,11 +1429,10 @@
874
1429
  hideStatus(els.genStatus);
875
1430
  hideStatus(els.etsHint);
876
1431
  hideStatus(els.haHint);
1432
+ if (els.wizardStep2) els.wizardStep2.classList.add("disabled");
877
1433
  els.jsonMapping.style.display = "";
878
1434
  els.etsMapping.style.display = "none";
879
1435
  els.haMapping.style.display = "none";
880
- els.zoneOutput.value = "";
881
- els.btnCopyZone.disabled = true;
882
1436
  lastGenerated = null;
883
1437
 
884
1438
  const result = parseInput(els.input.value);
@@ -892,6 +1446,8 @@
892
1446
  return;
893
1447
  }
894
1448
 
1449
+ if (els.wizardStep2) els.wizardStep2.classList.remove("disabled");
1450
+
895
1451
  if (
896
1452
  result.value &&
897
1453
  result.value.__ets &&
@@ -988,6 +1544,7 @@
988
1544
  showStatus(els.genStatus, "err", "Parse a valid message first.");
989
1545
  return;
990
1546
  }
1547
+ const append = els.appendZones ? els.appendZones.checked === true : true;
991
1548
 
992
1549
  if (
993
1550
  parsedObject &&
@@ -1041,15 +1598,31 @@
1041
1598
  };
1042
1599
  });
1043
1600
 
1044
- els.zoneOutput.value = JSON.stringify(zones, null, 2);
1045
- els.btnCopyZone.disabled = zones.length === 0;
1601
+ if (append) {
1602
+ const start = zonesModel.length;
1603
+ zonesModel = zonesModel.concat(
1604
+ zones.map((z, idx) => {
1605
+ const cleaned = cleanZoneForJson(z, start + idx);
1606
+ cleaned.__idExplicit = false;
1607
+ return cleaned;
1608
+ }),
1609
+ );
1610
+ renderZones();
1611
+ } else {
1612
+ zonesModel = zones.map((z, idx) => {
1613
+ const cleaned = cleanZoneForJson(z, idx);
1614
+ cleaned.__idExplicit = false;
1615
+ return cleaned;
1616
+ });
1617
+ renderZones();
1618
+ }
1046
1619
  const skippedGroups = allRows.length - leafRows.length;
1047
1620
  const skippedNonBoolean = leafRows.length - booleanRows.length;
1048
1621
  const skippedByFilters = booleanRows.length - filteredRows.length;
1049
1622
  showStatus(
1050
1623
  els.genStatus,
1051
1624
  zones.length ? "ok" : "warn",
1052
- `Generated ${zones.length} zones from ETS (skipped ${skippedGroups} group rows, ${skippedNonBoolean} non-boolean datapoints, ${skippedByFilters} filtered out).`,
1625
+ `${append ? "Appended" : "Replaced with"} ${zones.length} zones from ETS (skipped ${skippedGroups} group rows, ${skippedNonBoolean} non-boolean datapoints, ${skippedByFilters} filtered out).`,
1053
1626
  );
1054
1627
  return;
1055
1628
  }
@@ -1089,8 +1662,24 @@
1089
1662
  };
1090
1663
  });
1091
1664
 
1092
- els.zoneOutput.value = JSON.stringify(zones, null, 2);
1093
- els.btnCopyZone.disabled = zones.length === 0;
1665
+ if (append) {
1666
+ const start = zonesModel.length;
1667
+ zonesModel = zonesModel.concat(
1668
+ zones.map((z, idx) => {
1669
+ const cleaned = cleanZoneForJson(z, start + idx);
1670
+ cleaned.__idExplicit = false;
1671
+ return cleaned;
1672
+ }),
1673
+ );
1674
+ renderZones();
1675
+ } else {
1676
+ zonesModel = zones.map((z, idx) => {
1677
+ const cleaned = cleanZoneForJson(z, idx);
1678
+ cleaned.__idExplicit = false;
1679
+ return cleaned;
1680
+ });
1681
+ renderZones();
1682
+ }
1094
1683
  const skippedNonEntity = states.length - eligible.length;
1095
1684
  const skippedDomain = eligible.length - domainFiltered.length;
1096
1685
  const skippedByFilters = domainFiltered.length - filtered.length;
@@ -1139,8 +1728,24 @@
1139
1728
  };
1140
1729
  });
1141
1730
 
1142
- els.zoneOutput.value = JSON.stringify(zones, null, 2);
1143
- els.btnCopyZone.disabled = zones.length === 0;
1731
+ if (append) {
1732
+ const start = zonesModel.length;
1733
+ zonesModel = zonesModel.concat(
1734
+ zones.map((z, idx) => {
1735
+ const cleaned = cleanZoneForJson(z, start + idx);
1736
+ cleaned.__idExplicit = false;
1737
+ return cleaned;
1738
+ }),
1739
+ );
1740
+ renderZones();
1741
+ } else {
1742
+ zonesModel = zones.map((z, idx) => {
1743
+ const cleaned = cleanZoneForJson(z, idx);
1744
+ cleaned.__idExplicit = false;
1745
+ return cleaned;
1746
+ });
1747
+ renderZones();
1748
+ }
1144
1749
  const skippedNonEntity = entities.length - eligible.length;
1145
1750
  const skippedDisabled = eligible.length - enabledOnly.length;
1146
1751
  const skippedDomain = enabledOnly.length - domainFiltered.length;
@@ -1175,16 +1780,21 @@
1175
1780
  topic: String(topicValue),
1176
1781
  payload: valueValue,
1177
1782
  };
1178
- const zone = buildZoneTemplate(normalized, nameValue);
1179
- els.zoneOutput.value = JSON.stringify(zone, null, 2);
1180
-
1181
- els.btnCopyZone.disabled = false;
1783
+ const zone = buildZoneTemplate(normalized, nameValue);
1784
+ if (append) {
1785
+ addZone(zone);
1786
+ } else {
1787
+ const cleaned = cleanZoneForJson(zone, 0);
1788
+ cleaned.__idExplicit = false;
1789
+ zonesModel = [cleaned];
1790
+ renderZones();
1791
+ }
1182
1792
 
1183
1793
  lastGenerated = { normalized, zone };
1184
1794
  showStatus(
1185
1795
  els.genStatus,
1186
1796
  "ok",
1187
- `Generated using topic="${toOneLine(topicValue)}" and value="${valuePath}".`,
1797
+ `${append ? "Appended" : "Replaced with"} 1 zone using topic="${toOneLine(topicValue)}" and value="${valuePath}".`,
1188
1798
  );
1189
1799
  }
1190
1800
 
@@ -1198,6 +1808,7 @@
1198
1808
  hideStatus(els.genStatus);
1199
1809
  hideStatus(els.etsHint);
1200
1810
  hideStatus(els.haHint);
1811
+ if (els.wizardStep2) els.wizardStep2.classList.add("disabled");
1201
1812
  els.jsonMapping.style.display = "";
1202
1813
  els.etsMapping.style.display = "none";
1203
1814
  if (els.etsAddressFilter) els.etsAddressFilter.value = "";
@@ -1207,8 +1818,6 @@
1207
1818
  if (els.haNameFilter) els.haNameFilter.value = "";
1208
1819
  if (els.haDomains) els.haDomains.value = "";
1209
1820
  if (els.haIncludeDisabled) els.haIncludeDisabled.checked = false;
1210
- els.zoneOutput.value = "";
1211
- els.btnCopyZone.disabled = true;
1212
1821
  els.topicPath.innerHTML = "";
1213
1822
  els.valuePath.innerHTML = "";
1214
1823
  els.namePath.innerHTML = "";
@@ -1216,33 +1825,193 @@
1216
1825
  els.valuePath.disabled = false;
1217
1826
  els.namePath.disabled = false;
1218
1827
  });
1219
- els.btnLoadKnx.addEventListener("click", () => {
1220
- els.input.value = KNX_SAMPLE;
1828
+
1829
+ function loadSample(kind) {
1830
+ const k = String(kind || "").trim();
1831
+ if (k === "knx") els.input.value = KNX_SAMPLE;
1832
+ else if (k === "ets") els.input.value = ETS_SAMPLE;
1833
+ else if (k === "ha") els.input.value = HA_SAMPLE;
1834
+ else if (k === "ha_registry") els.input.value = HA_REGISTRY_SAMPLE;
1835
+ else return;
1221
1836
  parseAndPopulate();
1837
+ }
1838
+
1839
+ els.btnLoadSample.addEventListener("click", () => loadSample(els.sampleKind ? els.sampleKind.value : ""));
1840
+
1841
+ els.btnZoneAdd.addEventListener("click", () => addZone(null));
1842
+
1843
+ function toggleWizard(forceOpen) {
1844
+ if (!els.wizard) return;
1845
+ const isOpen = els.wizard.style.display !== "none";
1846
+ const nextOpen = forceOpen === true ? true : forceOpen === false ? false : !isOpen;
1847
+ els.wizard.style.display = nextOpen ? "" : "none";
1848
+ if (els.btnWizardToggle) {
1849
+ els.btnWizardToggle.textContent = nextOpen ? "Hide import wizard" : "Import zones wizard";
1850
+ }
1851
+ if (nextOpen) {
1852
+ setTimeout(() => {
1853
+ try {
1854
+ els.wizard.scrollIntoView({ behavior: "smooth", block: "start" });
1855
+ if (els.input) els.input.focus();
1856
+ } catch (_err) {
1857
+ // ignore
1858
+ }
1859
+ }, 0);
1860
+ }
1861
+ }
1862
+
1863
+ if (els.btnWizardToggle) {
1864
+ els.btnWizardToggle.addEventListener("click", () => toggleWizard());
1865
+ }
1866
+
1867
+ // Use input for text fields, change for selects.
1868
+ els.zoneTableBody.addEventListener("input", (evt) => {
1869
+ const target = evt.target;
1870
+ if (!target) return;
1871
+ const tr = target.closest("tr");
1872
+ if (!tr) return;
1873
+ const index = Number(tr.dataset.index);
1874
+ if (!Number.isInteger(index) || index < 0 || index >= zonesModel.length) return;
1875
+ const zone = zonesModel[index];
1876
+
1877
+ const field = target.dataset.field;
1878
+ if (field === "name") {
1879
+ zone.name = String(target.value || "");
1880
+ } else if (field === "topicValue") {
1881
+ const modeEl = tr.querySelector('select[data-field="match"]');
1882
+ const mode = modeEl ? String(modeEl.value || "topic") : "topic";
1883
+ if (mode === "topicPattern") {
1884
+ zone.topicPattern = String(target.value || "");
1885
+ delete zone.topic;
1886
+ } else {
1887
+ zone.topic = String(target.value || "");
1888
+ delete zone.topicPattern;
1889
+ }
1890
+ } else if (field === "kind") {
1891
+ applyZoneKind(zone, target.value);
1892
+ }
1893
+
1894
+ refreshZonesMeta();
1222
1895
  });
1223
- els.btnLoadEts.addEventListener("click", () => {
1224
- els.input.value = ETS_SAMPLE;
1225
- parseAndPopulate();
1896
+
1897
+ els.zoneTableBody.addEventListener("change", (evt) => {
1898
+ const target = evt.target;
1899
+ if (!target) return;
1900
+ const tr = target.closest("tr");
1901
+ if (!tr) return;
1902
+ const index = Number(tr.dataset.index);
1903
+ if (!Number.isInteger(index) || index < 0 || index >= zonesModel.length) return;
1904
+ const zone = zonesModel[index];
1905
+ const field = target.dataset.field;
1906
+
1907
+ if (field === "match") {
1908
+ const mode = String(target.value || "topic");
1909
+ const valueEl = tr.querySelector('input[data-field="topicValue"]');
1910
+ const rawValue = valueEl ? String(valueEl.value || "") : "";
1911
+ if (mode === "topicPattern") {
1912
+ zone.topicPattern = rawValue;
1913
+ delete zone.topic;
1914
+ } else {
1915
+ zone.topic = rawValue;
1916
+ delete zone.topicPattern;
1917
+ }
1918
+ refreshZonesMeta();
1919
+ }
1920
+
1921
+ if (field === "kind") {
1922
+ applyZoneKind(zone, target.value);
1923
+ refreshZonesMeta();
1924
+ }
1226
1925
  });
1227
- els.btnLoadHa.addEventListener("click", () => {
1228
- els.input.value = HA_SAMPLE;
1229
- parseAndPopulate();
1926
+
1927
+ // Track editing state to avoid autosave while typing.
1928
+ els.zoneTableBody.addEventListener("focusin", () => {
1929
+ isEditingZoneTable = true;
1930
+ if (autoSaveTimer) window.clearTimeout(autoSaveTimer);
1931
+ autoSaveTimer = null;
1932
+ setAutosaveHint("Autosave paused while editing zones.");
1230
1933
  });
1231
- els.btnLoadHaRegistry.addEventListener("click", () => {
1232
- els.input.value = HA_REGISTRY_SAMPLE;
1233
- parseAndPopulate();
1934
+
1935
+ els.zoneTableBody.addEventListener("focusout", () => {
1936
+ setTimeout(() => {
1937
+ const active = document.activeElement;
1938
+ const stillInside =
1939
+ active && els.zoneTableBody && els.zoneTableBody.contains(active);
1940
+ if (stillInside) return;
1941
+ isEditingZoneTable = false;
1942
+ refreshZonesMeta();
1943
+ }, 0);
1234
1944
  });
1235
1945
 
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
- );
1946
+ els.zoneTableBody.addEventListener("click", (evt) => {
1947
+ const target = evt.target;
1948
+ if (!target) return;
1949
+ if (target.dataset.action !== "delete") return;
1950
+ const tr = target.closest("tr");
1951
+ if (!tr) return;
1952
+ const index = Number(tr.dataset.index);
1953
+ if (!Number.isInteger(index) || index < 0 || index >= zonesModel.length) return;
1954
+ zonesModel.splice(index, 1);
1955
+ renderZones();
1245
1956
  });
1957
+
1958
+ function renderZoneContext(nodeName) {
1959
+ const name = String(nodeName || "").trim() || page.alarmNodeName;
1960
+ if (page.alarmNodeId) {
1961
+ const label = name ? `${name}` : "(unnamed Alarm)";
1962
+ els.zoneContext.innerHTML = `Target Alarm: <span class="pill">${label}</span> • synced automatically (click Done in Node-RED editor to apply)`;
1963
+ return;
1964
+ }
1965
+ els.zoneContext.textContent =
1966
+ "Tip: open this page from the Alarm node editor to load/save zones automatically.";
1967
+ }
1968
+ renderZoneContext();
1969
+
1970
+ if (canTalkToEditor()) {
1971
+ requestZonesFromEditor();
1972
+ }
1973
+
1974
+ function handleEditorZonesMessage(data) {
1975
+ if (!data || data.type !== "alarm-ultimate-zones") return;
1976
+ if (page.alarmNodeId && data.nodeId !== page.alarmNodeId) return;
1977
+ if (typeof data.zonesJson !== "string") return;
1978
+ try {
1979
+ editorConnected = true;
1980
+ setConnectionState("connected");
1981
+ if (isEditingZoneTable) {
1982
+ // Avoid clobbering the user's focus/typing due to background sync.
1983
+ return;
1984
+ }
1985
+ setZonesFromJson(data.zonesJson);
1986
+ // Align lastAutosaved with the normalized JSON to avoid immediate re-save.
1987
+ lastAutoSavedJson = zonesJsonText;
1988
+ if (typeof data.nodeName === "string") {
1989
+ renderZoneContext(data.nodeName);
1990
+ }
1991
+ showStatus(els.genStatus, "ok", `Loaded ${zonesModel.length} zones from Alarm editor.`);
1992
+ setAutosaveHint(`Autosave: loaded ${zonesModel.length} zones.`);
1993
+ } catch (err) {
1994
+ showStatus(els.genStatus, "err", `Load failed: ${String(err && err.message ? err.message : err)}`);
1995
+ setAutosaveHint("Autosave: load failed.");
1996
+ }
1997
+ }
1998
+
1999
+ window.addEventListener("message", (evt) => {
2000
+ if (evt.origin !== page.origin) return;
2001
+ const data = evt.data && typeof evt.data === "object" ? evt.data : null;
2002
+ handleEditorZonesMessage(data);
2003
+ });
2004
+
2005
+ if (bc) {
2006
+ bc.addEventListener("message", (evt) => {
2007
+ const data = evt && evt.data && typeof evt.data === "object" ? evt.data : null;
2008
+ handleEditorZonesMessage(data);
2009
+ });
2010
+ }
2011
+
2012
+ // Initial render.
2013
+ renderZones();
2014
+ setConnectionState(canTalkToEditor() ? "connecting" : "disconnected");
1246
2015
  </script>
1247
2016
  </body>
1248
2017