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

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.
@@ -1,241 +1,309 @@
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";
30
+ --bg: #f6f8ff;
31
+ --panel: #ffffff;
32
+ --text: #111827;
33
+ --muted: #5b647a;
34
+ --border: #d8deef;
35
+ --accent: #2563eb;
23
36
  }
37
+ }
24
38
 
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
- }
34
- }
39
+ body {
40
+ margin: 0;
41
+ background: var(--bg);
42
+ color: var(--text);
43
+ font-family: var(--sans);
44
+ line-height: 1.4;
45
+ }
35
46
 
36
- body {
37
- margin: 0;
38
- background: var(--bg);
39
- color: var(--text);
40
- font-family: var(--sans);
41
- line-height: 1.4;
42
- }
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
+ }
43
54
 
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
- }
55
+ header h1 {
56
+ font-size: 18px;
57
+ margin: 0 0 6px 0;
58
+ }
49
59
 
50
- header h1 {
51
- font-size: 18px;
52
- margin: 0 0 6px 0;
53
- }
60
+ header p {
61
+ margin: 0;
62
+ color: var(--muted);
63
+ font-size: 13px;
64
+ }
54
65
 
55
- header p {
56
- margin: 0;
57
- color: var(--muted);
58
- font-size: 13px;
59
- }
66
+ main {
67
+ max-width: 1100px;
68
+ margin: 0 auto;
69
+ padding: 16px;
70
+ display: grid;
71
+ gap: 12px;
72
+ }
60
73
 
61
- main {
62
- max-width: 1100px;
63
- margin: 0 auto;
64
- padding: 16px;
65
- display: grid;
66
- gap: 12px;
67
- }
74
+ .grid {
75
+ display: grid;
76
+ grid-template-columns: 1.2fr 0.8fr;
77
+ gap: 12px;
78
+ }
68
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
+ }
131
146
 
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
- }
147
+ button.primary {
148
+ border-color: rgba(110, 168, 254, 0.5);
149
+ background: rgba(110, 168, 254, 0.22);
150
+ }
141
151
 
142
- button.primary {
143
- border-color: rgba(110, 168, 254, 0.5);
144
- background: rgba(110, 168, 254, 0.22);
145
- }
152
+ button:disabled {
153
+ opacity: 0.6;
154
+ cursor: not-allowed;
155
+ }
146
156
 
147
- button:disabled {
148
- opacity: 0.6;
149
- cursor: not-allowed;
150
- }
157
+ .hint {
158
+ font-size: 12px;
159
+ color: var(--muted);
160
+ margin: 6px 0 0 0;
161
+ }
151
162
 
152
- .hint {
153
- font-size: 12px;
154
- color: var(--muted);
155
- margin: 6px 0 0 0;
156
- }
163
+ .status {
164
+ font-family: var(--mono);
165
+ font-size: 12px;
166
+ padding: 8px 10px;
167
+ border-radius: 8px;
168
+ border: 1px solid var(--border);
169
+ margin-top: 10px;
170
+ }
157
171
 
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
- }
172
+ .status.ok {
173
+ border-color: rgba(47, 191, 113, 0.55);
174
+ color: var(--ok);
175
+ }
166
176
 
167
- .status.ok {
168
- border-color: rgba(47, 191, 113, 0.55);
169
- color: var(--ok);
170
- }
177
+ .status.warn {
178
+ border-color: rgba(255, 204, 102, 0.55);
179
+ color: var(--warn);
180
+ }
171
181
 
172
- .status.warn {
173
- border-color: rgba(255, 204, 102, 0.55);
174
- color: var(--warn);
175
- }
182
+ .status.err {
183
+ border-color: rgba(255, 107, 107, 0.55);
184
+ color: var(--danger);
185
+ }
176
186
 
177
- .status.err {
178
- border-color: rgba(255, 107, 107, 0.55);
179
- color: var(--danger);
180
- }
187
+ pre {
188
+ margin: 0;
189
+ white-space: pre-wrap;
190
+ word-break: break-word;
191
+ font-family: var(--mono);
192
+ font-size: 12px;
193
+ }
181
194
 
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
- }
195
+ .mono {
196
+ font-family: var(--mono);
197
+ }
189
198
 
190
- .mono {
191
- font-family: var(--mono);
192
- }
199
+ .two {
200
+ display: grid;
201
+ grid-template-columns: 1fr 1fr;
202
+ gap: 12px;
203
+ }
193
204
 
205
+ @media (max-width: 980px) {
194
206
  .two {
195
- display: grid;
196
- grid-template-columns: 1fr 1fr;
197
- gap: 12px;
207
+ grid-template-columns: 1fr;
198
208
  }
209
+ }
210
+ </style>
211
+ </head>
212
+
213
+ <body>
214
+ <header>
215
+ <h1>Alarm JSON Mapper</h1>
216
+ <p>
217
+ Paste a sample incoming message (e.g. KNX Ultimate) and map its fields
218
+ to what the Alarm node needs (topic/value), or paste an ETS Group
219
+ Addresses export (TSV) to generate zones in batch.
220
+ </p>
221
+ </header>
222
+
223
+ <main>
224
+ <div class="grid">
225
+ <section class="card">
226
+ <h2>1) Input message</h2>
227
+ <textarea id="input" spellcheck="false"
228
+ placeholder="Paste JSON here (strict JSON).&#10;&#10;Tip: this tool also accepts JS-style objects with unquoted keys and // comments."></textarea>
229
+ <div class="buttons">
230
+ <button id="btn-load-knx">Load JSON sample</button>
231
+ <button id="btn-load-ets">Load ETS sample</button>
232
+ <button id="btn-load-ha">Load HA sample</button>
233
+ <button id="btn-load-ha-registry">Load HA registry sample</button>
234
+ <button id="btn-clear">Clear</button>
235
+ <button class="primary" id="btn-parse">Parse</button>
236
+ </div>
237
+ <p class="hint">
238
+ If your input is not strict JSON (comments, unquoted keys), click
239
+ Parse anyway: the tool will try to normalize it.
240
+ </p>
241
+ <div id="parse-status" class="status warn" style="display: none"></div>
242
+ </section>
199
243
 
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.
213
- </p>
214
- </header>
215
-
216
- <main>
217
- <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>
225
- <div class="buttons">
226
- <button class="primary" id="btn-parse">Parse</button>
227
- <button id="btn-load-knx">Load KNX sample</button>
228
- <button id="btn-clear">Clear</button>
244
+ <section class="card">
245
+ <h2>2) Field mapping</h2>
246
+ <div id="ets-hint" class="status ok" style="display: none"></div>
247
+ <div id="ha-hint" class="status ok" style="display: none"></div>
248
+ <div id="ets-mapping" style="display: none">
249
+ <div class="row">
250
+ <label for="etsAddressFilter">ETS address filter</label>
251
+ <input
252
+ type="text"
253
+ id="etsAddressFilter"
254
+ placeholder="Examples: 0/0/* • 0/1/?? • */7/* (comma or newline separated)"
255
+ />
256
+ </div>
257
+ <div class="row">
258
+ <label for="etsNameFilter">ETS name filter</label>
259
+ <input
260
+ type="text"
261
+ id="etsNameFilter"
262
+ placeholder='Optional. Examples: Front door • *garage* (supports * and ?)'
263
+ />
229
264
  </div>
230
265
  <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.
266
+ Address patterns are matched against the ETS <span class="mono">Address</span> column (e.g.
267
+ <span class="mono">0/0/1</span>). Name filter is matched against <span class="mono">Description</span>,
268
+ fallback to <span class="mono">Group name</span>.
233
269
  </p>
234
- <div id="parse-status" class="status warn" style="display: none"></div>
235
- </section>
236
-
237
- <section class="card">
238
- <h2>2) Field mapping</h2>
270
+ </div>
271
+ <div id="ha-mapping" style="display: none">
272
+ <div class="row">
273
+ <label for="haEntityFilter">HA entity filter</label>
274
+ <input
275
+ type="text"
276
+ id="haEntityFilter"
277
+ placeholder="Examples: binary_sensor.*door* • *_pir • switch.* (comma or newline separated)"
278
+ />
279
+ </div>
280
+ <div class="row">
281
+ <label for="haNameFilter">HA name filter</label>
282
+ <input
283
+ type="text"
284
+ id="haNameFilter"
285
+ placeholder='Optional. Examples: Front door • *garage* (supports * and ?)'
286
+ />
287
+ </div>
288
+ <div class="row">
289
+ <label for="haDomains">HA domains</label>
290
+ <input
291
+ type="text"
292
+ id="haDomains"
293
+ placeholder="Optional. Example: binary_sensor,input_boolean,switch (default: boolean-like domains)"
294
+ />
295
+ </div>
296
+ <div class="row">
297
+ <label for="haIncludeDisabled">Include disabled</label>
298
+ <input type="checkbox" id="haIncludeDisabled" style="width:auto; margin-top:7px;" />
299
+ </div>
300
+ <p class="hint">
301
+ Paste Home Assistant JSON (e.g. the array returned by <span class="mono">/api/states</span>). The zone topic
302
+ will be the <span class="mono">entity_id</span>. Names use <span class="mono">attributes.friendly_name</span>
303
+ if present.
304
+ </p>
305
+ </div>
306
+ <div id="json-mapping">
239
307
  <div class="row">
240
308
  <label for="topicPath">Topic path</label>
241
309
  <select id="topicPath"></select>
@@ -248,51 +316,66 @@
248
316
  <label for="namePath">Zone name path</label>
249
317
  <select id="namePath"></select>
250
318
  </div>
251
- <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>).
254
- </p>
255
- <div class="buttons">
256
- <button class="primary" id="btn-generate">Generate output</button>
257
- </div>
258
- <div id="gen-status" class="status warn" style="display: none"></div>
259
- </section>
260
- </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
319
  </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
320
  <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).
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>).
275
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>
276
330
  </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
- };
331
+ </div>
294
332
 
295
- const KNX_SAMPLE = `{
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>
337
+ </div>
338
+ <textarea id="zone-output" spellcheck="false"
339
+ placeholder="Click “Generate output” to create a zone template, then edit it here if needed."></textarea>
340
+ <p class="hint">
341
+ This creates a single zone object using the mapped topic and optional
342
+ name. Paste it into the Zones field (legacy: one JSON object per line,
343
+ or formatted: JSON array).
344
+ </p>
345
+ </section>
346
+ </main>
347
+
348
+ <script>
349
+ const els = {
350
+ input: document.getElementById("input"),
351
+ btnParse: document.getElementById("btn-parse"),
352
+ btnLoadKnx: document.getElementById("btn-load-knx"),
353
+ btnLoadEts: document.getElementById("btn-load-ets"),
354
+ btnLoadHa: document.getElementById("btn-load-ha"),
355
+ btnLoadHaRegistry: document.getElementById("btn-load-ha-registry"),
356
+ btnClear: document.getElementById("btn-clear"),
357
+ parseStatus: document.getElementById("parse-status"),
358
+ genStatus: document.getElementById("gen-status"),
359
+ topicPath: document.getElementById("topicPath"),
360
+ valuePath: document.getElementById("valuePath"),
361
+ namePath: document.getElementById("namePath"),
362
+ etsHint: document.getElementById("ets-hint"),
363
+ haHint: document.getElementById("ha-hint"),
364
+ etsMapping: document.getElementById("ets-mapping"),
365
+ etsAddressFilter: document.getElementById("etsAddressFilter"),
366
+ etsNameFilter: document.getElementById("etsNameFilter"),
367
+ haMapping: document.getElementById("ha-mapping"),
368
+ haEntityFilter: document.getElementById("haEntityFilter"),
369
+ haNameFilter: document.getElementById("haNameFilter"),
370
+ haDomains: document.getElementById("haDomains"),
371
+ haIncludeDisabled: document.getElementById("haIncludeDisabled"),
372
+ jsonMapping: document.getElementById("json-mapping"),
373
+ btnGenerate: document.getElementById("btn-generate"),
374
+ zoneOutput: document.getElementById("zone-output"),
375
+ btnCopyZone: document.getElementById("btn-copy-zone"),
376
+ };
377
+
378
+ const KNX_SAMPLE = `{
296
379
  topic: "0/1/2",
297
380
  payload: false,
298
381
  previouspayload: true,
@@ -312,285 +395,855 @@
312
395
  dpt: "1.001",
313
396
  dptdesc: "Humidity",
314
397
  source: "15.15.22",
315
- destination: "0/1/2",
316
- rawValue: {
317
- 0: "0x0"
398
+ destination: "0/1/2",
399
+ rawValue: {
400
+ 0: "0x0"
401
+ }
402
+ }
403
+ }`;
404
+
405
+ const ETS_SAMPLE = `"Group name"\t"Address"\t"Central"\t"Unfiltered"\t"Description"\t"DatapointType"\t"Security"
406
+ "Lights"\t"0/-/-"\t""\t""\t""\t""\t"Auto"
407
+ "Living room"\t"0/0/-"\t""\t""\t""\t""\t"Auto"
408
+ "Front door contact"\t"0/0/1"\t""\t""\t""\t"DPST-1-1"\t"Auto"
409
+ "Motion sensor"\t"0/0/2"\t""\t""\t""\t"DPT-1"\t"Auto"
410
+ "Dimming value"\t"0/0/3"\t""\t""\t""\t"DPST-5-1"\t"Auto"`;
411
+
412
+ const HA_SAMPLE = `[
413
+ {
414
+ "entity_id": "binary_sensor.front_door",
415
+ "state": "off",
416
+ "attributes": {
417
+ "friendly_name": "Front door"
418
+ }
419
+ },
420
+ {
421
+ "entity_id": "binary_sensor.living_pir",
422
+ "state": "on",
423
+ "attributes": {
424
+ "friendly_name": "Living PIR"
425
+ }
426
+ },
427
+ {
428
+ "entity_id": "switch.garden_lights",
429
+ "state": "off",
430
+ "attributes": {
431
+ "friendly_name": "Garden lights"
318
432
  }
319
433
  }
434
+ ]`;
435
+
436
+ const HA_REGISTRY_SAMPLE = `{
437
+ "data": {
438
+ "entities": [
439
+ {
440
+ "entity_id": "binary_sensor.front_door",
441
+ "name": "Front door",
442
+ "original_name": "Front door contact",
443
+ "disabled_by": null
444
+ },
445
+ {
446
+ "entity_id": "binary_sensor.living_pir",
447
+ "name": "Living PIR",
448
+ "original_name": "Living PIR",
449
+ "disabled_by": null
450
+ },
451
+ {
452
+ "entity_id": "switch.garden_lights",
453
+ "name": "Garden lights",
454
+ "original_name": "Garden lights",
455
+ "disabled_by": "user"
456
+ }
457
+ ]
458
+ },
459
+ "key": "core.entity_registry",
460
+ "version": 1
320
461
  }`;
321
462
 
322
- let parsedObject = null;
323
- let lastGenerated = null;
463
+ let parsedObject = null;
464
+ let lastGenerated = null;
324
465
 
325
- function showStatus(el, kind, message) {
326
- el.style.display = "";
327
- el.className = `status ${kind}`;
328
- el.textContent = message;
329
- }
466
+ function showStatus(el, kind, message) {
467
+ el.style.display = "";
468
+ el.className = `status ${kind}`;
469
+ el.textContent = message;
470
+ }
330
471
 
331
- function hideStatus(el) {
332
- el.style.display = "none";
333
- el.textContent = "";
334
- }
472
+ function hideStatus(el) {
473
+ el.style.display = "none";
474
+ el.textContent = "";
475
+ }
335
476
 
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");
477
+ function normalizeLenientJson(input) {
478
+ let text = String(input || "");
479
+ text = text.replace(/\r\n/g, "\n");
480
+ text = text.replace(/\/\*[\s\S]*?\*\//g, "");
481
+ text = text.replace(/(^|[^:])\/\/.*$/gm, "$1");
341
482
 
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');
483
+ text = text.replace(/([{,]\s*)([A-Za-z_$][\w$]*)(\s*:)/g, '$1"$2"$3');
484
+ text = text.replace(/([{,]\s*)(\d+)(\s*:)/g, '$1"$2"$3');
344
485
 
345
- text = text.replace(/:\s*0x([0-9a-fA-F]+)\b/g, ': "0x$1"');
486
+ text = text.replace(/:\s*0x([0-9a-fA-F]+)\b/g, ': "0x$1"');
346
487
 
347
- return text;
348
- }
488
+ return text;
489
+ }
349
490
 
350
- function parseInput(text) {
351
- const raw = String(text || "").trim();
352
- if (!raw) return { ok: false, error: "Empty input." };
491
+ function parseInput(text) {
492
+ const raw = String(text || "").trim();
493
+ if (!raw) return { ok: false, error: "Empty input." };
353
494
 
495
+ try {
496
+ return {
497
+ ok: true,
498
+ value: JSON.parse(raw),
499
+ note: "Parsed as strict JSON.",
500
+ };
501
+ } catch (err1) {
354
502
  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
- };
503
+ const normalized = normalizeLenientJson(raw);
504
+ const value = JSON.parse(normalized);
505
+ return {
506
+ ok: true,
507
+ value,
508
+ note: "Parsed after normalizing (removed comments, quoted keys).",
509
+ };
510
+ } catch (err2) {
511
+ const ets = parseEtsExport(raw);
512
+ if (ets.ok) {
513
+ return { ok: true, value: { __ets: true, ets }, note: ets.note };
368
514
  }
515
+ return {
516
+ ok: false,
517
+ error:
518
+ "Unable to parse. Please paste strict JSON, or a JS-style object with only // comments and unquoted keys.",
519
+ details: String(err2 && err2.message ? err2.message : err2),
520
+ };
369
521
  }
370
522
  }
523
+ }
371
524
 
372
- function isPlainObject(value) {
373
- return Boolean(value) && typeof value === "object" && !Array.isArray(value);
374
- }
375
-
376
- function enumeratePaths(value, basePath = "", out = []) {
377
- if (value === null || value === undefined) {
378
- out.push(basePath || "(root)");
379
- return out;
380
- }
381
- if (typeof value !== "object") {
382
- out.push(basePath || "(root)");
383
- return out;
384
- }
525
+ function normalizeHeaderKey(value) {
526
+ return String(value || "")
527
+ .trim()
528
+ .toLowerCase()
529
+ .replace(/^\ufeff/, "")
530
+ .replace(/[^a-z0-9]/g, "");
531
+ }
385
532
 
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);
533
+ function parseDelimitedLine(line, delimiter) {
534
+ const out = [];
535
+ let cur = "";
536
+ let inQuotes = false;
537
+ for (let i = 0; i < line.length; i += 1) {
538
+ const ch = line[i];
539
+ if (inQuotes) {
540
+ if (ch === '"') {
541
+ const next = line[i + 1];
542
+ if (next === '"') {
543
+ cur += '"';
544
+ i += 1;
545
+ } else {
546
+ inQuotes = false;
547
+ }
548
+ } else {
549
+ cur += ch;
550
+ }
551
+ } else {
552
+ if (ch === '"') {
553
+ inQuotes = true;
554
+ } else if (ch === delimiter) {
555
+ out.push(cur.trim());
556
+ cur = "";
557
+ } else {
558
+ cur += ch;
391
559
  }
392
- return out;
393
560
  }
561
+ }
562
+ out.push(cur.trim());
563
+ return out;
564
+ }
565
+
566
+ function detectDelimiter(headerLine) {
567
+ const line = String(headerLine || "");
568
+ if (line.includes("\t")) return "\t";
569
+ if (line.includes(";")) return ";";
570
+ if (line.includes(",")) return ",";
571
+ return "\t";
572
+ }
573
+
574
+ function isBooleanDatapointType(value) {
575
+ const t = String(value || "")
576
+ .trim()
577
+ .toLowerCase();
578
+ if (!t) return false;
579
+ return (
580
+ t === "dpt-1" ||
581
+ t.startsWith("dpt-1-") ||
582
+ t.startsWith("dpst-1-") ||
583
+ /^1\.\d+/.test(t)
584
+ );
585
+ }
586
+
587
+ function parseEtsExport(text) {
588
+ const raw = String(text || "")
589
+ .replace(/\r\n/g, "\n")
590
+ .replace(/\r/g, "\n")
591
+ .trim();
592
+
593
+ const lines = raw
594
+ .split("\n")
595
+ .map((l) => l.trim())
596
+ .filter((l) => l.length > 0);
597
+
598
+ if (lines.length < 2) return { ok: false };
599
+
600
+ const headerLine = lines[0];
601
+ const delimiter = detectDelimiter(headerLine);
602
+ const headers = parseDelimitedLine(headerLine, delimiter);
603
+ const keyByIndex = headers.map(normalizeHeaderKey);
604
+
605
+ const idxGroupName = keyByIndex.indexOf("groupname");
606
+ const idxAddress = keyByIndex.indexOf("address");
607
+ const idxDescription = keyByIndex.indexOf("description");
608
+ const idxDatapointType = keyByIndex.indexOf("datapointtype");
609
+
610
+ if (idxGroupName < 0 || idxAddress < 0) return { ok: false };
611
+
612
+ const rows = [];
613
+ for (let i = 1; i < lines.length; i += 1) {
614
+ const cols = parseDelimitedLine(lines[i], delimiter);
615
+ const groupName = cols[idxGroupName] || "";
616
+ const address = cols[idxAddress] || "";
617
+ const description =
618
+ idxDescription >= 0 ? cols[idxDescription] || "" : "";
619
+ const datapointType =
620
+ idxDatapointType >= 0 ? cols[idxDatapointType] || "" : "";
621
+
622
+ rows.push({ groupName, address, description, datapointType });
623
+ }
624
+
625
+ return {
626
+ ok: true,
627
+ note: "Detected ETS Group Addresses export (TSV).",
628
+ rows,
629
+ };
630
+ }
631
+
632
+ function escapeRegExp(value) {
633
+ return String(value || "").replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
634
+ }
635
+
636
+ function globToRegExp(glob) {
637
+ const raw = String(glob || "").trim();
638
+ if (!raw) return null;
639
+ let pattern = "";
640
+ for (let i = 0; i < raw.length; i += 1) {
641
+ const ch = raw[i];
642
+ if (ch === "*") pattern += ".*";
643
+ else if (ch === "?") pattern += ".";
644
+ else pattern += escapeRegExp(ch);
645
+ }
646
+ try {
647
+ return new RegExp(`^${pattern}$`, "i");
648
+ } catch (_err) {
649
+ return null;
650
+ }
651
+ }
652
+
653
+ function splitPatterns(text) {
654
+ return String(text || "")
655
+ .split(/[\n,]+/g)
656
+ .map((p) => p.trim())
657
+ .filter((p) => p.length > 0);
658
+ }
659
+
660
+ function matchesAnyGlob(value, patterns) {
661
+ if (!patterns || patterns.length === 0) return true;
662
+ const target = String(value || "").trim();
663
+ if (!target) return false;
664
+ for (const p of patterns) {
665
+ const re = globToRegExp(p);
666
+ if (re && re.test(target)) return true;
667
+ }
668
+ return false;
669
+ }
670
+
671
+ function matchesNameFilter(row, nameFilterRaw) {
672
+ const filter = String(nameFilterRaw || "").trim();
673
+ if (!filter) return true;
674
+ const name = String(row && (row.description || row.groupName) ? row.description || row.groupName : "")
675
+ .trim()
676
+ .toLowerCase();
677
+ if (!name) return false;
678
+ if (filter.includes("*") || filter.includes("?")) {
679
+ const re = globToRegExp(filter);
680
+ return Boolean(re && re.test(name));
681
+ }
682
+ return name.includes(filter.toLowerCase());
683
+ }
684
+
685
+ function extractHomeAssistantStates(value) {
686
+ if (Array.isArray(value)) return value;
687
+ if (value && typeof value === "object") {
688
+ if (Array.isArray(value.result)) return value.result;
689
+ if (Array.isArray(value.states)) return value.states;
690
+ if (Array.isArray(value.entities)) return value.entities;
691
+ }
692
+ return null;
693
+ }
694
+
695
+ function isHomeAssistantStatesExport(value) {
696
+ const states = extractHomeAssistantStates(value);
697
+ if (!states || states.length === 0) return false;
698
+ const sample = states.slice(0, 10);
699
+ return sample.some((s) => s && typeof s === "object" && typeof s.entity_id === "string");
700
+ }
701
+
702
+ function extractHomeAssistantEntityRegistry(value) {
703
+ if (!value || typeof value !== "object") return null;
704
+ if (Array.isArray(value.entities)) return value.entities;
705
+ if (value.data && Array.isArray(value.data.entities)) return value.data.entities;
706
+ if (value.result && value.result.data && Array.isArray(value.result.data.entities)) return value.result.data.entities;
707
+ return null;
708
+ }
709
+
710
+ function isHomeAssistantEntityRegistryExport(value) {
711
+ const entities = extractHomeAssistantEntityRegistry(value);
712
+ if (!entities || entities.length === 0) return false;
713
+ const sample = entities.slice(0, 10);
714
+ return sample.some((e) => e && typeof e === "object" && typeof e.entity_id === "string");
715
+ }
716
+
717
+ function getHaDomain(entityId) {
718
+ const id = String(entityId || "");
719
+ const idx = id.indexOf(".");
720
+ if (idx <= 0) return "";
721
+ return id.slice(0, idx).toLowerCase();
722
+ }
723
+
724
+ function parseDomainList(input) {
725
+ return String(input || "")
726
+ .split(/[\s,]+/g)
727
+ .map((d) => d.trim().toLowerCase())
728
+ .filter((d) => d.length > 0);
729
+ }
730
+
731
+ function isPlainObject(value) {
732
+ return (
733
+ Boolean(value) && typeof value === "object" && !Array.isArray(value)
734
+ );
735
+ }
394
736
 
737
+ function enumeratePaths(value, basePath = "", out = []) {
738
+ if (value === null || value === undefined) {
739
+ out.push(basePath || "(root)");
740
+ return out;
741
+ }
742
+ if (typeof value !== "object") {
395
743
  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
744
  return out;
402
745
  }
403
746
 
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];
747
+ if (Array.isArray(value)) {
748
+ out.push(basePath || "(root)");
749
+ const limit = Math.min(20, value.length);
750
+ for (let i = 0; i < limit; i += 1) {
751
+ enumeratePaths(value[i], `${basePath}[${i}]`, out);
423
752
  }
424
- return cur;
753
+ return out;
425
754
  }
426
755
 
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
- }
756
+ out.push(basePath || "(root)");
757
+ const keys = Object.keys(value).slice(0, 200);
758
+ for (const key of keys) {
759
+ const next = basePath ? `${basePath}.${key}` : key;
760
+ enumeratePaths(value[key], next, out);
435
761
  }
762
+ return out;
763
+ }
436
764
 
437
- function guessDefault(paths, candidates) {
438
- for (const c of candidates) {
439
- if (paths.includes(c)) return c;
440
- }
441
- return paths.includes("topic") ? "topic" : paths[0] || "(root)";
765
+ function getByPath(obj, path) {
766
+ if (!path || path === "(root)") return obj;
767
+ const parts = [];
768
+ String(path)
769
+ .split(".")
770
+ .forEach((segment) => {
771
+ const m = segment.match(/^([^[\]]+)(\[(\d+)\])?$/);
772
+ if (!m) {
773
+ parts.push(segment);
774
+ return;
775
+ }
776
+ parts.push(m[1]);
777
+ if (m[2]) parts.push(Number(m[3]));
778
+ });
779
+
780
+ let cur = obj;
781
+ for (const part of parts) {
782
+ if (cur === null || cur === undefined) return undefined;
783
+ cur = cur[part];
442
784
  }
785
+ return cur;
786
+ }
443
787
 
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);
451
- }
452
- select.value = selected;
788
+ function toOneLine(value) {
789
+ if (value === undefined) return "undefined";
790
+ if (typeof value === "string") return value;
791
+ try {
792
+ return JSON.stringify(value);
793
+ } catch (err) {
794
+ return String(value);
453
795
  }
796
+ }
454
797
 
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";
798
+ function guessDefault(paths, candidates) {
799
+ for (const c of candidates) {
800
+ if (paths.includes(c)) return c;
801
+ }
802
+ return paths.includes("topic") ? "topic" : paths[0] || "(root)";
803
+ }
462
804
 
463
- return {
464
- id,
465
- name,
466
- topic,
467
- type: "perimeter",
468
- entry: false,
469
- bypassable: true,
470
- chime: false,
471
- };
805
+ function setSelectOptions(select, paths, selected) {
806
+ select.innerHTML = "";
807
+ for (const p of paths) {
808
+ const opt = document.createElement("option");
809
+ opt.value = p;
810
+ opt.textContent = p;
811
+ select.appendChild(opt);
472
812
  }
813
+ select.value = selected;
814
+ }
473
815
 
474
- async function copyToClipboard(text) {
475
- try {
476
- await navigator.clipboard.writeText(text);
477
- return true;
478
- } catch (err) {
479
- return false;
480
- }
816
+ function buildZoneTemplate(normalizedMsg, zoneNameValue) {
817
+ const topic =
818
+ normalizedMsg && normalizedMsg.topic
819
+ ? String(normalizedMsg.topic)
820
+ : "";
821
+ const name =
822
+ zoneNameValue !== undefined &&
823
+ zoneNameValue !== null &&
824
+ String(zoneNameValue).trim().length > 0
825
+ ? String(zoneNameValue).trim()
826
+ : topic || "Zone";
827
+ const id = topic
828
+ ? topic
829
+ .replace(/[^\w]+/g, "_")
830
+ .replace(/^_+|_+$/g, "")
831
+ .toLowerCase()
832
+ : "zone1";
833
+
834
+ return {
835
+ id,
836
+ name,
837
+ topic,
838
+ type: "perimeter",
839
+ entry: false,
840
+ bypassable: true,
841
+ chime: false,
842
+ };
843
+ }
844
+
845
+ async function copyToClipboard(text) {
846
+ try {
847
+ await navigator.clipboard.writeText(text);
848
+ return true;
849
+ } catch (err) {
850
+ return false;
481
851
  }
852
+ }
482
853
 
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);
854
+ function renderJson(el, obj) {
855
+ el.style.display = "";
856
+ el.className = "status ok";
857
+ el.innerHTML = "";
858
+ const pre = document.createElement("pre");
859
+ pre.textContent = JSON.stringify(obj, null, 2);
860
+ el.appendChild(pre);
861
+ }
862
+
863
+ function renderText(el, text) {
864
+ el.style.display = "";
865
+ el.className = "status ok";
866
+ el.innerHTML = "";
867
+ const pre = document.createElement("pre");
868
+ pre.textContent = String(text || "");
869
+ el.appendChild(pre);
870
+ }
871
+
872
+ function parseAndPopulate() {
873
+ hideStatus(els.parseStatus);
874
+ hideStatus(els.genStatus);
875
+ hideStatus(els.etsHint);
876
+ hideStatus(els.haHint);
877
+ els.jsonMapping.style.display = "";
878
+ els.etsMapping.style.display = "none";
879
+ els.haMapping.style.display = "none";
880
+ els.zoneOutput.value = "";
881
+ els.btnCopyZone.disabled = true;
882
+ lastGenerated = null;
883
+
884
+ const result = parseInput(els.input.value);
885
+ if (!result.ok) {
886
+ parsedObject = null;
887
+ showStatus(
888
+ els.parseStatus,
889
+ "err",
890
+ `${result.error}${result.details ? "\n" + result.details : ""}`,
891
+ );
892
+ return;
490
893
  }
491
894
 
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);
895
+ if (
896
+ result.value &&
897
+ result.value.__ets &&
898
+ result.value.ets &&
899
+ Array.isArray(result.value.ets.rows)
900
+ ) {
901
+ parsedObject = result.value;
902
+ showStatus(els.parseStatus, "ok", result.note);
903
+ els.jsonMapping.style.display = "none";
904
+ els.etsMapping.style.display = "";
905
+ els.haMapping.style.display = "none";
906
+ showStatus(
907
+ els.etsHint,
908
+ "ok",
909
+ "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.",
910
+ );
911
+ return;
499
912
  }
500
913
 
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
- }
914
+ if (isHomeAssistantStatesExport(result.value)) {
915
+ parsedObject = { __ha: true, ha: { kind: "states", states: extractHomeAssistantStates(result.value) } };
916
+ showStatus(els.parseStatus, "ok", "Detected Home Assistant states export (JSON).");
917
+ els.jsonMapping.style.display = "none";
918
+ els.etsMapping.style.display = "none";
919
+ els.haMapping.style.display = "";
920
+ showStatus(
921
+ els.haHint,
922
+ "ok",
923
+ "Home Assistant import mode.\n- Topic: entity_id\n- Name: attributes.friendly_name (fallback entity_id)\nUse the filters below, then click “Generate output”.",
924
+ );
925
+ return;
926
+ }
514
927
 
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
- );
522
- } else {
523
- parsedObject = result.value;
524
- showStatus(els.parseStatus, "ok", result.note);
525
- }
928
+ if (isHomeAssistantEntityRegistryExport(result.value)) {
929
+ parsedObject = { __ha: true, ha: { kind: "entity_registry", entities: extractHomeAssistantEntityRegistry(result.value) } };
930
+ showStatus(els.parseStatus, "ok", "Detected Home Assistant entity registry export (core.entity_registry).");
931
+ els.jsonMapping.style.display = "none";
932
+ els.etsMapping.style.display = "none";
933
+ els.haMapping.style.display = "";
934
+ showStatus(
935
+ els.haHint,
936
+ "ok",
937
+ "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”.",
938
+ );
939
+ return;
940
+ }
526
941
 
527
- const paths = Array.from(new Set(enumeratePaths(parsedObject))).filter(Boolean);
528
- paths.sort((a, b) => a.localeCompare(b));
942
+ els.topicPath.disabled = false;
943
+ els.valuePath.disabled = false;
944
+ els.namePath.disabled = false;
945
+
946
+ if (!isPlainObject(result.value)) {
947
+ parsedObject = result.value;
948
+ showStatus(
949
+ els.parseStatus,
950
+ "warn",
951
+ `${result.note} Note: the root is not an object. Paths will be limited.`,
952
+ );
953
+ } else {
954
+ parsedObject = result.value;
955
+ showStatus(els.parseStatus, "ok", result.note);
956
+ }
529
957
 
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"]);
958
+ const paths = Array.from(new Set(enumeratePaths(parsedObject))).filter(
959
+ Boolean,
960
+ );
961
+ paths.sort((a, b) => a.localeCompare(b));
962
+
963
+ const defaultTopic = guessDefault(paths, [
964
+ "topic",
965
+ "knx.destination",
966
+ "destination",
967
+ ]);
968
+ const defaultValue = guessDefault(paths, [
969
+ "payload",
970
+ "payloadsubtypevalue",
971
+ "value",
972
+ ]);
973
+ const defaultName = guessDefault(paths, [
974
+ "devicename",
975
+ "gainfo.ganame",
976
+ "name",
977
+ "topic",
978
+ ]);
979
+
980
+ setSelectOptions(els.topicPath, paths, defaultTopic);
981
+ setSelectOptions(els.valuePath, paths, defaultValue);
982
+ setSelectOptions(els.namePath, paths, defaultName);
983
+ }
533
984
 
534
- setSelectOptions(els.topicPath, paths, defaultTopic);
535
- setSelectOptions(els.valuePath, paths, defaultValue);
536
- setSelectOptions(els.namePath, paths, defaultName);
985
+ function generate() {
986
+ hideStatus(els.genStatus);
987
+ if (!parsedObject) {
988
+ showStatus(els.genStatus, "err", "Parse a valid message first.");
989
+ return;
537
990
  }
538
991
 
539
- function generate() {
540
- hideStatus(els.genStatus);
541
- if (!parsedObject) {
542
- showStatus(els.genStatus, "err", "Parse a valid message first.");
543
- return;
544
- }
992
+ if (
993
+ parsedObject &&
994
+ parsedObject.__ets &&
995
+ parsedObject.ets &&
996
+ Array.isArray(parsedObject.ets.rows)
997
+ ) {
998
+ const allRows = parsedObject.ets.rows;
999
+ const addressPatterns = splitPatterns(
1000
+ els.etsAddressFilter ? els.etsAddressFilter.value : "",
1001
+ );
1002
+ const nameFilter = els.etsNameFilter ? els.etsNameFilter.value : "";
1003
+
1004
+ const leafRows = allRows.filter((r) => {
1005
+ const addr = String(r && r.address ? r.address : "").trim();
1006
+ if (!addr) return false;
1007
+ if (addr.includes("-")) return false;
1008
+ return /^\d+\/\d+\/\d+$/.test(addr);
1009
+ });
1010
+
1011
+ const booleanRows = leafRows.filter((r) =>
1012
+ isBooleanDatapointType(r.datapointType),
1013
+ );
1014
+ const filteredRows = booleanRows.filter((r) => {
1015
+ const addr = String(r && r.address ? r.address : "").trim();
1016
+ if (!matchesAnyGlob(addr, addressPatterns)) return false;
1017
+ if (!matchesNameFilter(r, nameFilter)) return false;
1018
+ return true;
1019
+ });
1020
+
1021
+ const zones = filteredRows.map((row) => {
1022
+ const address = String(row.address).trim();
1023
+ const topic = address;
1024
+ const nameCandidate =
1025
+ String(row.description || "").trim() ||
1026
+ String(row.groupName || "").trim();
1027
+ const name = nameCandidate || address;
1028
+ const idSafe = address
1029
+ .replace(/[^\w]+/g, "_")
1030
+ .replace(/^_+|_+$/g, "")
1031
+ .toLowerCase();
1032
+ const id = `ga_${idSafe || "unknown"}`;
1033
+ return {
1034
+ id,
1035
+ name,
1036
+ topic,
1037
+ type: "perimeter",
1038
+ entry: false,
1039
+ bypassable: true,
1040
+ chime: false,
1041
+ };
1042
+ });
1043
+
1044
+ els.zoneOutput.value = JSON.stringify(zones, null, 2);
1045
+ els.btnCopyZone.disabled = zones.length === 0;
1046
+ const skippedGroups = allRows.length - leafRows.length;
1047
+ const skippedNonBoolean = leafRows.length - booleanRows.length;
1048
+ const skippedByFilters = booleanRows.length - filteredRows.length;
1049
+ showStatus(
1050
+ els.genStatus,
1051
+ zones.length ? "ok" : "warn",
1052
+ `Generated ${zones.length} zones from ETS (skipped ${skippedGroups} group rows, ${skippedNonBoolean} non-boolean datapoints, ${skippedByFilters} filtered out).`,
1053
+ );
1054
+ return;
1055
+ }
545
1056
 
546
- const topicPath = els.topicPath.value;
547
- const valuePath = els.valuePath.value;
548
- const namePath = els.namePath.value;
1057
+ if (parsedObject && parsedObject.__ha && parsedObject.ha && parsedObject.ha.kind === "states" && Array.isArray(parsedObject.ha.states)) {
1058
+ const states = parsedObject.ha.states;
1059
+ const entityPatterns = splitPatterns(els.haEntityFilter ? els.haEntityFilter.value : "");
1060
+ const nameFilter = els.haNameFilter ? els.haNameFilter.value : "";
1061
+ const domains = parseDomainList(els.haDomains ? els.haDomains.value : "");
1062
+ const defaultBooleanDomains = ["binary_sensor", "input_boolean", "switch"];
1063
+ const allowedDomains = domains.length ? domains : defaultBooleanDomains;
1064
+
1065
+ const eligible = states.filter((s) => s && typeof s === "object" && typeof s.entity_id === "string");
1066
+ const domainFiltered = eligible.filter((s) => allowedDomains.includes(getHaDomain(s.entity_id)));
1067
+ const filtered = domainFiltered.filter((s) => {
1068
+ const entityId = String(s.entity_id || "").trim();
1069
+ if (!matchesAnyGlob(entityId, entityPatterns)) return false;
1070
+ const row = { description: s.attributes && s.attributes.friendly_name ? s.attributes.friendly_name : "" , groupName: entityId };
1071
+ if (!matchesNameFilter(row, nameFilter)) return false;
1072
+ return true;
1073
+ });
1074
+
1075
+ const zones = filtered.map((s) => {
1076
+ const entityId = String(s.entity_id).trim();
1077
+ const friendly = s.attributes && s.attributes.friendly_name ? String(s.attributes.friendly_name).trim() : "";
1078
+ const name = friendly || entityId;
1079
+ const idSafe = entityId.replace(/[^\w]+/g, "_").replace(/^_+|_+$/g, "").toLowerCase();
1080
+ const id = `ha_${idSafe || "unknown"}`;
1081
+ return {
1082
+ id,
1083
+ name,
1084
+ topic: entityId,
1085
+ type: "perimeter",
1086
+ entry: false,
1087
+ bypassable: true,
1088
+ chime: false,
1089
+ };
1090
+ });
1091
+
1092
+ els.zoneOutput.value = JSON.stringify(zones, null, 2);
1093
+ els.btnCopyZone.disabled = zones.length === 0;
1094
+ const skippedNonEntity = states.length - eligible.length;
1095
+ const skippedDomain = eligible.length - domainFiltered.length;
1096
+ const skippedByFilters = domainFiltered.length - filtered.length;
1097
+ showStatus(
1098
+ els.genStatus,
1099
+ zones.length ? "ok" : "warn",
1100
+ `Generated ${zones.length} zones from HA (skipped ${skippedNonEntity} invalid rows, ${skippedDomain} domain filtered, ${skippedByFilters} filtered out).`,
1101
+ );
1102
+ return;
1103
+ }
549
1104
 
550
- const topicValue = getByPath(parsedObject, topicPath);
551
- const valueValue = getByPath(parsedObject, valuePath);
552
- const nameValue = getByPath(parsedObject, namePath);
1105
+ if (parsedObject && parsedObject.__ha && parsedObject.ha && parsedObject.ha.kind === "entity_registry" && Array.isArray(parsedObject.ha.entities)) {
1106
+ const entities = parsedObject.ha.entities;
1107
+ const includeDisabled = Boolean(els.haIncludeDisabled && els.haIncludeDisabled.checked);
1108
+ const entityPatterns = splitPatterns(els.haEntityFilter ? els.haEntityFilter.value : "");
1109
+ const nameFilter = els.haNameFilter ? els.haNameFilter.value : "";
1110
+ const domains = parseDomainList(els.haDomains ? els.haDomains.value : "");
1111
+ const defaultBooleanDomains = ["binary_sensor", "input_boolean", "switch"];
1112
+ const allowedDomains = domains.length ? domains : defaultBooleanDomains;
1113
+
1114
+ const eligible = entities.filter((e) => e && typeof e === "object" && typeof e.entity_id === "string");
1115
+ const enabledOnly = eligible.filter((e) => includeDisabled || !e.disabled_by);
1116
+ const domainFiltered = enabledOnly.filter((e) => allowedDomains.includes(getHaDomain(e.entity_id)));
1117
+ const filtered = domainFiltered.filter((e) => {
1118
+ const entityId = String(e.entity_id || "").trim();
1119
+ if (!matchesAnyGlob(entityId, entityPatterns)) return false;
1120
+ const displayName = String(e.name || "").trim() || String(e.original_name || "").trim() || entityId;
1121
+ const row = { description: displayName, groupName: entityId };
1122
+ if (!matchesNameFilter(row, nameFilter)) return false;
1123
+ return true;
1124
+ });
1125
+
1126
+ const zones = filtered.map((e) => {
1127
+ const entityId = String(e.entity_id).trim();
1128
+ const displayName = String(e.name || "").trim() || String(e.original_name || "").trim() || entityId;
1129
+ const idSafe = entityId.replace(/[^\w]+/g, "_").replace(/^_+|_+$/g, "").toLowerCase();
1130
+ const id = `ha_${idSafe || "unknown"}`;
1131
+ return {
1132
+ id,
1133
+ name: displayName,
1134
+ topic: entityId,
1135
+ type: "perimeter",
1136
+ entry: false,
1137
+ bypassable: true,
1138
+ chime: false,
1139
+ };
1140
+ });
1141
+
1142
+ els.zoneOutput.value = JSON.stringify(zones, null, 2);
1143
+ els.btnCopyZone.disabled = zones.length === 0;
1144
+ const skippedNonEntity = entities.length - eligible.length;
1145
+ const skippedDisabled = eligible.length - enabledOnly.length;
1146
+ const skippedDomain = enabledOnly.length - domainFiltered.length;
1147
+ const skippedByFilters = domainFiltered.length - filtered.length;
1148
+ showStatus(
1149
+ els.genStatus,
1150
+ zones.length ? "ok" : "warn",
1151
+ `Generated ${zones.length} zones from HA registry (skipped ${skippedNonEntity} invalid rows, ${skippedDisabled} disabled, ${skippedDomain} domain filtered, ${skippedByFilters} filtered out).`,
1152
+ );
1153
+ return;
1154
+ }
553
1155
 
554
- if (topicValue === undefined) {
555
- showStatus(els.genStatus, "err", `Topic path "${topicPath}" is undefined in the input message.`);
556
- return;
557
- }
1156
+ const topicPath = els.topicPath.value;
1157
+ const valuePath = els.valuePath.value;
1158
+ const namePath = els.namePath.value;
1159
+
1160
+ const topicValue = getByPath(parsedObject, topicPath);
1161
+ const valueValue = getByPath(parsedObject, valuePath);
1162
+ const nameValue = getByPath(parsedObject, namePath);
1163
+
1164
+ if (topicValue === undefined) {
1165
+ showStatus(
1166
+ els.genStatus,
1167
+ "err",
1168
+ `Topic path "${topicPath}" is undefined in the input message.`,
1169
+ );
1170
+ return;
1171
+ }
558
1172
 
559
- const normalized = { ...parsedObject, topic: String(topicValue), payload: valueValue };
560
- const zone = buildZoneTemplate(normalized, nameValue);
561
- els.zoneOutput.value = JSON.stringify(zone, null, 2);
1173
+ const normalized = {
1174
+ ...parsedObject,
1175
+ topic: String(topicValue),
1176
+ payload: valueValue,
1177
+ };
1178
+ const zone = buildZoneTemplate(normalized, nameValue);
1179
+ els.zoneOutput.value = JSON.stringify(zone, null, 2);
562
1180
 
563
- els.btnCopyZone.disabled = false;
1181
+ els.btnCopyZone.disabled = false;
564
1182
 
565
- lastGenerated = { normalized, zone };
566
- showStatus(els.genStatus, "ok", `Generated using topic="${toOneLine(topicValue)}" and value="${valuePath}".`);
567
- }
1183
+ lastGenerated = { normalized, zone };
1184
+ showStatus(
1185
+ els.genStatus,
1186
+ "ok",
1187
+ `Generated using topic="${toOneLine(topicValue)}" and value="${valuePath}".`,
1188
+ );
1189
+ }
1190
+
1191
+ els.btnParse.addEventListener("click", () => parseAndPopulate());
1192
+ els.btnGenerate.addEventListener("click", () => generate());
1193
+ els.btnClear.addEventListener("click", () => {
1194
+ els.input.value = "";
1195
+ parsedObject = null;
1196
+ lastGenerated = null;
1197
+ hideStatus(els.parseStatus);
1198
+ hideStatus(els.genStatus);
1199
+ hideStatus(els.etsHint);
1200
+ hideStatus(els.haHint);
1201
+ els.jsonMapping.style.display = "";
1202
+ els.etsMapping.style.display = "none";
1203
+ if (els.etsAddressFilter) els.etsAddressFilter.value = "";
1204
+ if (els.etsNameFilter) els.etsNameFilter.value = "";
1205
+ els.haMapping.style.display = "none";
1206
+ if (els.haEntityFilter) els.haEntityFilter.value = "";
1207
+ if (els.haNameFilter) els.haNameFilter.value = "";
1208
+ if (els.haDomains) els.haDomains.value = "";
1209
+ if (els.haIncludeDisabled) els.haIncludeDisabled.checked = false;
1210
+ els.zoneOutput.value = "";
1211
+ els.btnCopyZone.disabled = true;
1212
+ els.topicPath.innerHTML = "";
1213
+ els.valuePath.innerHTML = "";
1214
+ els.namePath.innerHTML = "";
1215
+ els.topicPath.disabled = false;
1216
+ els.valuePath.disabled = false;
1217
+ els.namePath.disabled = false;
1218
+ });
1219
+ els.btnLoadKnx.addEventListener("click", () => {
1220
+ els.input.value = KNX_SAMPLE;
1221
+ parseAndPopulate();
1222
+ });
1223
+ els.btnLoadEts.addEventListener("click", () => {
1224
+ els.input.value = ETS_SAMPLE;
1225
+ parseAndPopulate();
1226
+ });
1227
+ els.btnLoadHa.addEventListener("click", () => {
1228
+ els.input.value = HA_SAMPLE;
1229
+ parseAndPopulate();
1230
+ });
1231
+ els.btnLoadHaRegistry.addEventListener("click", () => {
1232
+ els.input.value = HA_REGISTRY_SAMPLE;
1233
+ parseAndPopulate();
1234
+ });
1235
+
1236
+ els.btnCopyZone.addEventListener("click", async () => {
1237
+ const text = String(els.zoneOutput.value || "").trim();
1238
+ if (!text) return;
1239
+ const ok = await copyToClipboard(text);
1240
+ showStatus(
1241
+ els.genStatus,
1242
+ ok ? "ok" : "warn",
1243
+ ok ? "Zone JSON copied." : "Copy failed.",
1244
+ );
1245
+ });
1246
+ </script>
1247
+ </body>
568
1248
 
569
- els.btnParse.addEventListener("click", () => parseAndPopulate());
570
- els.btnGenerate.addEventListener("click", () => generate());
571
- els.btnClear.addEventListener("click", () => {
572
- els.input.value = "";
573
- 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
- });
587
-
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.");
593
- });
594
- </script>
595
- </body>
596
1249
  </html>