node-red-contrib-alarm-ultimate 0.1.1 → 0.1.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +11 -0
- package/examples/README.md +13 -0
- package/examples/alarm-ultimate-basic.json +0 -1
- package/examples/alarm-ultimate-dashboard-controls.json +3 -2
- package/examples/alarm-ultimate-dashboard-v2.json +762 -0
- package/examples/alarm-ultimate-dashboard.json +3 -3
- package/flowfuse-node-red-dashboard-1.30.2.tgz +0 -0
- package/nodes/AlarmSystemUltimate.html +171 -82
- package/nodes/AlarmSystemUltimate.js +39 -8
- package/nodes/AlarmUltimateInputAdapter.html +304 -0
- package/nodes/AlarmUltimateInputAdapter.js +188 -0
- package/nodes/AlarmUltimateZone.html +2 -2
- package/nodes/AlarmUltimateZone.js +6 -3
- package/nodes/presets/input-adapter/ax-pro-hikvision-ultimate.js +34 -0
- package/nodes/presets/input-adapter/boolean-from-payload.js +10 -0
- package/nodes/presets/input-adapter/ha-on-off.js +24 -0
- package/nodes/presets/input-adapter/knx-ultimate.js +29 -0
- package/nodes/presets/input-adapter/passthrough.js +7 -0
- package/package.json +4 -3
- package/test/alarm-system.spec.js +51 -0
- package/test/input-adapter.spec.js +243 -0
- package/test/output-nodes.spec.js +3 -0
- package/tools/alarm-json-mapper.html +934 -165
- package/tools/alarm-panel.html +630 -131
|
@@ -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
|
-
|
|
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
|
|
281
|
-
<
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
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
|
|
289
|
-
<
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
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
|
-
|
|
310
|
-
|
|
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
|
-
|
|
320
|
-
|
|
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,448 @@
|
|
|
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
|
+
}, 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
|
-
|
|
1045
|
-
|
|
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
|
-
|
|
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
|
-
|
|
1093
|
-
|
|
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
|
-
|
|
1143
|
-
|
|
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
|
-
|
|
1179
|
-
|
|
1180
|
-
|
|
1181
|
-
|
|
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
|
-
|
|
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
|
-
|
|
1220
|
-
|
|
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
|
-
|
|
1224
|
-
|
|
1225
|
-
|
|
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
|
-
|
|
1228
|
-
|
|
1229
|
-
|
|
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
|
-
|
|
1232
|
-
|
|
1233
|
-
|
|
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.
|
|
1237
|
-
const
|
|
1238
|
-
if (!
|
|
1239
|
-
|
|
1240
|
-
|
|
1241
|
-
|
|
1242
|
-
|
|
1243
|
-
|
|
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
|
|