node-red-contrib-alarm-ultimate 0.1.1 → 0.1.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +14 -0
- package/examples/README.md +93 -3
- package/examples/alarm-ultimate-basic.json +0 -1
- package/examples/alarm-ultimate-dashboard-controls.json +34 -4
- package/examples/alarm-ultimate-dashboard-v2.json +834 -0
- package/examples/alarm-ultimate-dashboard.json +34 -5
- package/examples/alarm-ultimate-home-assistant-alarm-panel.json +335 -0
- package/flowfuse-node-red-dashboard-1.30.2.tgz +0 -0
- package/nodes/AlarmSystemUltimate.html +332 -105
- package/nodes/AlarmSystemUltimate.js +158 -12
- package/nodes/AlarmUltimateInputAdapter.html +304 -0
- package/nodes/AlarmUltimateInputAdapter.js +188 -0
- package/nodes/AlarmUltimateZone.html +2 -2
- package/nodes/AlarmUltimateZone.js +6 -3
- package/nodes/presets/input-adapter/ax-pro-hikvision-ultimate.js +34 -0
- package/nodes/presets/input-adapter/boolean-from-payload.js +10 -0
- package/nodes/presets/input-adapter/ha-on-off.js +24 -0
- package/nodes/presets/input-adapter/knx-ultimate.js +29 -0
- package/nodes/presets/input-adapter/passthrough.js +7 -0
- package/package.json +4 -3
- package/test/alarm-system.spec.js +112 -0
- package/test/input-adapter.spec.js +243 -0
- package/test/output-nodes.spec.js +3 -0
- package/tools/alarm-json-mapper.html +955 -167
- package/tools/alarm-panel.html +995 -139
|
@@ -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.
|
|
149
|
-
background:
|
|
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
|
-
<
|
|
225
|
-
<
|
|
226
|
-
|
|
227
|
-
<
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
<
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
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). 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
|
-
|
|
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
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
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
|
|
289
|
-
<
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
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
|
|
297
|
-
<
|
|
298
|
-
|
|
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
|
-
|
|
302
|
-
|
|
303
|
-
|
|
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
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
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="
|
|
312
|
-
|
|
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
|
-
|
|
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
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
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
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
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
|
-
|
|
464
|
-
|
|
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
|
-
|
|
1045
|
-
|
|
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
|
-
|
|
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
|
-
|
|
1093
|
-
|
|
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
|
-
|
|
1143
|
-
|
|
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
|
-
|
|
1179
|
-
|
|
1180
|
-
|
|
1181
|
-
|
|
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
|
-
|
|
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
|
-
|
|
1220
|
-
|
|
1221
|
-
|
|
1222
|
-
|
|
1223
|
-
|
|
1224
|
-
els.input.value =
|
|
1225
|
-
|
|
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
|
-
|
|
1232
|
-
|
|
1233
|
-
|
|
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
|
-
|
|
1237
|
-
|
|
1238
|
-
|
|
1239
|
-
|
|
1240
|
-
|
|
1241
|
-
|
|
1242
|
-
|
|
1243
|
-
|
|
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
|
|