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.
- package/README.md +87 -13
- package/docs/images/alarm-panel-mock.svg +114 -0
- package/docs/images/banner.svg +63 -0
- package/docs/images/flow-overview.svg +85 -0
- package/examples/README.md +32 -11
- package/examples/alarm-ultimate-basic.json +0 -1
- package/examples/alarm-ultimate-dashboard-controls.json +575 -0
- package/examples/alarm-ultimate-dashboard-v2.json +762 -0
- package/examples/alarm-ultimate-dashboard.json +3 -3
- package/flowfuse-node-red-dashboard-1.30.2.tgz +0 -0
- package/nodes/AlarmSystemUltimate.html +174 -85
- package/nodes/AlarmSystemUltimate.js +39 -8
- package/nodes/AlarmUltimateInputAdapter.html +304 -0
- package/nodes/AlarmUltimateInputAdapter.js +188 -0
- package/nodes/AlarmUltimateSiren.html +3 -3
- package/nodes/AlarmUltimateSiren.js +6 -2
- package/nodes/AlarmUltimateState.html +3 -3
- package/nodes/AlarmUltimateState.js +6 -2
- package/nodes/AlarmUltimateZone.html +11 -6
- package/nodes/AlarmUltimateZone.js +27 -6
- package/nodes/icons/alarm-ultimate-siren.svg +6 -0
- package/nodes/icons/alarm-ultimate-state.svg +5 -0
- package/nodes/icons/alarm-ultimate-zone.svg +5 -0
- package/nodes/icons/alarm-ultimate.svg +6 -0
- package/nodes/presets/input-adapter/ax-pro-hikvision-ultimate.js +34 -0
- package/nodes/presets/input-adapter/boolean-from-payload.js +10 -0
- package/nodes/presets/input-adapter/ha-on-off.js +24 -0
- package/nodes/presets/input-adapter/knx-ultimate.js +29 -0
- package/nodes/presets/input-adapter/passthrough.js +7 -0
- package/package.json +5 -4
- package/test/alarm-system.spec.js +51 -0
- package/test/input-adapter.spec.js +243 -0
- package/test/output-nodes.spec.js +3 -0
- package/tools/alarm-json-mapper.html +1882 -460
- package/tools/alarm-panel.html +630 -131
|
@@ -1,298 +1,496 @@
|
|
|
1
1
|
<!doctype html>
|
|
2
2
|
<html lang="en">
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
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
|
-
|
|
10
|
-
--
|
|
11
|
-
--
|
|
12
|
-
--
|
|
13
|
-
--
|
|
14
|
-
--
|
|
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
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
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
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
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
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
55
|
+
header h1 {
|
|
56
|
+
font-size: 18px;
|
|
57
|
+
margin: 0 0 6px 0;
|
|
58
|
+
}
|
|
54
59
|
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
+
header p {
|
|
61
|
+
margin: 0;
|
|
62
|
+
color: var(--muted);
|
|
63
|
+
font-size: 13px;
|
|
64
|
+
}
|
|
60
65
|
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
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
|
-
|
|
71
|
-
grid-template-columns: 1.2fr 0.8fr;
|
|
72
|
-
gap: 12px;
|
|
82
|
+
grid-template-columns: 1fr;
|
|
73
83
|
}
|
|
84
|
+
}
|
|
74
85
|
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
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
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
padding: 12px;
|
|
86
|
-
}
|
|
93
|
+
.card h2 {
|
|
94
|
+
font-size: 14px;
|
|
95
|
+
margin: 0 0 10px 0;
|
|
96
|
+
}
|
|
87
97
|
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
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
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
align-items: center;
|
|
98
|
-
margin: 8px 0;
|
|
99
|
-
}
|
|
106
|
+
.row label {
|
|
107
|
+
font-size: 12px;
|
|
108
|
+
color: var(--muted);
|
|
109
|
+
}
|
|
100
110
|
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
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
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
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
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
130
|
+
.buttons {
|
|
131
|
+
display: flex;
|
|
132
|
+
flex-wrap: wrap;
|
|
133
|
+
gap: 8px;
|
|
134
|
+
margin-top: 10px;
|
|
135
|
+
}
|
|
124
136
|
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
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
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
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
|
-
|
|
143
|
-
|
|
144
|
-
|
|
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
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
}
|
|
159
|
+
button.primary:hover {
|
|
160
|
+
filter: brightness(1.05);
|
|
161
|
+
}
|
|
151
162
|
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
margin: 6px 0 0 0;
|
|
156
|
-
}
|
|
163
|
+
button:active {
|
|
164
|
+
transform: translateY(1px);
|
|
165
|
+
}
|
|
157
166
|
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
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
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
172
|
+
.hint {
|
|
173
|
+
font-size: 12px;
|
|
174
|
+
color: var(--muted);
|
|
175
|
+
margin: 6px 0 0 0;
|
|
176
|
+
}
|
|
171
177
|
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
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
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
187
|
+
.status.ok {
|
|
188
|
+
border-color: rgba(47, 191, 113, 0.55);
|
|
189
|
+
color: var(--ok);
|
|
190
|
+
}
|
|
181
191
|
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
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
|
-
|
|
191
|
-
|
|
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
|
-
|
|
196
|
-
grid-template-columns: 1fr 1fr;
|
|
197
|
-
gap: 12px;
|
|
222
|
+
grid-template-columns: 1fr;
|
|
198
223
|
}
|
|
224
|
+
}
|
|
199
225
|
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
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
|
-
|
|
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
|
-
<
|
|
338
|
+
<div id="wizard" style="display:none;">
|
|
217
339
|
<div class="grid">
|
|
218
|
-
<section class="card">
|
|
219
|
-
<h2>1
|
|
220
|
-
<textarea
|
|
221
|
-
|
|
222
|
-
spellcheck="false"
|
|
223
|
-
placeholder='Paste JSON here (strict JSON). 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). Tip: this tool also accepts JS-style objects with unquoted keys and // comments."></textarea>
|
|
225
344
|
<div class="buttons">
|
|
226
|
-
<
|
|
227
|
-
|
|
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
|
-
|
|
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
|
|
239
|
-
<div class="
|
|
240
|
-
|
|
241
|
-
|
|
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
|
|
244
|
-
<
|
|
245
|
-
|
|
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
|
|
248
|
-
<
|
|
249
|
-
|
|
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
|
|
253
|
-
|
|
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
|
-
<
|
|
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
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
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
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
513
|
+
destination: "0/1/2",
|
|
514
|
+
rawValue: {
|
|
515
|
+
0: "0x0"
|
|
516
|
+
}
|
|
319
517
|
}
|
|
320
518
|
}`;
|
|
321
519
|
|
|
322
|
-
|
|
323
|
-
|
|
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
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
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
|
-
|
|
332
|
-
|
|
333
|
-
|
|
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
|
-
|
|
337
|
-
|
|
338
|
-
|
|
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
|
-
|
|
343
|
-
|
|
642
|
+
function isConnectedToEditor() {
|
|
643
|
+
return Boolean(page.alarmNodeId) && editorConnected === true;
|
|
644
|
+
}
|
|
344
645
|
|
|
345
|
-
|
|
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
|
-
|
|
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
|
-
|
|
351
|
-
|
|
352
|
-
|
|
694
|
+
function cleanZoneForJson(zone, index) {
|
|
695
|
+
const z = zone && typeof zone === "object" ? { ...zone } : {};
|
|
696
|
+
const name = String(z.name || "").trim();
|
|
353
697
|
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
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
|
-
|
|
373
|
-
|
|
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
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
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 (
|
|
382
|
-
|
|
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
|
-
|
|
387
|
-
|
|
388
|
-
|
|
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
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
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
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
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
|
-
|
|
428
|
-
if (
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
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
|
-
|
|
438
|
-
|
|
439
|
-
|
|
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
|
-
|
|
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
|
-
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
465
|
-
|
|
466
|
-
|
|
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
|
-
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
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
|
-
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
|
|
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
|
-
|
|
516
|
-
|
|
517
|
-
|
|
518
|
-
|
|
519
|
-
|
|
520
|
-
|
|
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
|
-
|
|
524
|
-
|
|
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
|
-
|
|
528
|
-
|
|
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
|
-
|
|
531
|
-
|
|
532
|
-
|
|
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
|
-
|
|
535
|
-
|
|
536
|
-
|
|
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
|
-
|
|
540
|
-
|
|
541
|
-
|
|
542
|
-
|
|
543
|
-
|
|
544
|
-
|
|
1180
|
+
return {
|
|
1181
|
+
ok: true,
|
|
1182
|
+
note: "Detected ETS Group Addresses export (TSV).",
|
|
1183
|
+
rows,
|
|
1184
|
+
};
|
|
1185
|
+
}
|
|
545
1186
|
|
|
546
|
-
|
|
547
|
-
|
|
548
|
-
|
|
1187
|
+
function escapeRegExp(value) {
|
|
1188
|
+
return String(value || "").replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
1189
|
+
}
|
|
549
1190
|
|
|
550
|
-
|
|
551
|
-
|
|
552
|
-
|
|
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
|
-
|
|
555
|
-
|
|
556
|
-
|
|
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
|
-
|
|
560
|
-
|
|
561
|
-
|
|
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
|
-
|
|
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
|
-
|
|
566
|
-
|
|
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
|
-
|
|
570
|
-
|
|
571
|
-
|
|
572
|
-
|
|
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
|
-
|
|
575
|
-
|
|
576
|
-
|
|
577
|
-
|
|
578
|
-
|
|
579
|
-
|
|
580
|
-
|
|
581
|
-
|
|
582
|
-
|
|
583
|
-
|
|
584
|
-
|
|
585
|
-
|
|
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
|
-
|
|
589
|
-
|
|
590
|
-
|
|
591
|
-
|
|
592
|
-
|
|
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
|
-
|
|
595
|
-
|
|
2010
|
+
}
|
|
2011
|
+
|
|
2012
|
+
// Initial render.
|
|
2013
|
+
renderZones();
|
|
2014
|
+
setConnectionState(canTalkToEditor() ? "connecting" : "disconnected");
|
|
2015
|
+
</script>
|
|
2016
|
+
</body>
|
|
2017
|
+
|
|
596
2018
|
</html>
|