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

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -42,7 +42,6 @@
42
42
  "name": "Home Alarm",
43
43
  "controlTopic": "alarm",
44
44
  "payloadPropName": "payload",
45
- "translatorConfig": "",
46
45
  "persistState": true,
47
46
  "requireCodeForArm": false,
48
47
  "requireCodeForDisarm": false,
@@ -93,7 +92,8 @@
93
92
  "fwdInMessages": false,
94
93
  "resendOnRefresh": true,
95
94
  "templateScope": "local",
96
- "className": ""
95
+ "className": "",
96
+ "x": 520,
97
+ "y": 160
97
98
  }
98
99
  ]
99
-
@@ -6,7 +6,6 @@
6
6
  name: { value: "" },
7
7
  controlTopic: { value: "alarm" },
8
8
  payloadPropName: { value: "payload", required: false },
9
- translatorConfig: { type: "translator-config", required: false },
10
9
  persistState: { value: true },
11
10
  requireCodeForArm: { value: false },
12
11
  requireCodeForDisarm: { value: true },
@@ -58,6 +57,12 @@
58
57
  },
59
58
  oneditprepare: function () {
60
59
  const nodeId = this.id;
60
+ const origin = window.location.origin;
61
+ const openerOrigin = origin;
62
+ const bc =
63
+ typeof BroadcastChannel === "function"
64
+ ? new BroadcastChannel("alarm-ultimate-zones")
65
+ : null;
61
66
  const payloadField = $("#node-input-payloadPropName");
62
67
  if (payloadField.val() === "") payloadField.val("payload");
63
68
  payloadField.typedInput({ default: "msg", types: ["msg"] });
@@ -81,7 +86,7 @@
81
86
  );
82
87
 
83
88
  const zonesField = $("#node-input-zones");
84
- zonesField.css("font-family", "monospace");
89
+ const zonesSummary = $("#node-input-zones-summary");
85
90
 
86
91
  function parseZonesText(text) {
87
92
  const raw = String(text || "").trim();
@@ -116,42 +121,23 @@
116
121
  return zones;
117
122
  }
118
123
 
119
- $("#node-input-zones-format").on("click", function (evt) {
120
- evt.preventDefault();
124
+ function updateZonesSummary() {
121
125
  try {
122
126
  const zones = parseZonesText(zonesField.val());
123
- zonesField.val(zones.length ? JSON.stringify(zones, null, 2) : "");
127
+ zonesSummary.text(zones.length ? `${zones.length} zones configured` : "No zones configured");
124
128
  } catch (err) {
125
- RED.notify(`Zones: ${err.message}`, "error");
129
+ zonesSummary.text("Zones: invalid JSON");
126
130
  }
127
- });
128
-
129
- $("#node-input-zones-legacy").on("click", function (evt) {
130
- evt.preventDefault();
131
- try {
132
- const zones = parseZonesText(zonesField.val());
133
- const legacy = zones
134
- .map((zone) => JSON.stringify(zone))
135
- .filter(
136
- (line) => line !== undefined && line !== null && line !== "",
137
- )
138
- .join("\n");
139
- zonesField.val(legacy);
140
- } catch (err) {
141
- RED.notify(`Zones: ${err.message}`, "error");
142
- }
143
- });
131
+ }
144
132
 
145
- $("#node-input-zones-mapper").on("click", function (evt) {
146
- evt.preventDefault();
147
- const httpAdminRoot = (RED.settings && RED.settings.httpAdminRoot) || "/";
148
- const root = httpAdminRoot.endsWith("/") ? httpAdminRoot : `${httpAdminRoot}/`;
149
- window.open(
150
- `${root}alarm-ultimate/alarm-json-mapper`,
151
- "_blank",
152
- "noopener,noreferrer",
153
- );
154
- });
133
+ function openZonesManager() {
134
+ const httpAdminRoot = (RED.settings && RED.settings.httpAdminRoot) || "/";
135
+ const root = httpAdminRoot.endsWith("/") ? httpAdminRoot : `${httpAdminRoot}/`;
136
+ const currentName = String($("#node-input-name").val() || "").trim();
137
+ const namePart = currentName ? `&name=${encodeURIComponent(currentName)}` : "";
138
+ const idPart = nodeId ? `?id=${encodeURIComponent(nodeId)}${namePart}` : "";
139
+ window.open(`${root}alarm-ultimate/alarm-json-mapper${idPart}`, "_blank");
140
+ }
155
141
 
156
142
  $("#node-input-zones-panel").on("click", function (evt) {
157
143
  evt.preventDefault();
@@ -164,8 +150,99 @@
164
150
  "noopener,noreferrer",
165
151
  );
166
152
  });
153
+
154
+ $("#node-input-zones-manage").on("click", function (evt) {
155
+ evt.preventDefault();
156
+ openZonesManager();
157
+ });
158
+
159
+ const messageListener = (evt) => {
160
+ if (!evt || evt.origin !== openerOrigin) return;
161
+ const data = evt.data && typeof evt.data === "object" ? evt.data : null;
162
+ if (!data || typeof data.type !== "string") return;
163
+ if (data.nodeId && data.nodeId !== nodeId) return;
164
+
165
+ if (data.type === "alarm-ultimate-request-zones") {
166
+ let zonesJson = "";
167
+ try {
168
+ const zones = parseZonesText(zonesField.val());
169
+ zonesJson = zones.length ? JSON.stringify(zones, null, 2) : "";
170
+ } catch (_err) {
171
+ zonesJson = "";
172
+ }
173
+ try {
174
+ evt.source.postMessage(
175
+ {
176
+ type: "alarm-ultimate-zones",
177
+ nodeId,
178
+ nodeName: String($("#node-input-name").val() || "").trim() || String(this.name || ""),
179
+ zonesJson,
180
+ },
181
+ openerOrigin,
182
+ );
183
+ } catch (_err) {
184
+ // ignore
185
+ }
186
+ try {
187
+ if (bc) {
188
+ bc.postMessage({
189
+ type: "alarm-ultimate-zones",
190
+ nodeId,
191
+ nodeName: String($("#node-input-name").val() || "").trim() || String(this.name || ""),
192
+ zonesJson,
193
+ });
194
+ }
195
+ } catch (_err) {
196
+ // ignore
197
+ }
198
+ return;
199
+ }
200
+
201
+ if (data.type === "alarm-ultimate-zones" && typeof data.zonesJson === "string") {
202
+ zonesField.val(String(data.zonesJson || ""));
203
+ try {
204
+ zonesField.trigger("change");
205
+ } catch (_err) {
206
+ // ignore
207
+ }
208
+ try {
209
+ if (RED && RED.nodes && typeof RED.nodes.dirty === "function") {
210
+ RED.nodes.dirty(true);
211
+ }
212
+ } catch (_err) {
213
+ // ignore
214
+ }
215
+ updateZonesSummary();
216
+ }
217
+ };
218
+
219
+ window.addEventListener("message", messageListener);
220
+ if (bc) {
221
+ bc.addEventListener("message", (ev) => {
222
+ const data = ev && ev.data && typeof ev.data === "object" ? ev.data : null;
223
+ if (!data || typeof data.type !== "string") return;
224
+ if (data.nodeId && data.nodeId !== nodeId) return;
225
+ // Reuse the same handler surface by calling it with a minimal shape.
226
+ messageListener({ origin: openerOrigin, data, source: null });
227
+ });
228
+ this._alarmUltimateZonesBroadcast = bc;
229
+ }
230
+ this._alarmUltimateZonesMessageListener = messageListener;
231
+ updateZonesSummary();
167
232
  },
168
233
  oneditsave: function () {
234
+ if (this._alarmUltimateZonesMessageListener) {
235
+ window.removeEventListener("message", this._alarmUltimateZonesMessageListener);
236
+ this._alarmUltimateZonesMessageListener = null;
237
+ }
238
+ if (this._alarmUltimateZonesBroadcast) {
239
+ try {
240
+ this._alarmUltimateZonesBroadcast.close();
241
+ } catch (_err) {
242
+ // ignore
243
+ }
244
+ this._alarmUltimateZonesBroadcast = null;
245
+ }
169
246
  this.sirenOnPayloadType = $("#node-input-sirenOnPayload").typedInput(
170
247
  "type",
171
248
  );
@@ -173,10 +250,55 @@
173
250
  "type",
174
251
  );
175
252
  },
253
+ oneditcancel: function () {
254
+ if (this._alarmUltimateZonesMessageListener) {
255
+ window.removeEventListener("message", this._alarmUltimateZonesMessageListener);
256
+ this._alarmUltimateZonesMessageListener = null;
257
+ }
258
+ if (this._alarmUltimateZonesBroadcast) {
259
+ try {
260
+ this._alarmUltimateZonesBroadcast.close();
261
+ } catch (_err) {
262
+ // ignore
263
+ }
264
+ this._alarmUltimateZonesBroadcast = null;
265
+ }
266
+ },
176
267
  });
177
268
  </script>
178
269
 
179
270
  <script type="text/html" data-template-name="AlarmSystemUltimate">
271
+ <style>
272
+ .alarm-ultimate-editor .red-ui-button.au-btn {
273
+ border-radius: 8px;
274
+ font-weight: 600;
275
+ border-width: 1px;
276
+ }
277
+
278
+ .alarm-ultimate-editor .red-ui-button.au-btn i {
279
+ margin-right: 4px;
280
+ }
281
+
282
+ .alarm-ultimate-editor .red-ui-button.au-btn-primary {
283
+ background: #2563eb !important;
284
+ border-color: #1d4ed8 !important;
285
+ color: #fff !important;
286
+ }
287
+ .alarm-ultimate-editor .red-ui-button.au-btn-primary:hover {
288
+ background: #1d4ed8 !important;
289
+ }
290
+
291
+ .alarm-ultimate-editor .red-ui-button.au-btn-success {
292
+ background: #16a34a !important;
293
+ border-color: #15803d !important;
294
+ color: #fff !important;
295
+ }
296
+ .alarm-ultimate-editor .red-ui-button.au-btn-success:hover {
297
+ background: #15803d !important;
298
+ }
299
+ </style>
300
+
301
+ <div class="alarm-ultimate-editor">
180
302
  <div class="form-row">
181
303
  <b>Alarm System Ultimate (BETA)</b>
182
304
  &nbsp;&nbsp;<span style="color:red"
@@ -190,7 +312,7 @@
190
312
 
191
313
  <div class="form-row">
192
314
  <label>WEB PAGE</label>
193
- <button type="button" class="red-ui-button" id="node-input-zones-panel">
315
+ <button type="button" class="red-ui-button au-btn au-btn-primary" id="node-input-zones-panel">
194
316
  <i class="fa fa-keyboard-o"></i> Panel
195
317
  </button>
196
318
  </div>
@@ -217,14 +339,6 @@
217
339
  <input type="text" id="node-input-payloadPropName" />
218
340
  </div>
219
341
 
220
- <div class="form-row">
221
- <label
222
- for="node-input-translatorConfig"
223
- ><i class="fa fa-language"></i> Translator</label
224
- >
225
- <input type="text" id="node-input-translatorConfig" />
226
- </div>
227
-
228
342
  <div class="form-row">
229
343
  <label for="node-input-persistState"
230
344
  ><i class="fa fa-database"></i> Persist state</label
@@ -434,42 +548,16 @@
434
548
  </div>
435
549
 
436
550
  <div class="form-row">
437
- <label for="node-input-zones"
438
- ><i class="fa fa-th-large"></i> Zones (JSON per line or JSON
439
- array)</label
440
- >
441
- <textarea
442
- id="node-input-zones"
443
- spellcheck="false"
444
- wrap="off"
445
- style="width:100%; height:220px; font-family:monospace;"
446
- placeholder='{"id":"front_door","name":"Front door","topic":"sensor/frontdoor","type":"perimeter","entry":true,"bypassable":true,"chime":true}\n{"id":"pir_living","name":"Living PIR","topic":"sensor/living_pir","type":"motion","entry":false,"bypassable":true,"cooldownSeconds":10}'
447
- ></textarea>
551
+ <label><i class="fa fa-th-large"></i> Zones</label>
552
+ <button type="button" class="red-ui-button au-btn au-btn-success" id="node-input-zones-manage">
553
+ <i class="fa fa-list"></i> Manage zones
554
+ </button>
555
+ <span id="node-input-zones-summary" style="margin-left:8px; color:#777;"></span>
448
556
  </div>
449
557
 
450
- <div class="form-row">
451
- <label>&nbsp;</label>
452
- <button type="button" class="red-ui-button" id="node-input-zones-format">
453
- <i class="fa fa-indent"></i> Format
454
- </button>
455
- <button
456
- type="button"
457
- class="red-ui-button"
458
- id="node-input-zones-legacy"
459
- style="margin-left:5px;"
460
- >
461
- <i class="fa fa-list"></i> Legacy
462
- </button>
463
- <button
464
- type="button"
465
- class="red-ui-button"
466
- id="node-input-zones-mapper"
467
- style="margin-left:5px;"
468
- >
469
- <i class="fa fa-external-link"></i> Mapper
470
- </button>
471
- </div>
472
- </script>
558
+ <textarea id="node-input-zones" style="display:none;"></textarea>
559
+ </div>
560
+ </script>
473
561
 
474
562
  <script type="text/markdown" data-help-name="AlarmSystemUltimate">
475
563
  <p><b>BETA</b> – Alarm control panel node with a single armed/disarmed state, zones, entry/exit delays, bypass, chime, tamper/fire/24h zones, siren control, status and event log.</p>
@@ -477,7 +565,7 @@
477
565
  The node has 2 inputs "in one":
478
566
 
479
567
  - **Control messages**: when `msg.topic` equals **Control topic**.
480
- - **Sensor messages**: any other message, matched to a zone by `msg.topic` and converted to boolean using **With Input** (default `payload`) and the optional **Translator**.
568
+ - **Sensor messages**: any other message, matched to a zone by `msg.topic` and converted to boolean using **With Input** (default `payload`).
481
569
 
482
570
  <br/>
483
571
 
@@ -485,7 +573,6 @@
485
573
  | ---------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------ |
486
574
  | Control topic | Topic that receives runtime commands (arm/disarm/status/bypass/siren/panic/reset). |
487
575
  | With Input | Message property evaluated as sensor value (default `payload`). |
488
- | Translator | Optional translator-config for true/false conversion (useful for Home Assistant strings). |
489
576
  | Persist state | Persists arming state, bypass list and event log across restarts. |
490
577
  | Require code for arm | Requires a valid PIN for arming commands (via `msg.code` or `msg.pin`). If **Code** is empty, commands are allowed. |
491
578
  | Require code for disarm | Requires a valid PIN for disarming commands (via `msg.code` or `msg.pin`). If **Code** is empty, commands are allowed. |
@@ -503,13 +590,13 @@
503
590
  | Latch siren until disarm | Forces siren to remain on until disarm (ignores duration). |
504
591
  | Emit restore events | Emits `zone_restore` when a zone returns to false. |
505
592
  | Event log size | Max stored log entries in node context (0 disables log). |
506
- | Zones | Zone definitions. Supports legacy "one JSON object per line" or a JSON array (use **Format** / **Legacy** buttons). |
593
+ | Zones | Zone definitions. Use **Manage zones** to edit with the web tool (advanced JSON is available there). |
507
594
 
508
595
  <br/>
509
596
 
510
597
  ### Web tools
511
598
 
512
- - **Zones JSON Mapper**: available at `/alarm-ultimate/alarm-json-mapper` (or via the **Mapper** button).
599
+ - **Zones Manager**: available at `/alarm-ultimate/alarm-json-mapper` (or via the **Manage zones** button).
513
600
  - **Alarm Panel**: available at `/alarm-ultimate/alarm-panel` (or via the **Panel** button).
514
601
  - The Alarm Panel supports `?id=<alarmNodeId>` and an embed mode for Dashboard: `?embed=1&id=<alarmNodeId>`.
515
602
 
@@ -544,7 +631,7 @@
544
631
 
545
632
  **Output 5 (Zone Activity)**
546
633
 
547
- - Same message as Output 1, only for zone events (`bypassed`, `unbypassed`, `chime`, `zone_ignored_exit`, `zone_bypassed_trigger`, `zone_restore`).
634
+ - Same message as Output 1, only for zone events (`zone_open`, `zone_close`, `bypassed`, `unbypassed`, `chime`, `zone_ignored_exit`, `zone_bypassed_trigger`, `zone_restore`).
548
635
 
549
636
  **Output 6 (Errors/Denied)**
550
637
 
@@ -582,9 +669,11 @@
582
669
 
583
670
  Zones / alarm:
584
671
 
585
- - `entry_delay` → `{ zone:{id,name}, seconds }`
672
+ - `entry_delay` → `{ zone:{id,name,type,topic}, seconds }`
586
673
  - `alarm` → `{ kind, zone, silent }` where `kind` can be `instant|entry_timeout|panic|duress|fire|tamper|24h|...`
587
674
  - `chime` → `{ zone:{id,name,type} }` (while disarmed)
675
+ - `zone_open` → `{ zone:{id,name,type,topic}, open:true, bypassed }` (always)
676
+ - `zone_close` → `{ zone:{id,name,type,topic}, open:false, bypassed }` (always)
588
677
  - `zone_ignored_exit` → `{ zone:{id,name,type} }` (triggered during exit delay and not allowed)
589
678
  - `zone_bypassed_trigger` → `{ zone:{id,name,type} }`
590
679
  - `zone_restore` → `{ zone:{id,name,type} }` (when enabled)
@@ -88,7 +88,8 @@ module.exports = function (RED) {
88
88
  try {
89
89
  const body = req.body && typeof req.body === 'object' ? req.body : {};
90
90
  api.command(body);
91
- res.json({ ok: true });
91
+ const snapshot = api.getState && typeof api.getState === 'function' ? api.getState() : null;
92
+ res.json({ ok: true, result: snapshot });
92
93
  } catch (err) {
93
94
  res.status(500).json({ ok: false, error: err.message });
94
95
  }
@@ -255,6 +256,8 @@ module.exports = function (RED) {
255
256
  'bypassed',
256
257
  'unbypassed',
257
258
  'chime',
259
+ 'zone_open',
260
+ 'zone_close',
258
261
  'zone_ignored_exit',
259
262
  'zone_bypassed_trigger',
260
263
  'zone_restore',
@@ -550,6 +553,22 @@ module.exports = function (RED) {
550
553
  let shape = 'ring';
551
554
  let text = 'DISARMED';
552
555
 
556
+ // When idle/disarmed, show recent arming errors to make the reason visible in the editor status.
557
+ if (!state.alarmActive && !state.entry && !state.arming && state.mode === 'disarmed') {
558
+ const last = Array.isArray(state.log) && state.log.length ? state.log[state.log.length - 1] : null;
559
+ const evt = last && typeof last.event === 'string' ? last.event : '';
560
+ if (evt === 'arm_blocked') {
561
+ const violations = Array.isArray(last.violations) ? last.violations.length : 0;
562
+ fill = 'yellow';
563
+ shape = 'ring';
564
+ text = `ARM BLOCKED${violations ? ` (${violations})` : ''}`;
565
+ } else if (evt === 'denied' && last && String(last.action || '') === 'arm') {
566
+ fill = 'red';
567
+ shape = 'ring';
568
+ text = 'ARM DENIED';
569
+ }
570
+ }
571
+
553
572
  if (state.alarmActive) {
554
573
  fill = 'red';
555
574
  shape = 'dot';
@@ -1107,7 +1126,7 @@ module.exports = function (RED) {
1107
1126
  }
1108
1127
  const until = now() + delay;
1109
1128
  state.entry = { zoneId: zone.id, until };
1110
- emitEvent('entry_delay', { zone: { id: zone.id, name: zone.name }, seconds: remainingSeconds(until) }, baseMsg);
1129
+ emitEvent('entry_delay', { zone: buildZoneSummary(zone), seconds: remainingSeconds(until) }, baseMsg);
1111
1130
  startStatusInterval();
1112
1131
  clearEntryTimer();
1113
1132
  entryTimer = timerBag.setTimeout(() => {
@@ -1177,7 +1196,7 @@ module.exports = function (RED) {
1177
1196
  }
1178
1197
  state.bypass[id] = Boolean(enabled);
1179
1198
  persist();
1180
- emitEvent(enabled ? 'bypassed' : 'unbypassed', { zone: { id: zone.id, name: zone.name } }, baseMsg);
1199
+ emitEvent(enabled ? 'bypassed' : 'unbypassed', { zone: buildZoneSummary(zone) }, baseMsg);
1181
1200
  }
1182
1201
 
1183
1202
  function handleControlMessage(msg) {
@@ -1278,7 +1297,7 @@ module.exports = function (RED) {
1278
1297
  if (!zone) {
1279
1298
  return;
1280
1299
  }
1281
- const resolved = helpers.resolveInput(msg, payloadPropName, config.translatorConfig, RED);
1300
+ const resolved = helpers.resolveInput(msg, payloadPropName, null, RED);
1282
1301
  const value = resolved.boolean;
1283
1302
  if (value === undefined) {
1284
1303
  return;
@@ -1290,8 +1309,20 @@ module.exports = function (RED) {
1290
1309
  zoneMeta.lastChangeAt = now();
1291
1310
  state.zoneState[zone.id] = zoneMeta;
1292
1311
 
1312
+ if (changed) {
1313
+ emitEvent(
1314
+ value === true ? 'zone_open' : 'zone_close',
1315
+ {
1316
+ zone: buildZoneSummary(zone),
1317
+ open: value === true,
1318
+ bypassed: state.bypass[zone.id] === true,
1319
+ },
1320
+ msg
1321
+ );
1322
+ }
1323
+
1293
1324
  if (changed && emitRestoreEvents && value === false) {
1294
- emitEvent('zone_restore', { zone: { id: zone.id, name: zone.name, type: zone.type } }, msg);
1325
+ emitEvent('zone_restore', { zone: buildZoneSummary(zone) }, msg);
1295
1326
  }
1296
1327
 
1297
1328
  if (changed) {
@@ -1317,7 +1348,7 @@ module.exports = function (RED) {
1317
1348
  }
1318
1349
 
1319
1350
  if (state.bypass[zone.id] === true && zone.bypassable !== false) {
1320
- emitEvent('zone_bypassed_trigger', { zone: { id: zone.id, name: zone.name, type: zone.type } }, msg);
1351
+ emitEvent('zone_bypassed_trigger', { zone: buildZoneSummary(zone) }, msg);
1321
1352
  return;
1322
1353
  }
1323
1354
 
@@ -1335,13 +1366,13 @@ module.exports = function (RED) {
1335
1366
  }
1336
1367
 
1337
1368
  if (state.arming && !zone.instantDuringExit) {
1338
- emitEvent('zone_ignored_exit', { zone: { id: zone.id, name: zone.name, type: zone.type } }, msg);
1369
+ emitEvent('zone_ignored_exit', { zone: buildZoneSummary(zone) }, msg);
1339
1370
  return;
1340
1371
  }
1341
1372
 
1342
1373
  if (state.mode === 'disarmed') {
1343
1374
  if (zone.chime) {
1344
- emitEvent('chime', { zone: { id: zone.id, name: zone.name, type: zone.type } }, msg);
1375
+ emitEvent('chime', { zone: buildZoneSummary(zone) }, msg);
1345
1376
  }
1346
1377
  return;
1347
1378
  }