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

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.
@@ -2,16 +2,16 @@
2
2
  RED.nodes.registerType("AlarmSystemUltimate", {
3
3
  category: "Alarm Ultimate",
4
4
  color: "#A8DADC",
5
- defaults: {
6
- name: { value: "" },
7
- controlTopic: { value: "alarm" },
8
- payloadPropName: { value: "payload", required: false },
9
- translatorConfig: { type: "translator-config", required: false },
10
- persistState: { value: true },
11
- requireCodeForArm: { value: false },
12
- requireCodeForDisarm: { value: true },
13
- armCode: { value: "" },
14
- duressCode: { value: "" },
5
+ defaults: {
6
+ name: { value: "" },
7
+ controlTopic: { value: "alarm" },
8
+ payloadPropName: { value: "payload", required: false },
9
+ persistState: { value: true },
10
+ syncTargets: { value: "" },
11
+ requireCodeForArm: { value: false },
12
+ requireCodeForDisarm: { value: true },
13
+ armCode: { value: "" },
14
+ duressCode: { value: "" },
15
15
  blockArmOnViolations: { value: true },
16
16
  exitDelaySeconds: { value: 30, validate: RED.validators.number() },
17
17
  entryDelaySeconds: { value: 30, validate: RED.validators.number() },
@@ -56,8 +56,14 @@
56
56
  paletteLabel: function () {
57
57
  return "Alarm System (BETA)";
58
58
  },
59
- oneditprepare: function () {
60
- const nodeId = this.id;
59
+ oneditprepare: function () {
60
+ const nodeId = this.id;
61
+ const origin = window.location.origin;
62
+ const openerOrigin = origin;
63
+ const bc =
64
+ typeof BroadcastChannel === "function"
65
+ ? new BroadcastChannel("alarm-ultimate-zones")
66
+ : null;
61
67
  const payloadField = $("#node-input-payloadPropName");
62
68
  if (payloadField.val() === "") payloadField.val("payload");
63
69
  payloadField.typedInput({ default: "msg", types: ["msg"] });
@@ -80,10 +86,12 @@
80
86
  this.sirenOffPayloadType || "bool",
81
87
  );
82
88
 
83
- const zonesField = $("#node-input-zones");
84
- zonesField.css("font-family", "monospace");
89
+ const zonesField = $("#node-input-zones");
90
+ const zonesSummary = $("#node-input-zones-summary");
91
+ const syncTargetsField = $("#node-input-syncTargets");
92
+ const syncTargetsUi = $("#node-input-syncTargets-ui");
85
93
 
86
- function parseZonesText(text) {
94
+ function parseZonesText(text) {
87
95
  const raw = String(text || "").trim();
88
96
  if (!raw) return [];
89
97
 
@@ -114,44 +122,147 @@
114
122
  }
115
123
  }
116
124
  return zones;
117
- }
118
-
119
- $("#node-input-zones-format").on("click", function (evt) {
120
- evt.preventDefault();
121
- try {
122
- const zones = parseZonesText(zonesField.val());
123
- zonesField.val(zones.length ? JSON.stringify(zones, null, 2) : "");
124
- } catch (err) {
125
- RED.notify(`Zones: ${err.message}`, "error");
126
- }
127
- });
128
-
129
- $("#node-input-zones-legacy").on("click", function (evt) {
130
- evt.preventDefault();
125
+ }
126
+
127
+ function parseSyncTargets(text) {
128
+ const raw = String(text || "").trim();
129
+ if (!raw) return {};
130
+ try {
131
+ const parsed = JSON.parse(raw);
132
+ return parsed && typeof parsed === "object" && !Array.isArray(parsed) ? parsed : {};
133
+ } catch (_err) {
134
+ return {};
135
+ }
136
+ }
137
+
138
+ function normalizeSyncAction(value) {
139
+ const v = String(value || "").trim().toLowerCase();
140
+ if (v === "arm") return "arm";
141
+ if (v === "disarm") return "disarm";
142
+ return "leave";
143
+ }
144
+
145
+ function syncActionLabel(value) {
146
+ const v = normalizeSyncAction(value);
147
+ if (v === "arm") return "Arm";
148
+ if (v === "disarm") return "Disarm";
149
+ return "Leave as is";
150
+ }
151
+
152
+ function escapeHtml(text) {
153
+ return String(text || "")
154
+ .replace(/&/g, "&")
155
+ .replace(/</g, "&lt;")
156
+ .replace(/>/g, "&gt;")
157
+ .replace(/"/g, "&quot;")
158
+ .replace(/'/g, "&#039;");
159
+ }
160
+
161
+ function buildSyncTargetsUi() {
162
+ if (!syncTargetsUi || !syncTargetsUi.length) return;
163
+ try {
164
+ const saved = parseSyncTargets(syncTargetsField.val());
165
+
166
+ // Collect other Alarm nodes in the workspace.
167
+ const other = [];
168
+ try {
169
+ if (RED && RED.nodes && typeof RED.nodes.eachNode === "function") {
170
+ RED.nodes.eachNode((n) => {
171
+ if (!n) return;
172
+ const type = String(n.type || "");
173
+ if (type !== "AlarmSystemUltimate") return;
174
+ if (n.id === nodeId) return;
175
+ other.push({ id: n.id, name: String(n.name || "").trim() });
176
+ });
177
+ }
178
+ } catch (_err) {}
179
+
180
+ other.sort((a, b) => {
181
+ const an = (a.name || a.id).toLowerCase();
182
+ const bn = (b.name || b.id).toLowerCase();
183
+ return an.localeCompare(bn);
184
+ });
185
+
186
+ if (other.length === 0) {
187
+ syncTargetsUi.html('<span style="color:#777;">No other Alarm nodes found.</span>');
188
+ return;
189
+ }
190
+
191
+ const table = $('<table style="width:100%; border-collapse:collapse;"></table>');
192
+ table.append(
193
+ '<thead><tr>' +
194
+ '<th style="text-align:left; padding:4px 6px;">Alarm</th>' +
195
+ '<th style="text-align:left; padding:4px 6px; width:160px;">On ARM</th>' +
196
+ '<th style="text-align:left; padding:4px 6px; width:160px;">On DISARM</th>' +
197
+ '</tr></thead>',
198
+ );
199
+
200
+ const tbody = $("<tbody></tbody>");
201
+ other.forEach((n) => {
202
+ const rule = saved[n.id] && typeof saved[n.id] === "object" ? saved[n.id] : {};
203
+ const onArm = normalizeSyncAction(rule.onArm);
204
+ const onDisarm = normalizeSyncAction(rule.onDisarm);
205
+
206
+ const armSel = $('<select style="width:100%;"></select>');
207
+ const disarmSel = $('<select style="width:100%;"></select>');
208
+ ["arm", "leave", "disarm"].forEach((v) => {
209
+ armSel.append(`<option value="${v}">${syncActionLabel(v)}</option>`);
210
+ disarmSel.append(`<option value="${v}">${syncActionLabel(v)}</option>`);
211
+ });
212
+ armSel.val(onArm);
213
+ disarmSel.val(onDisarm);
214
+
215
+ const tr = $("<tr></tr>");
216
+ const label = n.name ? `${n.name}` : "(unnamed Alarm)";
217
+ tr.append(
218
+ `<td style="padding:4px 6px;">${escapeHtml(label)}</td>`,
219
+ );
220
+ const tdArm = $('<td style="padding:4px 6px;"></td>').append(armSel);
221
+ const tdDisarm = $('<td style="padding:4px 6px;"></td>').append(disarmSel);
222
+ tr.append(tdArm).append(tdDisarm);
223
+ tbody.append(tr);
224
+
225
+ function persist() {
226
+ const next = parseSyncTargets(syncTargetsField.val());
227
+ next[n.id] = {
228
+ onArm: String(armSel.val() || "leave"),
229
+ onDisarm: String(disarmSel.val() || "leave"),
230
+ };
231
+ syncTargetsField.val(JSON.stringify(next));
232
+ try {
233
+ syncTargetsField.trigger("change");
234
+ } catch (_err) {}
235
+ }
236
+
237
+ armSel.on("change", persist);
238
+ disarmSel.on("change", persist);
239
+ });
240
+ table.append(tbody);
241
+ syncTargetsUi.empty().append(table);
242
+ } catch (err) {
243
+ syncTargetsUi.html(
244
+ `<span style="color:#c00;">Unable to render sync list. Check browser console. (${escapeHtml(err && err.message ? err.message : err)})</span>`,
245
+ );
246
+ }
247
+ }
248
+
249
+ function updateZonesSummary() {
131
250
  try {
132
251
  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);
252
+ zonesSummary.text(zones.length ? `${zones.length} zones configured` : "No zones configured");
140
253
  } catch (err) {
141
- RED.notify(`Zones: ${err.message}`, "error");
254
+ zonesSummary.text("Zones: invalid JSON");
142
255
  }
143
- });
256
+ }
144
257
 
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
- });
258
+ function openZonesManager() {
259
+ const httpAdminRoot = (RED.settings && RED.settings.httpAdminRoot) || "/";
260
+ const root = httpAdminRoot.endsWith("/") ? httpAdminRoot : `${httpAdminRoot}/`;
261
+ const currentName = String($("#node-input-name").val() || "").trim();
262
+ const namePart = currentName ? `&name=${encodeURIComponent(currentName)}` : "";
263
+ const idPart = nodeId ? `?id=${encodeURIComponent(nodeId)}${namePart}` : "";
264
+ window.open(`${root}alarm-ultimate/alarm-json-mapper${idPart}`, "_blank");
265
+ }
155
266
 
156
267
  $("#node-input-zones-panel").on("click", function (evt) {
157
268
  evt.preventDefault();
@@ -164,8 +275,100 @@
164
275
  "noopener,noreferrer",
165
276
  );
166
277
  });
167
- },
278
+
279
+ $("#node-input-zones-manage").on("click", function (evt) {
280
+ evt.preventDefault();
281
+ openZonesManager();
282
+ });
283
+
284
+ const messageListener = (evt) => {
285
+ if (!evt || evt.origin !== openerOrigin) return;
286
+ const data = evt.data && typeof evt.data === "object" ? evt.data : null;
287
+ if (!data || typeof data.type !== "string") return;
288
+ if (data.nodeId && data.nodeId !== nodeId) return;
289
+
290
+ if (data.type === "alarm-ultimate-request-zones") {
291
+ let zonesJson = "";
292
+ try {
293
+ const zones = parseZonesText(zonesField.val());
294
+ zonesJson = zones.length ? JSON.stringify(zones, null, 2) : "";
295
+ } catch (_err) {
296
+ zonesJson = "";
297
+ }
298
+ try {
299
+ evt.source.postMessage(
300
+ {
301
+ type: "alarm-ultimate-zones",
302
+ nodeId,
303
+ nodeName: String($("#node-input-name").val() || "").trim() || String(this.name || ""),
304
+ zonesJson,
305
+ },
306
+ openerOrigin,
307
+ );
308
+ } catch (_err) {
309
+ // ignore
310
+ }
311
+ try {
312
+ if (bc) {
313
+ bc.postMessage({
314
+ type: "alarm-ultimate-zones",
315
+ nodeId,
316
+ nodeName: String($("#node-input-name").val() || "").trim() || String(this.name || ""),
317
+ zonesJson,
318
+ });
319
+ }
320
+ } catch (_err) {
321
+ // ignore
322
+ }
323
+ return;
324
+ }
325
+
326
+ if (data.type === "alarm-ultimate-zones" && typeof data.zonesJson === "string") {
327
+ zonesField.val(String(data.zonesJson || ""));
328
+ try {
329
+ zonesField.trigger("change");
330
+ } catch (_err) {
331
+ // ignore
332
+ }
333
+ try {
334
+ if (RED && RED.nodes && typeof RED.nodes.dirty === "function") {
335
+ RED.nodes.dirty(true);
336
+ }
337
+ } catch (_err) {
338
+ // ignore
339
+ }
340
+ updateZonesSummary();
341
+ }
342
+ };
343
+
344
+ window.addEventListener("message", messageListener);
345
+ if (bc) {
346
+ bc.addEventListener("message", (ev) => {
347
+ const data = ev && ev.data && typeof ev.data === "object" ? ev.data : null;
348
+ if (!data || typeof data.type !== "string") return;
349
+ if (data.nodeId && data.nodeId !== nodeId) return;
350
+ // Reuse the same handler surface by calling it with a minimal shape.
351
+ messageListener({ origin: openerOrigin, data, source: null });
352
+ });
353
+ this._alarmUltimateZonesBroadcast = bc;
354
+ }
355
+ this._alarmUltimateZonesMessageListener = messageListener;
356
+ updateZonesSummary();
357
+ setTimeout(() => buildSyncTargetsUi(), 0);
358
+ },
168
359
  oneditsave: function () {
360
+ if (this._alarmUltimateZonesMessageListener) {
361
+ window.removeEventListener("message", this._alarmUltimateZonesMessageListener);
362
+ this._alarmUltimateZonesMessageListener = null;
363
+ }
364
+ if (this._alarmUltimateZonesBroadcast) {
365
+ try {
366
+ this._alarmUltimateZonesBroadcast.close();
367
+ } catch (_err) {
368
+ // ignore
369
+ }
370
+ this._alarmUltimateZonesBroadcast = null;
371
+ }
169
372
  this.sirenOnPayloadType = $("#node-input-sirenOnPayload").typedInput(
170
373
  "type",
171
374
  );
@@ -173,10 +376,55 @@
173
376
  "type",
174
377
  );
175
378
  },
379
+ oneditcancel: function () {
380
+ if (this._alarmUltimateZonesMessageListener) {
381
+ window.removeEventListener("message", this._alarmUltimateZonesMessageListener);
382
+ this._alarmUltimateZonesMessageListener = null;
383
+ }
384
+ if (this._alarmUltimateZonesBroadcast) {
385
+ try {
386
+ this._alarmUltimateZonesBroadcast.close();
387
+ } catch (_err) {
388
+ // ignore
389
+ }
390
+ this._alarmUltimateZonesBroadcast = null;
391
+ }
392
+ },
176
393
  });
177
394
  </script>
178
395
 
179
396
  <script type="text/html" data-template-name="AlarmSystemUltimate">
397
+ <style>
398
+ .alarm-ultimate-editor .red-ui-button.au-btn {
399
+ border-radius: 8px;
400
+ font-weight: 600;
401
+ border-width: 1px;
402
+ }
403
+
404
+ .alarm-ultimate-editor .red-ui-button.au-btn i {
405
+ margin-right: 4px;
406
+ }
407
+
408
+ .alarm-ultimate-editor .red-ui-button.au-btn-primary {
409
+ background: #2563eb !important;
410
+ border-color: #1d4ed8 !important;
411
+ color: #fff !important;
412
+ }
413
+ .alarm-ultimate-editor .red-ui-button.au-btn-primary:hover {
414
+ background: #1d4ed8 !important;
415
+ }
416
+
417
+ .alarm-ultimate-editor .red-ui-button.au-btn-success {
418
+ background: #16a34a !important;
419
+ border-color: #15803d !important;
420
+ color: #fff !important;
421
+ }
422
+ .alarm-ultimate-editor .red-ui-button.au-btn-success:hover {
423
+ background: #15803d !important;
424
+ }
425
+ </style>
426
+
427
+ <div class="alarm-ultimate-editor">
180
428
  <div class="form-row">
181
429
  <b>Alarm System Ultimate (BETA)</b>
182
430
  &nbsp;&nbsp;<span style="color:red"
@@ -190,15 +438,26 @@
190
438
 
191
439
  <div class="form-row">
192
440
  <label>WEB PAGE</label>
193
- <button type="button" class="red-ui-button" id="node-input-zones-panel">
441
+ <button type="button" class="red-ui-button au-btn au-btn-primary" id="node-input-zones-panel">
194
442
  <i class="fa fa-keyboard-o"></i> Panel
195
443
  </button>
196
444
  </div>
197
445
 
198
- <div class="form-row">
199
- <label for="node-input-name"
200
- ><i class="icon-tag"></i> Name</label
201
- >
446
+ <div class="form-row">
447
+ <label><i class="fa fa-random"></i> Sync other alarms</label>
448
+ <div style="width: 70%;">
449
+ <div class="form-tips" style="margin: 0 0 6px 0;">
450
+ When this Alarm is armed/disarmed, optionally arm/disarm other Alarm nodes in the workspace (codes are forwarded if present).
451
+ </div>
452
+ <div id="node-input-syncTargets-ui"></div>
453
+ <input type="hidden" id="node-input-syncTargets" />
454
+ </div>
455
+ </div>
456
+
457
+ <div class="form-row">
458
+ <label for="node-input-name"
459
+ ><i class="icon-tag"></i> Name</label
460
+ >
202
461
  <input type="text" id="node-input-name" placeholder="Name" />
203
462
  </div>
204
463
 
@@ -217,14 +476,6 @@
217
476
  <input type="text" id="node-input-payloadPropName" />
218
477
  </div>
219
478
 
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
479
  <div class="form-row">
229
480
  <label for="node-input-persistState"
230
481
  ><i class="fa fa-database"></i> Persist state</label
@@ -434,42 +685,16 @@
434
685
  </div>
435
686
 
436
687
  <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>
688
+ <label><i class="fa fa-th-large"></i> Zones</label>
689
+ <button type="button" class="red-ui-button au-btn au-btn-success" id="node-input-zones-manage">
690
+ <i class="fa fa-list"></i> Manage zones
691
+ </button>
692
+ <span id="node-input-zones-summary" style="margin-left:8px; color:#777;"></span>
448
693
  </div>
449
694
 
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>
695
+ <textarea id="node-input-zones" style="display:none;"></textarea>
696
+ </div>
697
+ </script>
473
698
 
474
699
  <script type="text/markdown" data-help-name="AlarmSystemUltimate">
475
700
  <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 +702,7 @@
477
702
  The node has 2 inputs "in one":
478
703
 
479
704
  - **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**.
705
+ - **Sensor messages**: any other message, matched to a zone by `msg.topic` and converted to boolean using **With Input** (default `payload`).
481
706
 
482
707
  <br/>
483
708
 
@@ -485,7 +710,6 @@
485
710
  | ---------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------ |
486
711
  | Control topic | Topic that receives runtime commands (arm/disarm/status/bypass/siren/panic/reset). |
487
712
  | 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
713
  | Persist state | Persists arming state, bypass list and event log across restarts. |
490
714
  | 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
715
  | Require code for disarm | Requires a valid PIN for disarming commands (via `msg.code` or `msg.pin`). If **Code** is empty, commands are allowed. |
@@ -500,16 +724,17 @@
500
724
  | Siren topic | Topic used on output 2 to turn the siren on/off (default: `controlTopic + "/siren"`). |
501
725
  | Siren ON/OFF payload | Values emitted on output 2 for siren on/off (typed). |
502
726
  | Siren duration (s) | Auto stop duration (`0` = latch until disarm). |
503
- | Latch siren until disarm | Forces siren to remain on until disarm (ignores duration). |
504
- | Emit restore events | Emits `zone_restore` when a zone returns to false. |
505
- | 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). |
727
+ | Latch siren until disarm | Forces siren to remain on until disarm (ignores duration). |
728
+ | Emit restore events | Emits `zone_restore` when a zone returns to false. |
729
+ | Event log size | Max stored log entries in node context (0 disables log). |
730
+ | Sync other alarms | Optional: when this node is armed/disarmed, it can arm/disarm other Alarm nodes. The original `msg.code`/`msg.pin` are forwarded. |
731
+ | Zones | Zone definitions. Use **Manage zones** to edit with the web tool (advanced JSON is available there). |
507
732
 
508
733
  <br/>
509
734
 
510
735
  ### Web tools
511
736
 
512
- - **Zones JSON Mapper**: available at `/alarm-ultimate/alarm-json-mapper` (or via the **Mapper** button).
737
+ - **Zones Manager**: available at `/alarm-ultimate/alarm-json-mapper` (or via the **Manage zones** button).
513
738
  - **Alarm Panel**: available at `/alarm-ultimate/alarm-panel` (or via the **Panel** button).
514
739
  - The Alarm Panel supports `?id=<alarmNodeId>` and an embed mode for Dashboard: `?embed=1&id=<alarmNodeId>`.
515
740
 
@@ -544,7 +769,7 @@
544
769
 
545
770
  **Output 5 (Zone Activity)**
546
771
 
547
- - Same message as Output 1, only for zone events (`bypassed`, `unbypassed`, `chime`, `zone_ignored_exit`, `zone_bypassed_trigger`, `zone_restore`).
772
+ - 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
773
 
549
774
  **Output 6 (Errors/Denied)**
550
775
 
@@ -582,9 +807,11 @@
582
807
 
583
808
  Zones / alarm:
584
809
 
585
- - `entry_delay` → `{ zone:{id,name}, seconds }`
810
+ - `entry_delay` → `{ zone:{id,name,type,topic}, seconds }`
586
811
  - `alarm` → `{ kind, zone, silent }` where `kind` can be `instant|entry_timeout|panic|duress|fire|tamper|24h|...`
587
812
  - `chime` → `{ zone:{id,name,type} }` (while disarmed)
813
+ - `zone_open` → `{ zone:{id,name,type,topic}, open:true, bypassed }` (always)
814
+ - `zone_close` → `{ zone:{id,name,type,topic}, open:false, bypassed }` (always)
588
815
  - `zone_ignored_exit` → `{ zone:{id,name,type} }` (triggered during exit delay and not allowed)
589
816
  - `zone_bypassed_trigger` → `{ zone:{id,name,type} }`
590
817
  - `zone_restore` → `{ zone:{id,name,type} }` (when enabled)