node-red-contrib-alarm-ultimate 0.1.0
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/LICENSE +22 -0
- package/README.md +73 -0
- package/examples/README.md +21 -0
- package/examples/alarm-ultimate-basic.json +636 -0
- package/examples/alarm-ultimate-dashboard.json +99 -0
- package/nodes/AlarmSystemUltimate.html +697 -0
- package/nodes/AlarmSystemUltimate.js +1418 -0
- package/nodes/AlarmUltimateSiren.html +94 -0
- package/nodes/AlarmUltimateSiren.js +83 -0
- package/nodes/AlarmUltimateState.html +95 -0
- package/nodes/AlarmUltimateState.js +87 -0
- package/nodes/AlarmUltimateZone.html +130 -0
- package/nodes/AlarmUltimateZone.js +91 -0
- package/nodes/lib/alarm-registry.js +15 -0
- package/nodes/lib/node-helpers.js +96 -0
- package/nodes/utils.js +95 -0
- package/package.json +33 -0
- package/test/alarm-system.spec.js +470 -0
- package/test/helpers.js +28 -0
- package/test/output-nodes.spec.js +155 -0
- package/tools/alarm-json-mapper.html +596 -0
- package/tools/alarm-panel.html +728 -0
|
@@ -0,0 +1,596 @@
|
|
|
1
|
+
<!doctype html>
|
|
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>
|
|
8
|
+
:root {
|
|
9
|
+
color-scheme: light dark;
|
|
10
|
+
--bg: #0b1020;
|
|
11
|
+
--panel: #111936;
|
|
12
|
+
--text: #e7eaf6;
|
|
13
|
+
--muted: #a9b0d2;
|
|
14
|
+
--border: #2a355f;
|
|
15
|
+
--accent: #6ea8fe;
|
|
16
|
+
--ok: #2fbf71;
|
|
17
|
+
--warn: #ffcc66;
|
|
18
|
+
--danger: #ff6b6b;
|
|
19
|
+
--mono: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New",
|
|
20
|
+
monospace;
|
|
21
|
+
--sans: system-ui, -apple-system, Segoe UI, Roboto, Helvetica, Arial, "Apple Color Emoji",
|
|
22
|
+
"Segoe UI Emoji";
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
@media (prefers-color-scheme: light) {
|
|
26
|
+
:root {
|
|
27
|
+
--bg: #f6f8ff;
|
|
28
|
+
--panel: #ffffff;
|
|
29
|
+
--text: #111827;
|
|
30
|
+
--muted: #5b647a;
|
|
31
|
+
--border: #d8deef;
|
|
32
|
+
--accent: #2563eb;
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
body {
|
|
37
|
+
margin: 0;
|
|
38
|
+
background: var(--bg);
|
|
39
|
+
color: var(--text);
|
|
40
|
+
font-family: var(--sans);
|
|
41
|
+
line-height: 1.4;
|
|
42
|
+
}
|
|
43
|
+
|
|
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
|
+
}
|
|
49
|
+
|
|
50
|
+
header h1 {
|
|
51
|
+
font-size: 18px;
|
|
52
|
+
margin: 0 0 6px 0;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
header p {
|
|
56
|
+
margin: 0;
|
|
57
|
+
color: var(--muted);
|
|
58
|
+
font-size: 13px;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
main {
|
|
62
|
+
max-width: 1100px;
|
|
63
|
+
margin: 0 auto;
|
|
64
|
+
padding: 16px;
|
|
65
|
+
display: grid;
|
|
66
|
+
gap: 12px;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
.grid {
|
|
70
|
+
display: grid;
|
|
71
|
+
grid-template-columns: 1.2fr 0.8fr;
|
|
72
|
+
gap: 12px;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
@media (max-width: 980px) {
|
|
76
|
+
.grid {
|
|
77
|
+
grid-template-columns: 1fr;
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
.card {
|
|
82
|
+
border: 1px solid var(--border);
|
|
83
|
+
background: var(--panel);
|
|
84
|
+
border-radius: 10px;
|
|
85
|
+
padding: 12px;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
.card h2 {
|
|
89
|
+
font-size: 14px;
|
|
90
|
+
margin: 0 0 10px 0;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
.row {
|
|
94
|
+
display: grid;
|
|
95
|
+
grid-template-columns: 160px 1fr;
|
|
96
|
+
gap: 10px;
|
|
97
|
+
align-items: center;
|
|
98
|
+
margin: 8px 0;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
.row label {
|
|
102
|
+
font-size: 12px;
|
|
103
|
+
color: var(--muted);
|
|
104
|
+
}
|
|
105
|
+
|
|
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
|
+
}
|
|
119
|
+
|
|
120
|
+
textarea {
|
|
121
|
+
min-height: 240px;
|
|
122
|
+
resize: vertical;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
.buttons {
|
|
126
|
+
display: flex;
|
|
127
|
+
flex-wrap: wrap;
|
|
128
|
+
gap: 8px;
|
|
129
|
+
margin-top: 10px;
|
|
130
|
+
}
|
|
131
|
+
|
|
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
|
+
}
|
|
141
|
+
|
|
142
|
+
button.primary {
|
|
143
|
+
border-color: rgba(110, 168, 254, 0.5);
|
|
144
|
+
background: rgba(110, 168, 254, 0.22);
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
button:disabled {
|
|
148
|
+
opacity: 0.6;
|
|
149
|
+
cursor: not-allowed;
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
.hint {
|
|
153
|
+
font-size: 12px;
|
|
154
|
+
color: var(--muted);
|
|
155
|
+
margin: 6px 0 0 0;
|
|
156
|
+
}
|
|
157
|
+
|
|
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
|
+
}
|
|
166
|
+
|
|
167
|
+
.status.ok {
|
|
168
|
+
border-color: rgba(47, 191, 113, 0.55);
|
|
169
|
+
color: var(--ok);
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
.status.warn {
|
|
173
|
+
border-color: rgba(255, 204, 102, 0.55);
|
|
174
|
+
color: var(--warn);
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
.status.err {
|
|
178
|
+
border-color: rgba(255, 107, 107, 0.55);
|
|
179
|
+
color: var(--danger);
|
|
180
|
+
}
|
|
181
|
+
|
|
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
|
+
}
|
|
189
|
+
|
|
190
|
+
.mono {
|
|
191
|
+
font-family: var(--mono);
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
.two {
|
|
195
|
+
display: grid;
|
|
196
|
+
grid-template-columns: 1fr 1fr;
|
|
197
|
+
gap: 12px;
|
|
198
|
+
}
|
|
199
|
+
|
|
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). 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>
|
|
229
|
+
</div>
|
|
230
|
+
<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.
|
|
233
|
+
</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>
|
|
239
|
+
<div class="row">
|
|
240
|
+
<label for="topicPath">Topic path</label>
|
|
241
|
+
<select id="topicPath"></select>
|
|
242
|
+
</div>
|
|
243
|
+
<div class="row">
|
|
244
|
+
<label for="valuePath">Value path</label>
|
|
245
|
+
<select id="valuePath"></select>
|
|
246
|
+
</div>
|
|
247
|
+
<div class="row">
|
|
248
|
+
<label for="namePath">Zone name path</label>
|
|
249
|
+
<select id="namePath"></select>
|
|
250
|
+
</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
|
+
</div>
|
|
267
|
+
<textarea
|
|
268
|
+
id="zone-output"
|
|
269
|
+
spellcheck="false"
|
|
270
|
+
placeholder="Click “Generate output” to create a zone template, then edit it here if needed."
|
|
271
|
+
></textarea>
|
|
272
|
+
<p class="hint">
|
|
273
|
+
This creates a single zone object using the mapped topic and optional name. Paste it into the Zones field
|
|
274
|
+
(legacy: one JSON object per line, or formatted: JSON array).
|
|
275
|
+
</p>
|
|
276
|
+
</section>
|
|
277
|
+
</main>
|
|
278
|
+
|
|
279
|
+
<script>
|
|
280
|
+
const els = {
|
|
281
|
+
input: document.getElementById("input"),
|
|
282
|
+
btnParse: document.getElementById("btn-parse"),
|
|
283
|
+
btnLoadKnx: document.getElementById("btn-load-knx"),
|
|
284
|
+
btnClear: document.getElementById("btn-clear"),
|
|
285
|
+
parseStatus: document.getElementById("parse-status"),
|
|
286
|
+
genStatus: document.getElementById("gen-status"),
|
|
287
|
+
topicPath: document.getElementById("topicPath"),
|
|
288
|
+
valuePath: document.getElementById("valuePath"),
|
|
289
|
+
namePath: document.getElementById("namePath"),
|
|
290
|
+
btnGenerate: document.getElementById("btn-generate"),
|
|
291
|
+
zoneOutput: document.getElementById("zone-output"),
|
|
292
|
+
btnCopyZone: document.getElementById("btn-copy-zone"),
|
|
293
|
+
};
|
|
294
|
+
|
|
295
|
+
const KNX_SAMPLE = `{
|
|
296
|
+
topic: "0/1/2",
|
|
297
|
+
payload: false,
|
|
298
|
+
previouspayload: true,
|
|
299
|
+
payloadmeasureunit: "%",
|
|
300
|
+
payloadsubtypevalue: "Start",
|
|
301
|
+
devicename: "Dinning table lamp",
|
|
302
|
+
gainfo: {
|
|
303
|
+
maingroupname: "Light actuators",
|
|
304
|
+
middlegroupname: "First flow lights",
|
|
305
|
+
ganame: "Table Light",
|
|
306
|
+
maingroupnumber: "1",
|
|
307
|
+
middlegroupnumber: "1",
|
|
308
|
+
ganumber: "0"
|
|
309
|
+
},
|
|
310
|
+
knx: {
|
|
311
|
+
event: "GroupValue_Write",
|
|
312
|
+
dpt: "1.001",
|
|
313
|
+
dptdesc: "Humidity",
|
|
314
|
+
source: "15.15.22",
|
|
315
|
+
destination: "0/1/2",
|
|
316
|
+
rawValue: {
|
|
317
|
+
0: "0x0"
|
|
318
|
+
}
|
|
319
|
+
}
|
|
320
|
+
}`;
|
|
321
|
+
|
|
322
|
+
let parsedObject = null;
|
|
323
|
+
let lastGenerated = null;
|
|
324
|
+
|
|
325
|
+
function showStatus(el, kind, message) {
|
|
326
|
+
el.style.display = "";
|
|
327
|
+
el.className = `status ${kind}`;
|
|
328
|
+
el.textContent = message;
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
function hideStatus(el) {
|
|
332
|
+
el.style.display = "none";
|
|
333
|
+
el.textContent = "";
|
|
334
|
+
}
|
|
335
|
+
|
|
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");
|
|
341
|
+
|
|
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');
|
|
344
|
+
|
|
345
|
+
text = text.replace(/:\s*0x([0-9a-fA-F]+)\b/g, ': "0x$1"');
|
|
346
|
+
|
|
347
|
+
return text;
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
function parseInput(text) {
|
|
351
|
+
const raw = String(text || "").trim();
|
|
352
|
+
if (!raw) return { ok: false, error: "Empty input." };
|
|
353
|
+
|
|
354
|
+
try {
|
|
355
|
+
return { ok: true, value: JSON.parse(raw), note: "Parsed as strict JSON." };
|
|
356
|
+
} catch (err1) {
|
|
357
|
+
try {
|
|
358
|
+
const normalized = normalizeLenientJson(raw);
|
|
359
|
+
const value = JSON.parse(normalized);
|
|
360
|
+
return { ok: true, value, note: "Parsed after normalizing (removed comments, quoted keys)." };
|
|
361
|
+
} catch (err2) {
|
|
362
|
+
return {
|
|
363
|
+
ok: false,
|
|
364
|
+
error:
|
|
365
|
+
"Unable to parse. Please paste strict JSON, or a JS-style object with only // comments and unquoted keys.",
|
|
366
|
+
details: String(err2 && err2.message ? err2.message : err2),
|
|
367
|
+
};
|
|
368
|
+
}
|
|
369
|
+
}
|
|
370
|
+
}
|
|
371
|
+
|
|
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
|
+
}
|
|
385
|
+
|
|
386
|
+
if (Array.isArray(value)) {
|
|
387
|
+
out.push(basePath || "(root)");
|
|
388
|
+
const limit = Math.min(20, value.length);
|
|
389
|
+
for (let i = 0; i < limit; i += 1) {
|
|
390
|
+
enumeratePaths(value[i], `${basePath}[${i}]`, out);
|
|
391
|
+
}
|
|
392
|
+
return out;
|
|
393
|
+
}
|
|
394
|
+
|
|
395
|
+
out.push(basePath || "(root)");
|
|
396
|
+
const keys = Object.keys(value).slice(0, 200);
|
|
397
|
+
for (const key of keys) {
|
|
398
|
+
const next = basePath ? `${basePath}.${key}` : key;
|
|
399
|
+
enumeratePaths(value[key], next, out);
|
|
400
|
+
}
|
|
401
|
+
return out;
|
|
402
|
+
}
|
|
403
|
+
|
|
404
|
+
function getByPath(obj, path) {
|
|
405
|
+
if (!path || path === "(root)") return obj;
|
|
406
|
+
const parts = [];
|
|
407
|
+
String(path)
|
|
408
|
+
.split(".")
|
|
409
|
+
.forEach((segment) => {
|
|
410
|
+
const m = segment.match(/^([^[\]]+)(\[(\d+)\])?$/);
|
|
411
|
+
if (!m) {
|
|
412
|
+
parts.push(segment);
|
|
413
|
+
return;
|
|
414
|
+
}
|
|
415
|
+
parts.push(m[1]);
|
|
416
|
+
if (m[2]) parts.push(Number(m[3]));
|
|
417
|
+
});
|
|
418
|
+
|
|
419
|
+
let cur = obj;
|
|
420
|
+
for (const part of parts) {
|
|
421
|
+
if (cur === null || cur === undefined) return undefined;
|
|
422
|
+
cur = cur[part];
|
|
423
|
+
}
|
|
424
|
+
return cur;
|
|
425
|
+
}
|
|
426
|
+
|
|
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
|
+
}
|
|
435
|
+
}
|
|
436
|
+
|
|
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)";
|
|
442
|
+
}
|
|
443
|
+
|
|
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;
|
|
453
|
+
}
|
|
454
|
+
|
|
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";
|
|
462
|
+
|
|
463
|
+
return {
|
|
464
|
+
id,
|
|
465
|
+
name,
|
|
466
|
+
topic,
|
|
467
|
+
type: "perimeter",
|
|
468
|
+
entry: false,
|
|
469
|
+
bypassable: true,
|
|
470
|
+
chime: false,
|
|
471
|
+
};
|
|
472
|
+
}
|
|
473
|
+
|
|
474
|
+
async function copyToClipboard(text) {
|
|
475
|
+
try {
|
|
476
|
+
await navigator.clipboard.writeText(text);
|
|
477
|
+
return true;
|
|
478
|
+
} catch (err) {
|
|
479
|
+
return false;
|
|
480
|
+
}
|
|
481
|
+
}
|
|
482
|
+
|
|
483
|
+
function renderJson(el, obj) {
|
|
484
|
+
el.style.display = "";
|
|
485
|
+
el.className = "status ok";
|
|
486
|
+
el.innerHTML = "";
|
|
487
|
+
const pre = document.createElement("pre");
|
|
488
|
+
pre.textContent = JSON.stringify(obj, null, 2);
|
|
489
|
+
el.appendChild(pre);
|
|
490
|
+
}
|
|
491
|
+
|
|
492
|
+
function renderText(el, text) {
|
|
493
|
+
el.style.display = "";
|
|
494
|
+
el.className = "status ok";
|
|
495
|
+
el.innerHTML = "";
|
|
496
|
+
const pre = document.createElement("pre");
|
|
497
|
+
pre.textContent = String(text || "");
|
|
498
|
+
el.appendChild(pre);
|
|
499
|
+
}
|
|
500
|
+
|
|
501
|
+
function parseAndPopulate() {
|
|
502
|
+
hideStatus(els.parseStatus);
|
|
503
|
+
hideStatus(els.genStatus);
|
|
504
|
+
els.zoneOutput.value = "";
|
|
505
|
+
els.btnCopyZone.disabled = true;
|
|
506
|
+
lastGenerated = null;
|
|
507
|
+
|
|
508
|
+
const result = parseInput(els.input.value);
|
|
509
|
+
if (!result.ok) {
|
|
510
|
+
parsedObject = null;
|
|
511
|
+
showStatus(els.parseStatus, "err", `${result.error}${result.details ? "\n" + result.details : ""}`);
|
|
512
|
+
return;
|
|
513
|
+
}
|
|
514
|
+
|
|
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
|
+
}
|
|
526
|
+
|
|
527
|
+
const paths = Array.from(new Set(enumeratePaths(parsedObject))).filter(Boolean);
|
|
528
|
+
paths.sort((a, b) => a.localeCompare(b));
|
|
529
|
+
|
|
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"]);
|
|
533
|
+
|
|
534
|
+
setSelectOptions(els.topicPath, paths, defaultTopic);
|
|
535
|
+
setSelectOptions(els.valuePath, paths, defaultValue);
|
|
536
|
+
setSelectOptions(els.namePath, paths, defaultName);
|
|
537
|
+
}
|
|
538
|
+
|
|
539
|
+
function generate() {
|
|
540
|
+
hideStatus(els.genStatus);
|
|
541
|
+
if (!parsedObject) {
|
|
542
|
+
showStatus(els.genStatus, "err", "Parse a valid message first.");
|
|
543
|
+
return;
|
|
544
|
+
}
|
|
545
|
+
|
|
546
|
+
const topicPath = els.topicPath.value;
|
|
547
|
+
const valuePath = els.valuePath.value;
|
|
548
|
+
const namePath = els.namePath.value;
|
|
549
|
+
|
|
550
|
+
const topicValue = getByPath(parsedObject, topicPath);
|
|
551
|
+
const valueValue = getByPath(parsedObject, valuePath);
|
|
552
|
+
const nameValue = getByPath(parsedObject, namePath);
|
|
553
|
+
|
|
554
|
+
if (topicValue === undefined) {
|
|
555
|
+
showStatus(els.genStatus, "err", `Topic path "${topicPath}" is undefined in the input message.`);
|
|
556
|
+
return;
|
|
557
|
+
}
|
|
558
|
+
|
|
559
|
+
const normalized = { ...parsedObject, topic: String(topicValue), payload: valueValue };
|
|
560
|
+
const zone = buildZoneTemplate(normalized, nameValue);
|
|
561
|
+
els.zoneOutput.value = JSON.stringify(zone, null, 2);
|
|
562
|
+
|
|
563
|
+
els.btnCopyZone.disabled = false;
|
|
564
|
+
|
|
565
|
+
lastGenerated = { normalized, zone };
|
|
566
|
+
showStatus(els.genStatus, "ok", `Generated using topic="${toOneLine(topicValue)}" and value="${valuePath}".`);
|
|
567
|
+
}
|
|
568
|
+
|
|
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
|
+
</html>
|