node-red-contrib-alarm-ultimate 0.1.0 → 0.1.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (35) hide show
  1. package/README.md +87 -13
  2. package/docs/images/alarm-panel-mock.svg +114 -0
  3. package/docs/images/banner.svg +63 -0
  4. package/docs/images/flow-overview.svg +85 -0
  5. package/examples/README.md +32 -11
  6. package/examples/alarm-ultimate-basic.json +0 -1
  7. package/examples/alarm-ultimate-dashboard-controls.json +575 -0
  8. package/examples/alarm-ultimate-dashboard-v2.json +762 -0
  9. package/examples/alarm-ultimate-dashboard.json +3 -3
  10. package/flowfuse-node-red-dashboard-1.30.2.tgz +0 -0
  11. package/nodes/AlarmSystemUltimate.html +174 -85
  12. package/nodes/AlarmSystemUltimate.js +39 -8
  13. package/nodes/AlarmUltimateInputAdapter.html +304 -0
  14. package/nodes/AlarmUltimateInputAdapter.js +188 -0
  15. package/nodes/AlarmUltimateSiren.html +3 -3
  16. package/nodes/AlarmUltimateSiren.js +6 -2
  17. package/nodes/AlarmUltimateState.html +3 -3
  18. package/nodes/AlarmUltimateState.js +6 -2
  19. package/nodes/AlarmUltimateZone.html +11 -6
  20. package/nodes/AlarmUltimateZone.js +27 -6
  21. package/nodes/icons/alarm-ultimate-siren.svg +6 -0
  22. package/nodes/icons/alarm-ultimate-state.svg +5 -0
  23. package/nodes/icons/alarm-ultimate-zone.svg +5 -0
  24. package/nodes/icons/alarm-ultimate.svg +6 -0
  25. package/nodes/presets/input-adapter/ax-pro-hikvision-ultimate.js +34 -0
  26. package/nodes/presets/input-adapter/boolean-from-payload.js +10 -0
  27. package/nodes/presets/input-adapter/ha-on-off.js +24 -0
  28. package/nodes/presets/input-adapter/knx-ultimate.js +29 -0
  29. package/nodes/presets/input-adapter/passthrough.js +7 -0
  30. package/package.json +5 -4
  31. package/test/alarm-system.spec.js +51 -0
  32. package/test/input-adapter.spec.js +243 -0
  33. package/test/output-nodes.spec.js +3 -0
  34. package/tools/alarm-json-mapper.html +1882 -460
  35. package/tools/alarm-panel.html +630 -131
@@ -1,298 +1,496 @@
1
1
  <!doctype html>
2
2
  <html lang="en">
3
- <head>
4
- <meta charset="utf-8" />
5
- <meta name="viewport" content="width=device-width,initial-scale=1" />
6
- <title>Alarm JSON Mapper (Node-RED)</title>
7
- <style>
3
+
4
+ <head>
5
+ <meta charset="utf-8" />
6
+ <meta name="viewport" content="width=device-width,initial-scale=1" />
7
+ <title>Alarm JSON Mapper (Node-RED)</title>
8
+ <style>
9
+ :root {
10
+ color-scheme: light dark;
11
+ --bg: #0b1020;
12
+ --panel: #111936;
13
+ --text: #e7eaf6;
14
+ --muted: #a9b0d2;
15
+ --border: #2a355f;
16
+ --accent: #6ea8fe;
17
+ --ok: #2fbf71;
18
+ --warn: #ffcc66;
19
+ --danger: #ff6b6b;
20
+ --mono:
21
+ ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas,
22
+ "Liberation Mono", "Courier New", monospace;
23
+ --sans:
24
+ system-ui, -apple-system, Segoe UI, Roboto, Helvetica, Arial,
25
+ "Apple Color Emoji", "Segoe UI Emoji";
26
+ }
27
+
28
+ @media (prefers-color-scheme: light) {
8
29
  :root {
9
- color-scheme: light dark;
10
- --bg: #0b1020;
11
- --panel: #111936;
12
- --text: #e7eaf6;
13
- --muted: #a9b0d2;
14
- --border: #2a355f;
15
- --accent: #6ea8fe;
16
- --ok: #2fbf71;
17
- --warn: #ffcc66;
18
- --danger: #ff6b6b;
19
- --mono: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New",
20
- monospace;
21
- --sans: system-ui, -apple-system, Segoe UI, Roboto, Helvetica, Arial, "Apple Color Emoji",
22
- "Segoe UI Emoji";
23
- }
24
-
25
- @media (prefers-color-scheme: light) {
26
- :root {
27
- --bg: #f6f8ff;
28
- --panel: #ffffff;
29
- --text: #111827;
30
- --muted: #5b647a;
31
- --border: #d8deef;
32
- --accent: #2563eb;
33
- }
30
+ --bg: #f6f8ff;
31
+ --panel: #ffffff;
32
+ --text: #111827;
33
+ --muted: #5b647a;
34
+ --border: #d8deef;
35
+ --accent: #2563eb;
34
36
  }
37
+ }
35
38
 
36
- body {
37
- margin: 0;
38
- background: var(--bg);
39
- color: var(--text);
40
- font-family: var(--sans);
41
- line-height: 1.4;
42
- }
39
+ body {
40
+ margin: 0;
41
+ background: var(--bg);
42
+ color: var(--text);
43
+ font-family: var(--sans);
44
+ line-height: 1.4;
45
+ }
43
46
 
44
- header {
45
- padding: 18px 16px;
46
- border-bottom: 1px solid var(--border);
47
- background: linear-gradient(180deg, rgba(110, 168, 254, 0.12), rgba(0, 0, 0, 0));
48
- }
47
+ header {
48
+ padding: 18px 16px;
49
+ border-bottom: 1px solid var(--border);
50
+ background: linear-gradient(180deg,
51
+ rgba(110, 168, 254, 0.12),
52
+ rgba(0, 0, 0, 0));
53
+ }
49
54
 
50
- header h1 {
51
- font-size: 18px;
52
- margin: 0 0 6px 0;
53
- }
55
+ header h1 {
56
+ font-size: 18px;
57
+ margin: 0 0 6px 0;
58
+ }
54
59
 
55
- header p {
56
- margin: 0;
57
- color: var(--muted);
58
- font-size: 13px;
59
- }
60
+ header p {
61
+ margin: 0;
62
+ color: var(--muted);
63
+ font-size: 13px;
64
+ }
60
65
 
61
- main {
62
- max-width: 1100px;
63
- margin: 0 auto;
64
- padding: 16px;
65
- display: grid;
66
- gap: 12px;
67
- }
66
+ main {
67
+ max-width: 1100px;
68
+ margin: 0 auto;
69
+ padding: 16px;
70
+ display: grid;
71
+ gap: 12px;
72
+ }
68
73
 
74
+ .grid {
75
+ display: grid;
76
+ grid-template-columns: 1.2fr 0.8fr;
77
+ gap: 12px;
78
+ }
79
+
80
+ @media (max-width: 980px) {
69
81
  .grid {
70
- display: grid;
71
- grid-template-columns: 1.2fr 0.8fr;
72
- gap: 12px;
82
+ grid-template-columns: 1fr;
73
83
  }
84
+ }
74
85
 
75
- @media (max-width: 980px) {
76
- .grid {
77
- grid-template-columns: 1fr;
78
- }
79
- }
86
+ .card {
87
+ border: 1px solid var(--border);
88
+ background: var(--panel);
89
+ border-radius: 10px;
90
+ padding: 12px;
91
+ }
80
92
 
81
- .card {
82
- border: 1px solid var(--border);
83
- background: var(--panel);
84
- border-radius: 10px;
85
- padding: 12px;
86
- }
93
+ .card h2 {
94
+ font-size: 14px;
95
+ margin: 0 0 10px 0;
96
+ }
87
97
 
88
- .card h2 {
89
- font-size: 14px;
90
- margin: 0 0 10px 0;
91
- }
98
+ .row {
99
+ display: grid;
100
+ grid-template-columns: 160px 1fr;
101
+ gap: 10px;
102
+ align-items: center;
103
+ margin: 8px 0;
104
+ }
92
105
 
93
- .row {
94
- display: grid;
95
- grid-template-columns: 160px 1fr;
96
- gap: 10px;
97
- align-items: center;
98
- margin: 8px 0;
99
- }
106
+ .row label {
107
+ font-size: 12px;
108
+ color: var(--muted);
109
+ }
100
110
 
101
- .row label {
102
- font-size: 12px;
103
- color: var(--muted);
104
- }
111
+ textarea,
112
+ select,
113
+ input[type="text"] {
114
+ width: 100%;
115
+ border: 1px solid var(--border);
116
+ background: transparent;
117
+ color: var(--text);
118
+ border-radius: 8px;
119
+ padding: 8px 10px;
120
+ box-sizing: border-box;
121
+ font-family: var(--mono);
122
+ font-size: 12px;
123
+ }
105
124
 
106
- textarea,
107
- select,
108
- input[type="text"] {
109
- width: 100%;
110
- border: 1px solid var(--border);
111
- background: transparent;
112
- color: var(--text);
113
- border-radius: 8px;
114
- padding: 8px 10px;
115
- box-sizing: border-box;
116
- font-family: var(--mono);
117
- font-size: 12px;
118
- }
125
+ textarea {
126
+ min-height: 240px;
127
+ resize: vertical;
128
+ }
119
129
 
120
- textarea {
121
- min-height: 240px;
122
- resize: vertical;
123
- }
130
+ .buttons {
131
+ display: flex;
132
+ flex-wrap: wrap;
133
+ gap: 8px;
134
+ margin-top: 10px;
135
+ }
124
136
 
125
- .buttons {
126
- display: flex;
127
- flex-wrap: wrap;
128
- gap: 8px;
129
- margin-top: 10px;
130
- }
137
+ button {
138
+ border: 1px solid var(--border);
139
+ background: rgba(110, 168, 254, 0.12);
140
+ color: var(--text);
141
+ border-radius: 8px;
142
+ padding: 8px 10px;
143
+ cursor: pointer;
144
+ font-size: 12px;
145
+ transition: background-color 120ms ease, border-color 120ms ease, transform 80ms ease;
146
+ }
131
147
 
132
- button {
133
- border: 1px solid var(--border);
134
- background: rgba(110, 168, 254, 0.12);
135
- color: var(--text);
136
- border-radius: 8px;
137
- padding: 8px 10px;
138
- cursor: pointer;
139
- font-size: 12px;
140
- }
148
+ button.primary {
149
+ border-color: rgba(110, 168, 254, 0.85);
150
+ background: var(--accent);
151
+ color: #ffffff;
152
+ }
141
153
 
142
- button.primary {
143
- border-color: rgba(110, 168, 254, 0.5);
144
- background: rgba(110, 168, 254, 0.22);
145
- }
154
+ button:hover {
155
+ background: rgba(110, 168, 254, 0.18);
156
+ border-color: rgba(110, 168, 254, 0.35);
157
+ }
146
158
 
147
- button:disabled {
148
- opacity: 0.6;
149
- cursor: not-allowed;
150
- }
159
+ button.primary:hover {
160
+ filter: brightness(1.05);
161
+ }
151
162
 
152
- .hint {
153
- font-size: 12px;
154
- color: var(--muted);
155
- margin: 6px 0 0 0;
156
- }
163
+ button:active {
164
+ transform: translateY(1px);
165
+ }
157
166
 
158
- .status {
159
- font-family: var(--mono);
160
- font-size: 12px;
161
- padding: 8px 10px;
162
- border-radius: 8px;
163
- border: 1px solid var(--border);
164
- margin-top: 10px;
165
- }
167
+ button:disabled {
168
+ opacity: 0.6;
169
+ cursor: not-allowed;
170
+ }
166
171
 
167
- .status.ok {
168
- border-color: rgba(47, 191, 113, 0.55);
169
- color: var(--ok);
170
- }
172
+ .hint {
173
+ font-size: 12px;
174
+ color: var(--muted);
175
+ margin: 6px 0 0 0;
176
+ }
171
177
 
172
- .status.warn {
173
- border-color: rgba(255, 204, 102, 0.55);
174
- color: var(--warn);
175
- }
178
+ .status {
179
+ font-family: var(--mono);
180
+ font-size: 12px;
181
+ padding: 8px 10px;
182
+ border-radius: 8px;
183
+ border: 1px solid var(--border);
184
+ margin-top: 10px;
185
+ }
176
186
 
177
- .status.err {
178
- border-color: rgba(255, 107, 107, 0.55);
179
- color: var(--danger);
180
- }
187
+ .status.ok {
188
+ border-color: rgba(47, 191, 113, 0.55);
189
+ color: var(--ok);
190
+ }
181
191
 
182
- pre {
183
- margin: 0;
184
- white-space: pre-wrap;
185
- word-break: break-word;
186
- font-family: var(--mono);
187
- font-size: 12px;
188
- }
192
+ .status.warn {
193
+ border-color: rgba(255, 204, 102, 0.55);
194
+ color: var(--warn);
195
+ }
189
196
 
190
- .mono {
191
- font-family: var(--mono);
192
- }
197
+ .status.err {
198
+ border-color: rgba(255, 107, 107, 0.55);
199
+ color: var(--danger);
200
+ }
193
201
 
202
+ pre {
203
+ margin: 0;
204
+ white-space: pre-wrap;
205
+ word-break: break-word;
206
+ font-family: var(--mono);
207
+ font-size: 12px;
208
+ }
209
+
210
+ .mono {
211
+ font-family: var(--mono);
212
+ }
213
+
214
+ .two {
215
+ display: grid;
216
+ grid-template-columns: 1fr 1fr;
217
+ gap: 12px;
218
+ }
219
+
220
+ @media (max-width: 980px) {
194
221
  .two {
195
- display: grid;
196
- grid-template-columns: 1fr 1fr;
197
- gap: 12px;
222
+ grid-template-columns: 1fr;
198
223
  }
224
+ }
199
225
 
200
- @media (max-width: 980px) {
201
- .two {
202
- grid-template-columns: 1fr;
203
- }
204
- }
205
- </style>
206
- </head>
207
- <body>
208
- <header>
209
- <h1>Alarm JSON Mapper</h1>
210
- <p>
211
- Paste a sample incoming message (e.g. KNX Ultimate) and map its fields to what the Alarm node needs (topic/value).
212
- This tool generates a zone JSON object you can paste into the Alarm node configuration.
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
+ }
296
+ </style>
297
+ </head>
298
+
299
+ <body>
300
+ <header>
301
+ <h1>Alarm JSON Mapper</h1>
302
+ <p>
303
+ Paste a sample incoming message (e.g. KNX Ultimate) and map its fields
304
+ to what the Alarm node needs (topic/value), or paste an ETS Group
305
+ Addresses export (TSV) to generate zones in batch.
306
+ </p>
307
+ </header>
308
+
309
+ <main>
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>
213
319
  </p>
214
- </header>
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>
215
337
 
216
- <main>
338
+ <div id="wizard" style="display:none;">
217
339
  <div class="grid">
218
- <section class="card">
219
- <h2>1) Input message</h2>
220
- <textarea
221
- id="input"
222
- spellcheck="false"
223
- placeholder='Paste JSON here (strict JSON).&#10;&#10;Tip: this tool also accepts JS-style objects with unquoted keys and // comments.'
224
- ></textarea>
340
+ <section class="card" id="wizard-step1">
341
+ <h2>Step 1 Paste data</h2>
342
+ <textarea id="input" spellcheck="false"
343
+ placeholder="Paste JSON here (message/HA export) or TSV (ETS export).&#10;&#10;Tip: this tool also accepts JS-style objects with unquoted keys and // comments."></textarea>
225
344
  <div class="buttons">
226
- <button class="primary" id="btn-parse">Parse</button>
227
- <button id="btn-load-knx">Load KNX sample</button>
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>
228
353
  <button id="btn-clear">Clear</button>
354
+ <button class="primary" id="btn-parse">Parse / detect</button>
229
355
  </div>
230
356
  <p class="hint">
231
- If your input is not strict JSON (comments, unquoted keys), click Parse anyway: the tool will try to
232
- normalize it.
357
+ Parse detects the format and unlocks the next step.
233
358
  </p>
234
359
  <div id="parse-status" class="status warn" style="display: none"></div>
235
360
  </section>
236
361
 
237
- <section class="card">
238
- <h2>2) Field mapping</h2>
239
- <div class="row">
240
- <label for="topicPath">Topic path</label>
241
- <select id="topicPath"></select>
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>
242
388
  </div>
243
- <div class="row">
244
- <label for="valuePath">Value path</label>
245
- <select id="valuePath"></select>
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>
246
423
  </div>
247
- <div class="row">
248
- <label for="namePath">Zone name path</label>
249
- <select id="namePath"></select>
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>
250
437
  </div>
251
438
  <p class="hint">
252
- Alarm expects zone messages with <span class="mono">msg.topic</span> and a boolean value in the configured
253
- “With Input” property (default <span class="mono">payload</span>).
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>).
254
443
  </p>
255
444
  <div class="buttons">
256
- <button class="primary" id="btn-generate">Generate output</button>
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>
257
450
  </div>
258
451
  <div id="gen-status" class="status warn" style="display: none"></div>
259
452
  </section>
260
453
  </div>
261
-
262
- <section class="card">
263
- <h2>3) Zone JSON template (for Alarm node)</h2>
264
- <div class="buttons">
265
- <button id="btn-copy-zone" disabled>Copy</button>
266
- </div>
267
- <textarea
268
- id="zone-output"
269
- spellcheck="false"
270
- placeholder="Click “Generate output” to create a zone template, then edit it here if needed."
271
- ></textarea>
272
- <p class="hint">
273
- This creates a single zone object using the mapped topic and optional name. Paste it into the Zones field
274
- (legacy: one JSON object per line, or formatted: JSON array).
275
- </p>
276
- </section>
277
- </main>
278
-
279
- <script>
280
- const els = {
281
- input: document.getElementById("input"),
282
- btnParse: document.getElementById("btn-parse"),
283
- btnLoadKnx: document.getElementById("btn-load-knx"),
284
- btnClear: document.getElementById("btn-clear"),
285
- parseStatus: document.getElementById("parse-status"),
286
- genStatus: document.getElementById("gen-status"),
287
- topicPath: document.getElementById("topicPath"),
288
- valuePath: document.getElementById("valuePath"),
289
- namePath: document.getElementById("namePath"),
290
- btnGenerate: document.getElementById("btn-generate"),
291
- zoneOutput: document.getElementById("zone-output"),
292
- btnCopyZone: document.getElementById("btn-copy-zone"),
293
- };
294
-
295
- const KNX_SAMPLE = `{
454
+ </div>
455
+ </main>
456
+
457
+ <script>
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"),
464
+ parseStatus: document.getElementById("parse-status"),
465
+ genStatus: document.getElementById("gen-status"),
466
+ topicPath: document.getElementById("topicPath"),
467
+ valuePath: document.getElementById("valuePath"),
468
+ namePath: document.getElementById("namePath"),
469
+ etsHint: document.getElementById("ets-hint"),
470
+ haHint: document.getElementById("ha-hint"),
471
+ etsMapping: document.getElementById("ets-mapping"),
472
+ etsAddressFilter: document.getElementById("etsAddressFilter"),
473
+ etsNameFilter: document.getElementById("etsNameFilter"),
474
+ haMapping: document.getElementById("ha-mapping"),
475
+ haEntityFilter: document.getElementById("haEntityFilter"),
476
+ haNameFilter: document.getElementById("haNameFilter"),
477
+ haDomains: document.getElementById("haDomains"),
478
+ haIncludeDisabled: document.getElementById("haIncludeDisabled"),
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
+ };
492
+
493
+ const KNX_SAMPLE = `{
296
494
  topic: "0/1/2",
297
495
  payload: false,
298
496
  previouspayload: true,
@@ -312,285 +510,1509 @@
312
510
  dpt: "1.001",
313
511
  dptdesc: "Humidity",
314
512
  source: "15.15.22",
315
- destination: "0/1/2",
316
- rawValue: {
317
- 0: "0x0"
318
- }
513
+ destination: "0/1/2",
514
+ rawValue: {
515
+ 0: "0x0"
516
+ }
319
517
  }
320
518
  }`;
321
519
 
322
- let parsedObject = null;
323
- let lastGenerated = null;
520
+ const ETS_SAMPLE = `"Group name"\t"Address"\t"Central"\t"Unfiltered"\t"Description"\t"DatapointType"\t"Security"
521
+ "Lights"\t"0/-/-"\t""\t""\t""\t""\t"Auto"
522
+ "Living room"\t"0/0/-"\t""\t""\t""\t""\t"Auto"
523
+ "Front door contact"\t"0/0/1"\t""\t""\t""\t"DPST-1-1"\t"Auto"
524
+ "Motion sensor"\t"0/0/2"\t""\t""\t""\t"DPT-1"\t"Auto"
525
+ "Dimming value"\t"0/0/3"\t""\t""\t""\t"DPST-5-1"\t"Auto"`;
526
+
527
+ const HA_SAMPLE = `[
528
+ {
529
+ "entity_id": "binary_sensor.front_door",
530
+ "state": "off",
531
+ "attributes": {
532
+ "friendly_name": "Front door"
533
+ }
534
+ },
535
+ {
536
+ "entity_id": "binary_sensor.living_pir",
537
+ "state": "on",
538
+ "attributes": {
539
+ "friendly_name": "Living PIR"
540
+ }
541
+ },
542
+ {
543
+ "entity_id": "switch.garden_lights",
544
+ "state": "off",
545
+ "attributes": {
546
+ "friendly_name": "Garden lights"
547
+ }
548
+ }
549
+ ]`;
550
+
551
+ const HA_REGISTRY_SAMPLE = `{
552
+ "data": {
553
+ "entities": [
554
+ {
555
+ "entity_id": "binary_sensor.front_door",
556
+ "name": "Front door",
557
+ "original_name": "Front door contact",
558
+ "disabled_by": null
559
+ },
560
+ {
561
+ "entity_id": "binary_sensor.living_pir",
562
+ "name": "Living PIR",
563
+ "original_name": "Living PIR",
564
+ "disabled_by": null
565
+ },
566
+ {
567
+ "entity_id": "switch.garden_lights",
568
+ "name": "Garden lights",
569
+ "original_name": "Garden lights",
570
+ "disabled_by": "user"
571
+ }
572
+ ]
573
+ },
574
+ "key": "core.entity_registry",
575
+ "version": 1
576
+ }`;
324
577
 
325
- function showStatus(el, kind, message) {
326
- el.style.display = "";
327
- el.className = `status ${kind}`;
328
- el.textContent = message;
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;
329
616
  }
617
+ els.zoneAutosave.style.display = "";
618
+ els.zoneAutosave.textContent = message;
619
+ }
330
620
 
331
- function hideStatus(el) {
332
- el.style.display = "none";
333
- el.textContent = "";
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;
334
633
  }
634
+ els.zoneConnection.textContent = "Not connected";
635
+ els.zoneConnection.className = "pill err";
636
+ }
335
637
 
336
- function normalizeLenientJson(input) {
337
- let text = String(input || "");
338
- text = text.replace(/\r\n/g, "\n");
339
- text = text.replace(/\/\*[\s\S]*?\*\//g, "");
340
- text = text.replace(/(^|[^:])\/\/.*$/gm, "$1");
638
+ function canTalkToEditor() {
639
+ return Boolean(page.alarmNodeId) && (page.hasOpener || Boolean(bc));
640
+ }
341
641
 
342
- text = text.replace(/([{,]\s*)([A-Za-z_$][\w$]*)(\s*:)/g, '$1"$2"$3');
343
- text = text.replace(/([{,]\s*)(\d+)(\s*:)/g, '$1"$2"$3');
642
+ function isConnectedToEditor() {
643
+ return Boolean(page.alarmNodeId) && editorConnected === true;
644
+ }
344
645
 
345
- text = text.replace(/:\s*0x([0-9a-fA-F]+)\b/g, ': "0x$1"');
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
+ }
346
663
 
347
- return text;
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;
348
689
  }
690
+ zone.type = ["fire", "tamper", "24h"].includes(k) ? k : "perimeter";
691
+ zone.entry = false;
692
+ }
349
693
 
350
- function parseInput(text) {
351
- const raw = String(text || "").trim();
352
- if (!raw) return { ok: false, error: "Empty input." };
694
+ function cleanZoneForJson(zone, index) {
695
+ const z = zone && typeof zone === "object" ? { ...zone } : {};
696
+ const name = String(z.name || "").trim();
353
697
 
354
- try {
355
- return { ok: true, value: JSON.parse(raw), note: "Parsed as strict JSON." };
356
- } catch (err1) {
357
- try {
358
- const normalized = normalizeLenientJson(raw);
359
- const value = JSON.parse(normalized);
360
- return { ok: true, value, note: "Parsed after normalizing (removed comments, quoted keys)." };
361
- } catch (err2) {
362
- return {
363
- ok: false,
364
- error:
365
- "Unable to parse. Please paste strict JSON, or a JS-style object with only // comments and unquoted keys.",
366
- details: String(err2 && err2.message ? err2.message : err2),
367
- };
368
- }
369
- }
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 = "";
370
709
  }
371
710
 
372
- function isPlainObject(value) {
373
- return Boolean(value) && typeof value === "object" && !Array.isArray(value);
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}`;
374
716
  }
375
717
 
376
- function enumeratePaths(value, basePath = "", out = []) {
377
- if (value === null || value === undefined) {
378
- out.push(basePath || "(root)");
379
- return out;
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;
380
753
  }
381
- if (typeof value !== "object") {
382
- out.push(basePath || "(root)");
383
- return out;
754
+ if (z.__idSource === "explicit") {
755
+ continue;
384
756
  }
757
+ let suffix = 2;
758
+ while (used.has(`${key}_${suffix}`)) suffix += 1;
759
+ z.id = `${base}_${suffix}`;
760
+ used.add(`${key}_${suffix}`);
761
+ }
385
762
 
386
- if (Array.isArray(value)) {
387
- out.push(basePath || "(root)");
388
- const limit = Math.min(20, value.length);
389
- for (let i = 0; i < limit; i += 1) {
390
- enumeratePaths(value[i], `${basePath}[${i}]`, out);
391
- }
392
- return out;
393
- }
763
+ payload.forEach((z) => {
764
+ delete z.__idSource;
765
+ });
394
766
 
395
- out.push(basePath || "(root)");
396
- const keys = Object.keys(value).slice(0, 200);
397
- for (const key of keys) {
398
- const next = basePath ? `${basePath}.${key}` : key;
399
- enumeratePaths(value[key], next, out);
400
- }
401
- return out;
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;
402
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
+ }
403
801
 
404
- function getByPath(obj, path) {
405
- if (!path || path === "(root)") return obj;
406
- const parts = [];
407
- String(path)
408
- .split(".")
409
- .forEach((segment) => {
410
- const m = segment.match(/^([^[\]]+)(\[(\d+)\])?$/);
411
- if (!m) {
412
- parts.push(segment);
413
- return;
414
- }
415
- parts.push(m[1]);
416
- if (m[2]) parts.push(Number(m[3]));
417
- });
418
-
419
- let cur = obj;
420
- for (const part of parts) {
421
- if (cur === null || cur === undefined) return undefined;
422
- cur = cur[part];
423
- }
424
- return cur;
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
+ });
425
828
  }
426
829
 
427
- function toOneLine(value) {
428
- if (value === undefined) return "undefined";
429
- if (typeof value === "string") return value;
430
- try {
431
- return JSON.stringify(value);
432
- } catch (err) {
433
- return String(value);
434
- }
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
+ );
435
847
  }
848
+ scheduleAutosave(errors);
849
+ }
436
850
 
437
- function guessDefault(paths, candidates) {
438
- for (const c of candidates) {
439
- if (paths.includes(c)) return c;
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
+ );
440
978
  }
441
- return paths.includes("topic") ? "topic" : paths[0] || "(root)";
979
+ } catch (_err) {
980
+ // ignore
442
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
+ }
443
995
 
444
- function setSelectOptions(select, paths, selected) {
445
- select.innerHTML = "";
446
- for (const p of paths) {
447
- const opt = document.createElement("option");
448
- opt.value = p;
449
- opt.textContent = p;
450
- select.appendChild(opt);
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 });
451
1014
  }
452
- select.value = selected;
1015
+ } catch (_err) {
1016
+ // ignore
453
1017
  }
1018
+ setAutosaveHint("Autosave: loading zones from Alarm...");
1019
+ }
1020
+
1021
+ function showStatus(el, kind, message) {
1022
+ el.style.display = "";
1023
+ el.className = `status ${kind}`;
1024
+ el.textContent = message;
1025
+ }
1026
+
1027
+ function hideStatus(el) {
1028
+ el.style.display = "none";
1029
+ el.textContent = "";
1030
+ }
1031
+
1032
+ function normalizeLenientJson(input) {
1033
+ let text = String(input || "");
1034
+ text = text.replace(/\r\n/g, "\n");
1035
+ text = text.replace(/\/\*[\s\S]*?\*\//g, "");
1036
+ text = text.replace(/(^|[^:])\/\/.*$/gm, "$1");
1037
+
1038
+ text = text.replace(/([{,]\s*)([A-Za-z_$][\w$]*)(\s*:)/g, '$1"$2"$3');
1039
+ text = text.replace(/([{,]\s*)(\d+)(\s*:)/g, '$1"$2"$3');
454
1040
 
455
- function buildZoneTemplate(normalizedMsg, zoneNameValue) {
456
- const topic = normalizedMsg && normalizedMsg.topic ? String(normalizedMsg.topic) : "";
457
- const name =
458
- zoneNameValue !== undefined && zoneNameValue !== null && String(zoneNameValue).trim().length > 0
459
- ? String(zoneNameValue).trim()
460
- : topic || "Zone";
461
- const id = topic ? topic.replace(/[^\w]+/g, "_").replace(/^_+|_+$/g, "").toLowerCase() : "zone1";
1041
+ text = text.replace(/:\s*0x([0-9a-fA-F]+)\b/g, ': "0x$1"');
462
1042
 
1043
+ return text;
1044
+ }
1045
+
1046
+ function parseInput(text) {
1047
+ const raw = String(text || "").trim();
1048
+ if (!raw) return { ok: false, error: "Empty input." };
1049
+
1050
+ try {
463
1051
  return {
464
- id,
465
- name,
466
- topic,
467
- type: "perimeter",
468
- entry: false,
469
- bypassable: true,
470
- chime: false,
1052
+ ok: true,
1053
+ value: JSON.parse(raw),
1054
+ note: "Parsed as strict JSON.",
471
1055
  };
472
- }
473
-
474
- async function copyToClipboard(text) {
1056
+ } catch (err1) {
475
1057
  try {
476
- await navigator.clipboard.writeText(text);
477
- return true;
478
- } catch (err) {
479
- return false;
1058
+ const normalized = normalizeLenientJson(raw);
1059
+ const value = JSON.parse(normalized);
1060
+ return {
1061
+ ok: true,
1062
+ value,
1063
+ note: "Parsed after normalizing (removed comments, quoted keys).",
1064
+ };
1065
+ } catch (err2) {
1066
+ const ets = parseEtsExport(raw);
1067
+ if (ets.ok) {
1068
+ return { ok: true, value: { __ets: true, ets }, note: ets.note };
1069
+ }
1070
+ return {
1071
+ ok: false,
1072
+ error:
1073
+ "Unable to parse. Please paste strict JSON, or a JS-style object with only // comments and unquoted keys.",
1074
+ details: String(err2 && err2.message ? err2.message : err2),
1075
+ };
480
1076
  }
481
1077
  }
1078
+ }
482
1079
 
483
- function renderJson(el, obj) {
484
- el.style.display = "";
485
- el.className = "status ok";
486
- el.innerHTML = "";
487
- const pre = document.createElement("pre");
488
- pre.textContent = JSON.stringify(obj, null, 2);
489
- el.appendChild(pre);
490
- }
491
-
492
- function renderText(el, text) {
493
- el.style.display = "";
494
- el.className = "status ok";
495
- el.innerHTML = "";
496
- const pre = document.createElement("pre");
497
- pre.textContent = String(text || "");
498
- el.appendChild(pre);
499
- }
500
-
501
- function parseAndPopulate() {
502
- hideStatus(els.parseStatus);
503
- hideStatus(els.genStatus);
504
- els.zoneOutput.value = "";
505
- els.btnCopyZone.disabled = true;
506
- lastGenerated = null;
507
-
508
- const result = parseInput(els.input.value);
509
- if (!result.ok) {
510
- parsedObject = null;
511
- showStatus(els.parseStatus, "err", `${result.error}${result.details ? "\n" + result.details : ""}`);
512
- return;
513
- }
1080
+ function normalizeHeaderKey(value) {
1081
+ return String(value || "")
1082
+ .trim()
1083
+ .toLowerCase()
1084
+ .replace(/^\ufeff/, "")
1085
+ .replace(/[^a-z0-9]/g, "");
1086
+ }
514
1087
 
515
- if (!isPlainObject(result.value)) {
516
- parsedObject = result.value;
517
- showStatus(
518
- els.parseStatus,
519
- "warn",
520
- `${result.note} Note: the root is not an object. Paths will be limited.`
521
- );
1088
+ function parseDelimitedLine(line, delimiter) {
1089
+ const out = [];
1090
+ let cur = "";
1091
+ let inQuotes = false;
1092
+ for (let i = 0; i < line.length; i += 1) {
1093
+ const ch = line[i];
1094
+ if (inQuotes) {
1095
+ if (ch === '"') {
1096
+ const next = line[i + 1];
1097
+ if (next === '"') {
1098
+ cur += '"';
1099
+ i += 1;
1100
+ } else {
1101
+ inQuotes = false;
1102
+ }
1103
+ } else {
1104
+ cur += ch;
1105
+ }
522
1106
  } else {
523
- parsedObject = result.value;
524
- showStatus(els.parseStatus, "ok", result.note);
1107
+ if (ch === '"') {
1108
+ inQuotes = true;
1109
+ } else if (ch === delimiter) {
1110
+ out.push(cur.trim());
1111
+ cur = "";
1112
+ } else {
1113
+ cur += ch;
1114
+ }
525
1115
  }
1116
+ }
1117
+ out.push(cur.trim());
1118
+ return out;
1119
+ }
526
1120
 
527
- const paths = Array.from(new Set(enumeratePaths(parsedObject))).filter(Boolean);
528
- paths.sort((a, b) => a.localeCompare(b));
1121
+ function detectDelimiter(headerLine) {
1122
+ const line = String(headerLine || "");
1123
+ if (line.includes("\t")) return "\t";
1124
+ if (line.includes(";")) return ";";
1125
+ if (line.includes(",")) return ",";
1126
+ return "\t";
1127
+ }
529
1128
 
530
- const defaultTopic = guessDefault(paths, ["topic", "knx.destination", "destination"]);
531
- const defaultValue = guessDefault(paths, ["payload", "payloadsubtypevalue", "value"]);
532
- const defaultName = guessDefault(paths, ["devicename", "gainfo.ganame", "name", "topic"]);
1129
+ function isBooleanDatapointType(value) {
1130
+ const t = String(value || "")
1131
+ .trim()
1132
+ .toLowerCase();
1133
+ if (!t) return false;
1134
+ return (
1135
+ t === "dpt-1" ||
1136
+ t.startsWith("dpt-1-") ||
1137
+ t.startsWith("dpst-1-") ||
1138
+ /^1\.\d+/.test(t)
1139
+ );
1140
+ }
533
1141
 
534
- setSelectOptions(els.topicPath, paths, defaultTopic);
535
- setSelectOptions(els.valuePath, paths, defaultValue);
536
- setSelectOptions(els.namePath, paths, defaultName);
1142
+ function parseEtsExport(text) {
1143
+ const raw = String(text || "")
1144
+ .replace(/\r\n/g, "\n")
1145
+ .replace(/\r/g, "\n")
1146
+ .trim();
1147
+
1148
+ const lines = raw
1149
+ .split("\n")
1150
+ .map((l) => l.trim())
1151
+ .filter((l) => l.length > 0);
1152
+
1153
+ if (lines.length < 2) return { ok: false };
1154
+
1155
+ const headerLine = lines[0];
1156
+ const delimiter = detectDelimiter(headerLine);
1157
+ const headers = parseDelimitedLine(headerLine, delimiter);
1158
+ const keyByIndex = headers.map(normalizeHeaderKey);
1159
+
1160
+ const idxGroupName = keyByIndex.indexOf("groupname");
1161
+ const idxAddress = keyByIndex.indexOf("address");
1162
+ const idxDescription = keyByIndex.indexOf("description");
1163
+ const idxDatapointType = keyByIndex.indexOf("datapointtype");
1164
+
1165
+ if (idxGroupName < 0 || idxAddress < 0) return { ok: false };
1166
+
1167
+ const rows = [];
1168
+ for (let i = 1; i < lines.length; i += 1) {
1169
+ const cols = parseDelimitedLine(lines[i], delimiter);
1170
+ const groupName = cols[idxGroupName] || "";
1171
+ const address = cols[idxAddress] || "";
1172
+ const description =
1173
+ idxDescription >= 0 ? cols[idxDescription] || "" : "";
1174
+ const datapointType =
1175
+ idxDatapointType >= 0 ? cols[idxDatapointType] || "" : "";
1176
+
1177
+ rows.push({ groupName, address, description, datapointType });
537
1178
  }
538
1179
 
539
- function generate() {
540
- hideStatus(els.genStatus);
541
- if (!parsedObject) {
542
- showStatus(els.genStatus, "err", "Parse a valid message first.");
543
- return;
544
- }
1180
+ return {
1181
+ ok: true,
1182
+ note: "Detected ETS Group Addresses export (TSV).",
1183
+ rows,
1184
+ };
1185
+ }
545
1186
 
546
- const topicPath = els.topicPath.value;
547
- const valuePath = els.valuePath.value;
548
- const namePath = els.namePath.value;
1187
+ function escapeRegExp(value) {
1188
+ return String(value || "").replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
1189
+ }
549
1190
 
550
- const topicValue = getByPath(parsedObject, topicPath);
551
- const valueValue = getByPath(parsedObject, valuePath);
552
- const nameValue = getByPath(parsedObject, namePath);
1191
+ function globToRegExp(glob) {
1192
+ const raw = String(glob || "").trim();
1193
+ if (!raw) return null;
1194
+ let pattern = "";
1195
+ for (let i = 0; i < raw.length; i += 1) {
1196
+ const ch = raw[i];
1197
+ if (ch === "*") pattern += ".*";
1198
+ else if (ch === "?") pattern += ".";
1199
+ else pattern += escapeRegExp(ch);
1200
+ }
1201
+ try {
1202
+ return new RegExp(`^${pattern}$`, "i");
1203
+ } catch (_err) {
1204
+ return null;
1205
+ }
1206
+ }
1207
+
1208
+ function splitPatterns(text) {
1209
+ return String(text || "")
1210
+ .split(/[\n,]+/g)
1211
+ .map((p) => p.trim())
1212
+ .filter((p) => p.length > 0);
1213
+ }
1214
+
1215
+ function matchesAnyGlob(value, patterns) {
1216
+ if (!patterns || patterns.length === 0) return true;
1217
+ const target = String(value || "").trim();
1218
+ if (!target) return false;
1219
+ for (const p of patterns) {
1220
+ const re = globToRegExp(p);
1221
+ if (re && re.test(target)) return true;
1222
+ }
1223
+ return false;
1224
+ }
1225
+
1226
+ function matchesNameFilter(row, nameFilterRaw) {
1227
+ const filter = String(nameFilterRaw || "").trim();
1228
+ if (!filter) return true;
1229
+ const name = String(row && (row.description || row.groupName) ? row.description || row.groupName : "")
1230
+ .trim()
1231
+ .toLowerCase();
1232
+ if (!name) return false;
1233
+ if (filter.includes("*") || filter.includes("?")) {
1234
+ const re = globToRegExp(filter);
1235
+ return Boolean(re && re.test(name));
1236
+ }
1237
+ return name.includes(filter.toLowerCase());
1238
+ }
1239
+
1240
+ function extractHomeAssistantStates(value) {
1241
+ if (Array.isArray(value)) return value;
1242
+ if (value && typeof value === "object") {
1243
+ if (Array.isArray(value.result)) return value.result;
1244
+ if (Array.isArray(value.states)) return value.states;
1245
+ if (Array.isArray(value.entities)) return value.entities;
1246
+ }
1247
+ return null;
1248
+ }
1249
+
1250
+ function isHomeAssistantStatesExport(value) {
1251
+ const states = extractHomeAssistantStates(value);
1252
+ if (!states || states.length === 0) return false;
1253
+ const sample = states.slice(0, 10);
1254
+ return sample.some((s) => s && typeof s === "object" && typeof s.entity_id === "string");
1255
+ }
1256
+
1257
+ function extractHomeAssistantEntityRegistry(value) {
1258
+ if (!value || typeof value !== "object") return null;
1259
+ if (Array.isArray(value.entities)) return value.entities;
1260
+ if (value.data && Array.isArray(value.data.entities)) return value.data.entities;
1261
+ if (value.result && value.result.data && Array.isArray(value.result.data.entities)) return value.result.data.entities;
1262
+ return null;
1263
+ }
1264
+
1265
+ function isHomeAssistantEntityRegistryExport(value) {
1266
+ const entities = extractHomeAssistantEntityRegistry(value);
1267
+ if (!entities || entities.length === 0) return false;
1268
+ const sample = entities.slice(0, 10);
1269
+ return sample.some((e) => e && typeof e === "object" && typeof e.entity_id === "string");
1270
+ }
1271
+
1272
+ function getHaDomain(entityId) {
1273
+ const id = String(entityId || "");
1274
+ const idx = id.indexOf(".");
1275
+ if (idx <= 0) return "";
1276
+ return id.slice(0, idx).toLowerCase();
1277
+ }
553
1278
 
554
- if (topicValue === undefined) {
555
- showStatus(els.genStatus, "err", `Topic path "${topicPath}" is undefined in the input message.`);
556
- return;
1279
+ function parseDomainList(input) {
1280
+ return String(input || "")
1281
+ .split(/[\s,]+/g)
1282
+ .map((d) => d.trim().toLowerCase())
1283
+ .filter((d) => d.length > 0);
1284
+ }
1285
+
1286
+ function isPlainObject(value) {
1287
+ return (
1288
+ Boolean(value) && typeof value === "object" && !Array.isArray(value)
1289
+ );
1290
+ }
1291
+
1292
+ function enumeratePaths(value, basePath = "", out = []) {
1293
+ if (value === null || value === undefined) {
1294
+ out.push(basePath || "(root)");
1295
+ return out;
1296
+ }
1297
+ if (typeof value !== "object") {
1298
+ out.push(basePath || "(root)");
1299
+ return out;
1300
+ }
1301
+
1302
+ if (Array.isArray(value)) {
1303
+ out.push(basePath || "(root)");
1304
+ const limit = Math.min(20, value.length);
1305
+ for (let i = 0; i < limit; i += 1) {
1306
+ enumeratePaths(value[i], `${basePath}[${i}]`, out);
557
1307
  }
1308
+ return out;
1309
+ }
558
1310
 
559
- const normalized = { ...parsedObject, topic: String(topicValue), payload: valueValue };
560
- const zone = buildZoneTemplate(normalized, nameValue);
561
- els.zoneOutput.value = JSON.stringify(zone, null, 2);
1311
+ out.push(basePath || "(root)");
1312
+ const keys = Object.keys(value).slice(0, 200);
1313
+ for (const key of keys) {
1314
+ const next = basePath ? `${basePath}.${key}` : key;
1315
+ enumeratePaths(value[key], next, out);
1316
+ }
1317
+ return out;
1318
+ }
562
1319
 
563
- els.btnCopyZone.disabled = false;
1320
+ function getByPath(obj, path) {
1321
+ if (!path || path === "(root)") return obj;
1322
+ const parts = [];
1323
+ String(path)
1324
+ .split(".")
1325
+ .forEach((segment) => {
1326
+ const m = segment.match(/^([^[\]]+)(\[(\d+)\])?$/);
1327
+ if (!m) {
1328
+ parts.push(segment);
1329
+ return;
1330
+ }
1331
+ parts.push(m[1]);
1332
+ if (m[2]) parts.push(Number(m[3]));
1333
+ });
1334
+
1335
+ let cur = obj;
1336
+ for (const part of parts) {
1337
+ if (cur === null || cur === undefined) return undefined;
1338
+ cur = cur[part];
1339
+ }
1340
+ return cur;
1341
+ }
564
1342
 
565
- lastGenerated = { normalized, zone };
566
- showStatus(els.genStatus, "ok", `Generated using topic="${toOneLine(topicValue)}" and value="${valuePath}".`);
1343
+ function toOneLine(value) {
1344
+ if (value === undefined) return "undefined";
1345
+ if (typeof value === "string") return value;
1346
+ try {
1347
+ return JSON.stringify(value);
1348
+ } catch (err) {
1349
+ return String(value);
567
1350
  }
1351
+ }
1352
+
1353
+ function guessDefault(paths, candidates) {
1354
+ for (const c of candidates) {
1355
+ if (paths.includes(c)) return c;
1356
+ }
1357
+ return paths.includes("topic") ? "topic" : paths[0] || "(root)";
1358
+ }
1359
+
1360
+ function setSelectOptions(select, paths, selected) {
1361
+ select.innerHTML = "";
1362
+ for (const p of paths) {
1363
+ const opt = document.createElement("option");
1364
+ opt.value = p;
1365
+ opt.textContent = p;
1366
+ select.appendChild(opt);
1367
+ }
1368
+ select.value = selected;
1369
+ }
1370
+
1371
+ function buildZoneTemplate(normalizedMsg, zoneNameValue) {
1372
+ const topic =
1373
+ normalizedMsg && normalizedMsg.topic
1374
+ ? String(normalizedMsg.topic)
1375
+ : "";
1376
+ const name =
1377
+ zoneNameValue !== undefined &&
1378
+ zoneNameValue !== null &&
1379
+ String(zoneNameValue).trim().length > 0
1380
+ ? String(zoneNameValue).trim()
1381
+ : topic || "Zone";
1382
+ const id = topic
1383
+ ? topic
1384
+ .replace(/[^\w]+/g, "_")
1385
+ .replace(/^_+|_+$/g, "")
1386
+ .toLowerCase()
1387
+ : "zone1";
1388
+
1389
+ return {
1390
+ id,
1391
+ name,
1392
+ topic,
1393
+ type: "perimeter",
1394
+ entry: false,
1395
+ bypassable: true,
1396
+ chime: false,
1397
+ };
1398
+ }
568
1399
 
569
- els.btnParse.addEventListener("click", () => parseAndPopulate());
570
- els.btnGenerate.addEventListener("click", () => generate());
571
- els.btnClear.addEventListener("click", () => {
572
- els.input.value = "";
1400
+ async function copyToClipboard(text) {
1401
+ try {
1402
+ await navigator.clipboard.writeText(text);
1403
+ return true;
1404
+ } catch (err) {
1405
+ return false;
1406
+ }
1407
+ }
1408
+
1409
+ function renderJson(el, obj) {
1410
+ el.style.display = "";
1411
+ el.className = "status ok";
1412
+ el.innerHTML = "";
1413
+ const pre = document.createElement("pre");
1414
+ pre.textContent = JSON.stringify(obj, null, 2);
1415
+ el.appendChild(pre);
1416
+ }
1417
+
1418
+ function renderText(el, text) {
1419
+ el.style.display = "";
1420
+ el.className = "status ok";
1421
+ el.innerHTML = "";
1422
+ const pre = document.createElement("pre");
1423
+ pre.textContent = String(text || "");
1424
+ el.appendChild(pre);
1425
+ }
1426
+
1427
+ function parseAndPopulate() {
1428
+ hideStatus(els.parseStatus);
1429
+ hideStatus(els.genStatus);
1430
+ hideStatus(els.etsHint);
1431
+ hideStatus(els.haHint);
1432
+ if (els.wizardStep2) els.wizardStep2.classList.add("disabled");
1433
+ els.jsonMapping.style.display = "";
1434
+ els.etsMapping.style.display = "none";
1435
+ els.haMapping.style.display = "none";
1436
+ lastGenerated = null;
1437
+
1438
+ const result = parseInput(els.input.value);
1439
+ if (!result.ok) {
573
1440
  parsedObject = null;
574
- lastGenerated = null;
575
- hideStatus(els.parseStatus);
576
- hideStatus(els.genStatus);
577
- els.zoneOutput.value = "";
578
- els.btnCopyZone.disabled = true;
579
- els.topicPath.innerHTML = "";
580
- els.valuePath.innerHTML = "";
581
- els.namePath.innerHTML = "";
582
- });
583
- els.btnLoadKnx.addEventListener("click", () => {
584
- els.input.value = KNX_SAMPLE;
585
- parseAndPopulate();
586
- });
1441
+ showStatus(
1442
+ els.parseStatus,
1443
+ "err",
1444
+ `${result.error}${result.details ? "\n" + result.details : ""}`,
1445
+ );
1446
+ return;
1447
+ }
1448
+
1449
+ if (els.wizardStep2) els.wizardStep2.classList.remove("disabled");
1450
+
1451
+ if (
1452
+ result.value &&
1453
+ result.value.__ets &&
1454
+ result.value.ets &&
1455
+ Array.isArray(result.value.ets.rows)
1456
+ ) {
1457
+ parsedObject = result.value;
1458
+ showStatus(els.parseStatus, "ok", result.note);
1459
+ els.jsonMapping.style.display = "none";
1460
+ els.etsMapping.style.display = "";
1461
+ els.haMapping.style.display = "none";
1462
+ showStatus(
1463
+ els.etsHint,
1464
+ "ok",
1465
+ "ETS import mode: mapping is fixed.\n- Topic: Address (e.g. 0/0/1)\n- Name: Description, fallback to Group name\nClick “Generate output” to create a zones JSON array.",
1466
+ );
1467
+ return;
1468
+ }
1469
+
1470
+ if (isHomeAssistantStatesExport(result.value)) {
1471
+ parsedObject = { __ha: true, ha: { kind: "states", states: extractHomeAssistantStates(result.value) } };
1472
+ showStatus(els.parseStatus, "ok", "Detected Home Assistant states export (JSON).");
1473
+ els.jsonMapping.style.display = "none";
1474
+ els.etsMapping.style.display = "none";
1475
+ els.haMapping.style.display = "";
1476
+ showStatus(
1477
+ els.haHint,
1478
+ "ok",
1479
+ "Home Assistant import mode.\n- Topic: entity_id\n- Name: attributes.friendly_name (fallback entity_id)\nUse the filters below, then click “Generate output”.",
1480
+ );
1481
+ return;
1482
+ }
1483
+
1484
+ if (isHomeAssistantEntityRegistryExport(result.value)) {
1485
+ parsedObject = { __ha: true, ha: { kind: "entity_registry", entities: extractHomeAssistantEntityRegistry(result.value) } };
1486
+ showStatus(els.parseStatus, "ok", "Detected Home Assistant entity registry export (core.entity_registry).");
1487
+ els.jsonMapping.style.display = "none";
1488
+ els.etsMapping.style.display = "none";
1489
+ els.haMapping.style.display = "";
1490
+ showStatus(
1491
+ els.haHint,
1492
+ "ok",
1493
+ "Home Assistant import mode (entity registry).\n- Topic: entity_id\n- Name: name/original_name (fallback entity_id)\nUse the filters below, then click “Generate output”.",
1494
+ );
1495
+ return;
1496
+ }
1497
+
1498
+ els.topicPath.disabled = false;
1499
+ els.valuePath.disabled = false;
1500
+ els.namePath.disabled = false;
1501
+
1502
+ if (!isPlainObject(result.value)) {
1503
+ parsedObject = result.value;
1504
+ showStatus(
1505
+ els.parseStatus,
1506
+ "warn",
1507
+ `${result.note} Note: the root is not an object. Paths will be limited.`,
1508
+ );
1509
+ } else {
1510
+ parsedObject = result.value;
1511
+ showStatus(els.parseStatus, "ok", result.note);
1512
+ }
1513
+
1514
+ const paths = Array.from(new Set(enumeratePaths(parsedObject))).filter(
1515
+ Boolean,
1516
+ );
1517
+ paths.sort((a, b) => a.localeCompare(b));
1518
+
1519
+ const defaultTopic = guessDefault(paths, [
1520
+ "topic",
1521
+ "knx.destination",
1522
+ "destination",
1523
+ ]);
1524
+ const defaultValue = guessDefault(paths, [
1525
+ "payload",
1526
+ "payloadsubtypevalue",
1527
+ "value",
1528
+ ]);
1529
+ const defaultName = guessDefault(paths, [
1530
+ "devicename",
1531
+ "gainfo.ganame",
1532
+ "name",
1533
+ "topic",
1534
+ ]);
1535
+
1536
+ setSelectOptions(els.topicPath, paths, defaultTopic);
1537
+ setSelectOptions(els.valuePath, paths, defaultValue);
1538
+ setSelectOptions(els.namePath, paths, defaultName);
1539
+ }
1540
+
1541
+ function generate() {
1542
+ hideStatus(els.genStatus);
1543
+ if (!parsedObject) {
1544
+ showStatus(els.genStatus, "err", "Parse a valid message first.");
1545
+ return;
1546
+ }
1547
+ const append = els.appendZones ? els.appendZones.checked === true : true;
1548
+
1549
+ if (
1550
+ parsedObject &&
1551
+ parsedObject.__ets &&
1552
+ parsedObject.ets &&
1553
+ Array.isArray(parsedObject.ets.rows)
1554
+ ) {
1555
+ const allRows = parsedObject.ets.rows;
1556
+ const addressPatterns = splitPatterns(
1557
+ els.etsAddressFilter ? els.etsAddressFilter.value : "",
1558
+ );
1559
+ const nameFilter = els.etsNameFilter ? els.etsNameFilter.value : "";
1560
+
1561
+ const leafRows = allRows.filter((r) => {
1562
+ const addr = String(r && r.address ? r.address : "").trim();
1563
+ if (!addr) return false;
1564
+ if (addr.includes("-")) return false;
1565
+ return /^\d+\/\d+\/\d+$/.test(addr);
1566
+ });
1567
+
1568
+ const booleanRows = leafRows.filter((r) =>
1569
+ isBooleanDatapointType(r.datapointType),
1570
+ );
1571
+ const filteredRows = booleanRows.filter((r) => {
1572
+ const addr = String(r && r.address ? r.address : "").trim();
1573
+ if (!matchesAnyGlob(addr, addressPatterns)) return false;
1574
+ if (!matchesNameFilter(r, nameFilter)) return false;
1575
+ return true;
1576
+ });
1577
+
1578
+ const zones = filteredRows.map((row) => {
1579
+ const address = String(row.address).trim();
1580
+ const topic = address;
1581
+ const nameCandidate =
1582
+ String(row.description || "").trim() ||
1583
+ String(row.groupName || "").trim();
1584
+ const name = nameCandidate || address;
1585
+ const idSafe = address
1586
+ .replace(/[^\w]+/g, "_")
1587
+ .replace(/^_+|_+$/g, "")
1588
+ .toLowerCase();
1589
+ const id = `ga_${idSafe || "unknown"}`;
1590
+ return {
1591
+ id,
1592
+ name,
1593
+ topic,
1594
+ type: "perimeter",
1595
+ entry: false,
1596
+ bypassable: true,
1597
+ chime: false,
1598
+ };
1599
+ });
1600
+
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
+ }
1619
+ const skippedGroups = allRows.length - leafRows.length;
1620
+ const skippedNonBoolean = leafRows.length - booleanRows.length;
1621
+ const skippedByFilters = booleanRows.length - filteredRows.length;
1622
+ showStatus(
1623
+ els.genStatus,
1624
+ zones.length ? "ok" : "warn",
1625
+ `${append ? "Appended" : "Replaced with"} ${zones.length} zones from ETS (skipped ${skippedGroups} group rows, ${skippedNonBoolean} non-boolean datapoints, ${skippedByFilters} filtered out).`,
1626
+ );
1627
+ return;
1628
+ }
1629
+
1630
+ if (parsedObject && parsedObject.__ha && parsedObject.ha && parsedObject.ha.kind === "states" && Array.isArray(parsedObject.ha.states)) {
1631
+ const states = parsedObject.ha.states;
1632
+ const entityPatterns = splitPatterns(els.haEntityFilter ? els.haEntityFilter.value : "");
1633
+ const nameFilter = els.haNameFilter ? els.haNameFilter.value : "";
1634
+ const domains = parseDomainList(els.haDomains ? els.haDomains.value : "");
1635
+ const defaultBooleanDomains = ["binary_sensor", "input_boolean", "switch"];
1636
+ const allowedDomains = domains.length ? domains : defaultBooleanDomains;
1637
+
1638
+ const eligible = states.filter((s) => s && typeof s === "object" && typeof s.entity_id === "string");
1639
+ const domainFiltered = eligible.filter((s) => allowedDomains.includes(getHaDomain(s.entity_id)));
1640
+ const filtered = domainFiltered.filter((s) => {
1641
+ const entityId = String(s.entity_id || "").trim();
1642
+ if (!matchesAnyGlob(entityId, entityPatterns)) return false;
1643
+ const row = { description: s.attributes && s.attributes.friendly_name ? s.attributes.friendly_name : "" , groupName: entityId };
1644
+ if (!matchesNameFilter(row, nameFilter)) return false;
1645
+ return true;
1646
+ });
1647
+
1648
+ const zones = filtered.map((s) => {
1649
+ const entityId = String(s.entity_id).trim();
1650
+ const friendly = s.attributes && s.attributes.friendly_name ? String(s.attributes.friendly_name).trim() : "";
1651
+ const name = friendly || entityId;
1652
+ const idSafe = entityId.replace(/[^\w]+/g, "_").replace(/^_+|_+$/g, "").toLowerCase();
1653
+ const id = `ha_${idSafe || "unknown"}`;
1654
+ return {
1655
+ id,
1656
+ name,
1657
+ topic: entityId,
1658
+ type: "perimeter",
1659
+ entry: false,
1660
+ bypassable: true,
1661
+ chime: false,
1662
+ };
1663
+ });
1664
+
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
+ }
1683
+ const skippedNonEntity = states.length - eligible.length;
1684
+ const skippedDomain = eligible.length - domainFiltered.length;
1685
+ const skippedByFilters = domainFiltered.length - filtered.length;
1686
+ showStatus(
1687
+ els.genStatus,
1688
+ zones.length ? "ok" : "warn",
1689
+ `Generated ${zones.length} zones from HA (skipped ${skippedNonEntity} invalid rows, ${skippedDomain} domain filtered, ${skippedByFilters} filtered out).`,
1690
+ );
1691
+ return;
1692
+ }
1693
+
1694
+ if (parsedObject && parsedObject.__ha && parsedObject.ha && parsedObject.ha.kind === "entity_registry" && Array.isArray(parsedObject.ha.entities)) {
1695
+ const entities = parsedObject.ha.entities;
1696
+ const includeDisabled = Boolean(els.haIncludeDisabled && els.haIncludeDisabled.checked);
1697
+ const entityPatterns = splitPatterns(els.haEntityFilter ? els.haEntityFilter.value : "");
1698
+ const nameFilter = els.haNameFilter ? els.haNameFilter.value : "";
1699
+ const domains = parseDomainList(els.haDomains ? els.haDomains.value : "");
1700
+ const defaultBooleanDomains = ["binary_sensor", "input_boolean", "switch"];
1701
+ const allowedDomains = domains.length ? domains : defaultBooleanDomains;
1702
+
1703
+ const eligible = entities.filter((e) => e && typeof e === "object" && typeof e.entity_id === "string");
1704
+ const enabledOnly = eligible.filter((e) => includeDisabled || !e.disabled_by);
1705
+ const domainFiltered = enabledOnly.filter((e) => allowedDomains.includes(getHaDomain(e.entity_id)));
1706
+ const filtered = domainFiltered.filter((e) => {
1707
+ const entityId = String(e.entity_id || "").trim();
1708
+ if (!matchesAnyGlob(entityId, entityPatterns)) return false;
1709
+ const displayName = String(e.name || "").trim() || String(e.original_name || "").trim() || entityId;
1710
+ const row = { description: displayName, groupName: entityId };
1711
+ if (!matchesNameFilter(row, nameFilter)) return false;
1712
+ return true;
1713
+ });
1714
+
1715
+ const zones = filtered.map((e) => {
1716
+ const entityId = String(e.entity_id).trim();
1717
+ const displayName = String(e.name || "").trim() || String(e.original_name || "").trim() || entityId;
1718
+ const idSafe = entityId.replace(/[^\w]+/g, "_").replace(/^_+|_+$/g, "").toLowerCase();
1719
+ const id = `ha_${idSafe || "unknown"}`;
1720
+ return {
1721
+ id,
1722
+ name: displayName,
1723
+ topic: entityId,
1724
+ type: "perimeter",
1725
+ entry: false,
1726
+ bypassable: true,
1727
+ chime: false,
1728
+ };
1729
+ });
1730
+
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
+ }
1749
+ const skippedNonEntity = entities.length - eligible.length;
1750
+ const skippedDisabled = eligible.length - enabledOnly.length;
1751
+ const skippedDomain = enabledOnly.length - domainFiltered.length;
1752
+ const skippedByFilters = domainFiltered.length - filtered.length;
1753
+ showStatus(
1754
+ els.genStatus,
1755
+ zones.length ? "ok" : "warn",
1756
+ `Generated ${zones.length} zones from HA registry (skipped ${skippedNonEntity} invalid rows, ${skippedDisabled} disabled, ${skippedDomain} domain filtered, ${skippedByFilters} filtered out).`,
1757
+ );
1758
+ return;
1759
+ }
1760
+
1761
+ const topicPath = els.topicPath.value;
1762
+ const valuePath = els.valuePath.value;
1763
+ const namePath = els.namePath.value;
1764
+
1765
+ const topicValue = getByPath(parsedObject, topicPath);
1766
+ const valueValue = getByPath(parsedObject, valuePath);
1767
+ const nameValue = getByPath(parsedObject, namePath);
1768
+
1769
+ if (topicValue === undefined) {
1770
+ showStatus(
1771
+ els.genStatus,
1772
+ "err",
1773
+ `Topic path "${topicPath}" is undefined in the input message.`,
1774
+ );
1775
+ return;
1776
+ }
1777
+
1778
+ const normalized = {
1779
+ ...parsedObject,
1780
+ topic: String(topicValue),
1781
+ payload: valueValue,
1782
+ };
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
+ }
1792
+
1793
+ lastGenerated = { normalized, zone };
1794
+ showStatus(
1795
+ els.genStatus,
1796
+ "ok",
1797
+ `${append ? "Appended" : "Replaced with"} 1 zone using topic="${toOneLine(topicValue)}" and value="${valuePath}".`,
1798
+ );
1799
+ }
1800
+
1801
+ els.btnParse.addEventListener("click", () => parseAndPopulate());
1802
+ els.btnGenerate.addEventListener("click", () => generate());
1803
+ els.btnClear.addEventListener("click", () => {
1804
+ els.input.value = "";
1805
+ parsedObject = null;
1806
+ lastGenerated = null;
1807
+ hideStatus(els.parseStatus);
1808
+ hideStatus(els.genStatus);
1809
+ hideStatus(els.etsHint);
1810
+ hideStatus(els.haHint);
1811
+ if (els.wizardStep2) els.wizardStep2.classList.add("disabled");
1812
+ els.jsonMapping.style.display = "";
1813
+ els.etsMapping.style.display = "none";
1814
+ if (els.etsAddressFilter) els.etsAddressFilter.value = "";
1815
+ if (els.etsNameFilter) els.etsNameFilter.value = "";
1816
+ els.haMapping.style.display = "none";
1817
+ if (els.haEntityFilter) els.haEntityFilter.value = "";
1818
+ if (els.haNameFilter) els.haNameFilter.value = "";
1819
+ if (els.haDomains) els.haDomains.value = "";
1820
+ if (els.haIncludeDisabled) els.haIncludeDisabled.checked = false;
1821
+ els.topicPath.innerHTML = "";
1822
+ els.valuePath.innerHTML = "";
1823
+ els.namePath.innerHTML = "";
1824
+ els.topicPath.disabled = false;
1825
+ els.valuePath.disabled = false;
1826
+ els.namePath.disabled = false;
1827
+ });
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;
1836
+ parseAndPopulate();
1837
+ }
1838
+
1839
+ els.btnLoadSample.addEventListener("click", () => loadSample(els.sampleKind ? els.sampleKind.value : ""));
1840
+
1841
+ els.btnZoneAdd.addEventListener("click", () => addZone(null));
587
1842
 
588
- els.btnCopyZone.addEventListener("click", async () => {
589
- const text = String(els.zoneOutput.value || "").trim();
590
- if (!text) return;
591
- const ok = await copyToClipboard(text);
592
- showStatus(els.genStatus, ok ? "ok" : "warn", ok ? "Zone JSON copied." : "Copy failed.");
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();
1895
+ });
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
+ }
1925
+ });
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.");
1933
+ });
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);
1944
+ });
1945
+
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();
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);
593
2009
  });
594
- </script>
595
- </body>
2010
+ }
2011
+
2012
+ // Initial render.
2013
+ renderZones();
2014
+ setConnectionState(canTalkToEditor() ? "connecting" : "disconnected");
2015
+ </script>
2016
+ </body>
2017
+
596
2018
  </html>