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.
@@ -0,0 +1,1418 @@
1
+ 'use strict';
2
+
3
+ const fs = require('fs');
4
+ const path = require('path');
5
+
6
+ const { alarmInstances, alarmEmitter } = require('./lib/alarm-registry.js');
7
+
8
+ function readJsonFileSync(filePath) {
9
+ try {
10
+ if (!fs.existsSync(filePath)) {
11
+ return null;
12
+ }
13
+ const raw = fs.readFileSync(filePath, 'utf8');
14
+ const trimmed = String(raw || '').trim();
15
+ if (!trimmed) {
16
+ return null;
17
+ }
18
+ return JSON.parse(trimmed);
19
+ } catch (err) {
20
+ return null;
21
+ }
22
+ }
23
+
24
+ function writeJsonFileAtomicSync(filePath, data) {
25
+ const dir = path.dirname(filePath);
26
+ const tempPath = `${filePath}.tmp`;
27
+ fs.mkdirSync(dir, { recursive: true });
28
+ fs.writeFileSync(tempPath, JSON.stringify(data, null, 2), 'utf8');
29
+ fs.renameSync(tempPath, filePath);
30
+ }
31
+
32
+ module.exports = function (RED) {
33
+ const helpers = require('./lib/node-helpers.js');
34
+
35
+ if (RED && RED.httpAdmin && typeof RED.httpAdmin.get === 'function') {
36
+ const needsRead =
37
+ RED.auth && typeof RED.auth.needsPermission === 'function'
38
+ ? RED.auth.needsPermission('AlarmSystemUltimate.read')
39
+ : (req, res, next) => next();
40
+ const needsWrite =
41
+ RED.auth && typeof RED.auth.needsPermission === 'function'
42
+ ? RED.auth.needsPermission('AlarmSystemUltimate.write')
43
+ : (req, res, next) => next();
44
+
45
+ function sendToolFile(res, filename) {
46
+ const filePath = path.join(__dirname, '..', 'tools', filename);
47
+ res.set('Cache-Control', 'no-store, max-age=0');
48
+ res.set('Pragma', 'no-cache');
49
+ res.sendFile(filePath, (err) => {
50
+ if (err) {
51
+ res.status(err.statusCode || 500).end();
52
+ }
53
+ });
54
+ }
55
+
56
+ RED.httpAdmin.get('/alarm-ultimate/alarm-json-mapper', needsRead, (req, res) => {
57
+ sendToolFile(res, 'alarm-json-mapper.html');
58
+ });
59
+
60
+ RED.httpAdmin.get('/alarm-ultimate/alarm-panel', needsRead, (req, res) => {
61
+ sendToolFile(res, 'alarm-panel.html');
62
+ });
63
+
64
+ RED.httpAdmin.get('/alarm-ultimate/alarm/nodes', needsRead, (req, res) => {
65
+ const nodes = Array.from(alarmInstances.values()).map((api) => ({
66
+ id: api.id,
67
+ name: api.name || '',
68
+ controlTopic: api.controlTopic || 'alarm',
69
+ }));
70
+ res.json({ nodes });
71
+ });
72
+
73
+ RED.httpAdmin.get('/alarm-ultimate/alarm/:id/state', needsRead, (req, res) => {
74
+ const api = alarmInstances.get(req.params.id);
75
+ if (!api) {
76
+ res.sendStatus(404);
77
+ return;
78
+ }
79
+ res.json(api.getState());
80
+ });
81
+
82
+ RED.httpAdmin.post('/alarm-ultimate/alarm/:id/command', needsWrite, (req, res) => {
83
+ const api = alarmInstances.get(req.params.id);
84
+ if (!api) {
85
+ res.sendStatus(404);
86
+ return;
87
+ }
88
+ try {
89
+ const body = req.body && typeof req.body === 'object' ? req.body : {};
90
+ api.command(body);
91
+ res.json({ ok: true });
92
+ } catch (err) {
93
+ res.status(500).json({ ok: false, error: err.message });
94
+ }
95
+ });
96
+ }
97
+
98
+ function AlarmSystemUltimate(config) {
99
+ RED.nodes.createNode(this, config);
100
+ const node = this;
101
+ const REDUtil = RED.util;
102
+
103
+ const setNodeStatus = helpers.createStatus(node);
104
+ const timerBag = helpers.createTimerBag(node);
105
+
106
+ const controlTopic = config.controlTopic || 'alarm';
107
+ const payloadPropName = config.payloadPropName || 'payload';
108
+
109
+ const requireCodeForArm = config.requireCodeForArm === true;
110
+ const requireCodeForDisarm = config.requireCodeForDisarm !== false;
111
+ const armCode = typeof config.armCode === 'string' ? config.armCode : '';
112
+ const duressCode = typeof config.duressCode === 'string' ? config.duressCode : '';
113
+ const duressEnabled = duressCode.trim().length > 0;
114
+
115
+ const blockArmOnViolations = config.blockArmOnViolations !== false;
116
+ const emitRestoreEvents = config.emitRestoreEvents === true;
117
+
118
+ const exitDelayMs = toMilliseconds(config.exitDelaySeconds, 30);
119
+ const entryDelayMs = toMilliseconds(config.entryDelaySeconds, 30);
120
+ const sirenDurationMs = toMilliseconds(config.sirenDurationSeconds, 180);
121
+ const sirenLatchUntilDisarm = config.sirenLatchUntilDisarm === true || Number(config.sirenDurationSeconds) === 0;
122
+
123
+ const maxLogEntries = clampInt(config.maxLogEntries, 50, 0, 500);
124
+ const persistState = config.persistState !== false;
125
+
126
+ const fileCacheDir =
127
+ RED &&
128
+ RED.settings &&
129
+ typeof RED.settings.userDir === 'string' &&
130
+ RED.settings.userDir.trim().length > 0
131
+ ? path.join(RED.settings.userDir, 'booleanlogicultimatepersist')
132
+ : null;
133
+ const fileCachePath = fileCacheDir ? path.join(fileCacheDir, `${node.id}.AlarmSystemUltimate.json`) : null;
134
+ let fileCacheWriteTimer = null;
135
+ let fileCacheDirty = false;
136
+
137
+ const zoneConfigText = typeof config.zones === 'string' ? config.zones : '';
138
+ let zones = parseZones(zoneConfigText);
139
+
140
+ const emitOpenZonesDuringArming = config.emitOpenZonesDuringArming === true;
141
+ const openZonesArmingIntervalMs = toMilliseconds(config.openZonesArmingIntervalSeconds, 1);
142
+
143
+ const openZonesRequestTopic =
144
+ typeof config.openZonesRequestTopic === 'string' && config.openZonesRequestTopic.trim().length > 0
145
+ ? config.openZonesRequestTopic.trim()
146
+ : `${controlTopic}/listOpenZones`;
147
+ const openZonesRequestIntervalMs = toMilliseconds(config.openZonesRequestIntervalSeconds, 0);
148
+
149
+ const stateKey = 'AlarmSystemUltimateState';
150
+ let state = restoreState();
151
+
152
+ function buildFileCachePayload() {
153
+ const zoneState = {};
154
+ for (const zone of zones) {
155
+ if (!zone || !zone.id) continue;
156
+ const meta = state.zoneState[zone.id] || { active: false, lastChangeAt: 0, lastTriggerAt: 0 };
157
+ zoneState[zone.id] = {
158
+ active: meta.active === true,
159
+ lastChangeAt: Number(meta.lastChangeAt) || 0,
160
+ lastTriggerAt: Number(meta.lastTriggerAt) || 0,
161
+ };
162
+ }
163
+ return {
164
+ nodeType: 'AlarmSystemUltimate',
165
+ nodeId: node.id,
166
+ savedAt: Date.now(),
167
+ mode: state.mode,
168
+ bypass: state.bypass,
169
+ zoneState,
170
+ };
171
+ }
172
+
173
+ function flushFileCache() {
174
+ if (!fileCachePath) return;
175
+ if (!fileCacheDirty) return;
176
+ fileCacheDirty = false;
177
+ try {
178
+ writeJsonFileAtomicSync(fileCachePath, buildFileCachePayload());
179
+ } catch (err) {
180
+ // Best-effort. Avoid crashing the runtime if filesystem is not writable.
181
+ }
182
+ }
183
+
184
+ function scheduleFileCacheWrite() {
185
+ if (!fileCachePath) return;
186
+ fileCacheDirty = true;
187
+ if (fileCacheWriteTimer) return;
188
+ fileCacheWriteTimer = timerBag.setTimeout(() => {
189
+ fileCacheWriteTimer = null;
190
+ flushFileCache();
191
+ }, 250);
192
+ }
193
+
194
+ function loadFileCache() {
195
+ if (!fileCachePath) return;
196
+ const cached = readJsonFileSync(fileCachePath);
197
+ if (!cached || typeof cached !== 'object') return;
198
+
199
+ if (cached.zoneState && typeof cached.zoneState === 'object') {
200
+ const nextZoneState = { ...(state.zoneState || {}) };
201
+ for (const zone of zones) {
202
+ if (!zone || !zone.id) continue;
203
+ const meta = cached.zoneState[zone.id];
204
+ if (!meta || typeof meta !== 'object') continue;
205
+ nextZoneState[zone.id] = {
206
+ active: meta.active === true,
207
+ lastChangeAt: Number(meta.lastChangeAt) || 0,
208
+ lastTriggerAt: Number(meta.lastTriggerAt) || 0,
209
+ };
210
+ }
211
+ state.zoneState = nextZoneState;
212
+ }
213
+
214
+ if (!persistState) {
215
+ if (typeof cached.mode === 'string') {
216
+ state.mode = normalizeMode(cached.mode) || state.mode;
217
+ }
218
+ if (cached.bypass && typeof cached.bypass === 'object') {
219
+ state.bypass = { ...cached.bypass };
220
+ }
221
+ }
222
+ }
223
+
224
+ loadFileCache();
225
+
226
+ let exitTimer = null;
227
+ let entryTimer = null;
228
+ let sirenTimer = null;
229
+ let statusInterval = null;
230
+
231
+ const OUTPUT_ALL_EVENTS = 0;
232
+ const OUTPUT_SIREN = 1;
233
+ const OUTPUT_ALARM_EVENTS = 2;
234
+ const OUTPUT_ARMING_EVENTS = 3;
235
+ const OUTPUT_ZONE_EVENTS = 4;
236
+ const OUTPUT_ERROR_EVENTS = 5;
237
+ const OUTPUT_ANY_ZONE_OPEN = 6;
238
+ const OUTPUT_OPEN_ZONES_ARMING = 7;
239
+ const OUTPUT_OPEN_ZONES_ON_REQUEST = 8;
240
+
241
+ const alarmEvents = new Set(['alarm']);
242
+ const armingEvents = new Set([
243
+ 'arming',
244
+ 'armed',
245
+ 'disarmed',
246
+ 'entry_delay',
247
+ 'arm_blocked',
248
+ 'already_armed',
249
+ 'status',
250
+ 'reset',
251
+ 'siren_on',
252
+ 'siren_off',
253
+ ]);
254
+ const zoneEvents = new Set([
255
+ 'bypassed',
256
+ 'unbypassed',
257
+ 'chime',
258
+ 'zone_ignored_exit',
259
+ 'zone_bypassed_trigger',
260
+ 'zone_restore',
261
+ ]);
262
+ const errorEvents = new Set(['error', 'denied']);
263
+
264
+ let lastAnyZoneOpen = null;
265
+ let lastOpenZonesCount = null;
266
+
267
+ let openZonesArmingInterval = null;
268
+ let openZonesRequestInterval = null;
269
+ let openZonesArmingIndex = 0;
270
+
271
+ function getOutputCount() {
272
+ if (Array.isArray(node.wires)) {
273
+ return Math.max(2, node.wires.length);
274
+ }
275
+ return 2;
276
+ }
277
+
278
+ function createOutputsArray() {
279
+ return new Array(getOutputCount()).fill(null);
280
+ }
281
+
282
+ function safeSend(outputs) {
283
+ node.send(outputs);
284
+ }
285
+
286
+ function outputForEvent(eventName) {
287
+ if (alarmEvents.has(eventName)) return OUTPUT_ALARM_EVENTS;
288
+ if (errorEvents.has(eventName)) return OUTPUT_ERROR_EVENTS;
289
+ if (zoneEvents.has(eventName)) return OUTPUT_ZONE_EVENTS;
290
+ if (armingEvents.has(eventName)) return OUTPUT_ARMING_EVENTS;
291
+ return null;
292
+ }
293
+
294
+ function safeSetOutput(outputs, index, msg) {
295
+ if (!msg) return;
296
+ if (!Number.isInteger(index)) return;
297
+ if (index < 0 || index >= outputs.length) return;
298
+ outputs[index] = msg;
299
+ }
300
+
301
+ function sendEventMessage(eventMsg, sirenMsg) {
302
+ const outputs = createOutputsArray();
303
+ safeSetOutput(outputs, OUTPUT_ALL_EVENTS, eventMsg);
304
+ safeSetOutput(outputs, OUTPUT_SIREN, sirenMsg);
305
+
306
+ if (eventMsg && typeof eventMsg.event === 'string') {
307
+ const groupOutput = outputForEvent(eventMsg.event);
308
+ if (groupOutput !== null && groupOutput !== OUTPUT_ALL_EVENTS && groupOutput < outputs.length) {
309
+ outputs[groupOutput] = REDUtil.cloneMessage(eventMsg);
310
+ }
311
+ }
312
+ safeSend(outputs);
313
+ }
314
+
315
+ function sendSingleOutput(outputIndex, msg) {
316
+ const outputs = createOutputsArray();
317
+ safeSetOutput(outputs, outputIndex, msg);
318
+ safeSend(outputs);
319
+ }
320
+
321
+ function clampInt(value, defaultValue, min, max) {
322
+ const parsed = Number(value);
323
+ if (!Number.isFinite(parsed)) {
324
+ return defaultValue;
325
+ }
326
+ return Math.max(min, Math.min(max, Math.trunc(parsed)));
327
+ }
328
+
329
+ function toMilliseconds(value, defaultSeconds) {
330
+ const seconds = Number(value);
331
+ if (Number.isFinite(seconds) && seconds >= 0) {
332
+ return seconds * 1000;
333
+ }
334
+ return defaultSeconds * 1000;
335
+ }
336
+
337
+ function now() {
338
+ return Date.now();
339
+ }
340
+
341
+ function createInitialState() {
342
+ return {
343
+ mode: 'disarmed',
344
+ arming: null,
345
+ entry: null,
346
+ alarmActive: false,
347
+ silentAlarmActive: false,
348
+ sirenActive: false,
349
+ alarmZone: null,
350
+ bypass: {},
351
+ zoneState: {},
352
+ log: [],
353
+ };
354
+ }
355
+
356
+ function restoreState() {
357
+ if (!persistState) {
358
+ return createInitialState();
359
+ }
360
+ const saved = node.context().get(stateKey);
361
+ if (!saved || typeof saved !== 'object') {
362
+ return createInitialState();
363
+ }
364
+ const next = createInitialState();
365
+ if (typeof saved.mode === 'string') {
366
+ next.mode = normalizeMode(saved.mode) || 'disarmed';
367
+ }
368
+ if (saved && typeof saved.bypass === 'object') {
369
+ next.bypass = { ...saved.bypass };
370
+ }
371
+ if (Array.isArray(saved.log)) {
372
+ next.log = saved.log.slice(-maxLogEntries);
373
+ }
374
+ return next;
375
+ }
376
+
377
+ function persist() {
378
+ if (persistState) {
379
+ node.context().set(stateKey, {
380
+ mode: state.mode,
381
+ bypass: state.bypass,
382
+ log: state.log,
383
+ });
384
+ }
385
+ scheduleFileCacheWrite();
386
+ }
387
+
388
+ function normalizeMode(value) {
389
+ if (typeof value !== 'string') {
390
+ return null;
391
+ }
392
+ const v = value.toLowerCase().trim();
393
+ if (v === 'disarmed') return 'disarmed';
394
+ if (v === 'armed') return 'armed';
395
+
396
+ // Backward compatibility: previously supported multi-mode arming.
397
+ // These legacy values now map to a single "armed" state.
398
+ if (['home', 'away', 'night', 'h24', '24h', '24'].includes(v)) {
399
+ return 'armed';
400
+ }
401
+
402
+ return null;
403
+ }
404
+
405
+
406
+ function parseZones(text) {
407
+ const results = [];
408
+ const rawText = String(text || '').trim();
409
+ if (!rawText) {
410
+ return results;
411
+ }
412
+
413
+ function pushZone(raw, index) {
414
+ if (!raw || typeof raw !== 'object') {
415
+ return;
416
+ }
417
+ const zone = normalizeZone(raw, index);
418
+ if (zone) {
419
+ results.push(zone);
420
+ }
421
+ }
422
+
423
+ try {
424
+ const parsed = JSON.parse(rawText);
425
+ if (Array.isArray(parsed)) {
426
+ parsed.forEach((item, index) => {
427
+ pushZone(item, index);
428
+ });
429
+ return results;
430
+ }
431
+ if (parsed && typeof parsed === 'object') {
432
+ pushZone(parsed, 0);
433
+ return results;
434
+ }
435
+ } catch (err) {
436
+ // fallback to JSON-per-line parsing
437
+ }
438
+
439
+ const lines = rawText
440
+ .split('\n')
441
+ .map((line) => line.trim())
442
+ .filter(Boolean);
443
+ for (let index = 0; index < lines.length; index += 1) {
444
+ const line = lines[index];
445
+ try {
446
+ pushZone(JSON.parse(line), index);
447
+ } catch (err) {
448
+ node.log(`AlarmSystemUltimate: unable to parse zone line: ${line}`);
449
+ }
450
+ }
451
+ return results;
452
+ }
453
+
454
+ function normalizeZone(raw, index) {
455
+ const zone = { ...raw };
456
+ zone.id = String(zone.id || zone.name || zone.topic || `zone${index + 1}`).trim();
457
+ if (!zone.id) {
458
+ return null;
459
+ }
460
+ zone.name = String(zone.name || zone.id).trim();
461
+
462
+ if (typeof zone.topic === 'string') {
463
+ zone.topic = zone.topic.trim();
464
+ }
465
+ if (typeof zone.topicPattern === 'string') {
466
+ zone.topicPattern = zone.topicPattern.trim();
467
+ }
468
+ if (!zone.topic && !zone.topicPattern) {
469
+ return null;
470
+ }
471
+
472
+ zone.topicPrefix = null;
473
+ if (zone.topic && zone.topic.endsWith('*')) {
474
+ zone.topicPrefix = zone.topic.slice(0, -1);
475
+ }
476
+
477
+ zone.topicRegex = null;
478
+ if (zone.topicPattern) {
479
+ try {
480
+ zone.topicRegex = new RegExp(zone.topicPattern);
481
+ } catch (err) {
482
+ node.log(`AlarmSystemUltimate: invalid topicPattern for zone ${zone.id}`);
483
+ return null;
484
+ }
485
+ }
486
+
487
+ const type = typeof zone.type === 'string' ? zone.type.toLowerCase().trim() : 'perimeter';
488
+ zone.type = type || 'perimeter';
489
+
490
+ zone.entry = zone.entry === true;
491
+ zone.bypassable = zone.bypassable !== false;
492
+ zone.chime = zone.chime === true;
493
+ zone.instantDuringExit = zone.instantDuringExit === true;
494
+
495
+ zone.entryDelayMs = toMilliseconds(zone.entryDelaySeconds, entryDelayMs / 1000);
496
+ zone.cooldownMs = toMilliseconds(zone.cooldownSeconds, 0);
497
+ zone.alwaysActive = zone.type === 'fire' || zone.type === 'tamper' || zone.type === '24h';
498
+ if (Object.prototype.hasOwnProperty.call(zone, 'modes')) {
499
+ delete zone.modes;
500
+ }
501
+
502
+ return zone;
503
+ }
504
+
505
+ function findZone(topic) {
506
+ if (!topic) {
507
+ return null;
508
+ }
509
+ for (const zone of zones) {
510
+ if (zone.topic && zone.topic === topic) {
511
+ return zone;
512
+ }
513
+ if (zone.topicPrefix && topic.startsWith(zone.topicPrefix)) {
514
+ return zone;
515
+ }
516
+ if (zone.topicRegex && zone.topicRegex.test(topic)) {
517
+ return zone;
518
+ }
519
+ }
520
+ return null;
521
+ }
522
+
523
+
524
+ function startStatusInterval() {
525
+ if (statusInterval) {
526
+ return;
527
+ }
528
+ statusInterval = timerBag.setInterval(() => {
529
+ updateStatus();
530
+ }, 1000);
531
+ }
532
+
533
+ function stopStatusIntervalIfIdle() {
534
+ if (!statusInterval) {
535
+ return;
536
+ }
537
+ if (state.arming || state.entry || state.alarmActive || state.sirenActive) {
538
+ return;
539
+ }
540
+ timerBag.clearInterval(statusInterval);
541
+ statusInterval = null;
542
+ }
543
+
544
+ function remainingSeconds(until) {
545
+ return Math.max(0, Math.ceil((until - now()) / 1000));
546
+ }
547
+
548
+ function updateStatus() {
549
+ let fill = 'grey';
550
+ let shape = 'ring';
551
+ let text = 'DISARMED';
552
+
553
+ if (state.alarmActive) {
554
+ fill = 'red';
555
+ shape = 'dot';
556
+ text = `ALARM${state.silentAlarmActive ? ' (silent)' : ''}`;
557
+ } else if (state.entry) {
558
+ fill = 'yellow';
559
+ shape = 'dot';
560
+ text = `ENTRY ${remainingSeconds(state.entry.until)}s`;
561
+ } else if (state.arming) {
562
+ fill = 'yellow';
563
+ shape = 'dot';
564
+ text = `ARMING ${remainingSeconds(state.arming.until)}s`;
565
+ } else if (state.mode === 'armed') {
566
+ fill = 'green';
567
+ shape = 'dot';
568
+ text = 'ARMED';
569
+ }
570
+
571
+ setNodeStatus({ fill, shape, text });
572
+ stopStatusIntervalIfIdle();
573
+ }
574
+
575
+ function pushLog(event) {
576
+ if (!maxLogEntries) {
577
+ return;
578
+ }
579
+ state.log.push({ ...event, ts: now() });
580
+ if (state.log.length > maxLogEntries) {
581
+ state.log.splice(0, state.log.length - maxLogEntries);
582
+ }
583
+ persist();
584
+ }
585
+
586
+ function buildOutputMessage(type, value, baseMsg) {
587
+ const msg = baseMsg ? REDUtil.cloneMessage(baseMsg) : {};
588
+ try {
589
+ msg.payload = REDUtil.evaluateNodeProperty(value, type, node, baseMsg);
590
+ } catch (err) {
591
+ msg.payload = value;
592
+ }
593
+ return msg;
594
+ }
595
+
596
+ function emitEvent(event, details, baseMsg) {
597
+ const msg = baseMsg ? REDUtil.cloneMessage(baseMsg) : {};
598
+ msg.topic = `${controlTopic}/event`;
599
+ msg.event = event;
600
+ msg.payload = {
601
+ event,
602
+ mode: state.mode,
603
+ ...(details || {}),
604
+ };
605
+ sendEventMessage(msg, null);
606
+ pushLog({ event, ...(details || {}) });
607
+ try {
608
+ alarmEmitter.emit('event', {
609
+ alarmId: node.id,
610
+ name: node.name || '',
611
+ controlTopic,
612
+ event,
613
+ details: details || {},
614
+ state: snapshotState(),
615
+ ts: now(),
616
+ });
617
+ } catch (_err) {
618
+ // Best-effort. Never crash runtime on listeners failures.
619
+ }
620
+ updateStatus();
621
+ }
622
+
623
+ function emitStatus(baseMsg) {
624
+ emitEvent(
625
+ 'status',
626
+ {
627
+ state: snapshotState(),
628
+ },
629
+ baseMsg
630
+ );
631
+ }
632
+
633
+ function snapshotState() {
634
+ const bypassed = Object.keys(state.bypass || {}).filter((k) => state.bypass[k] === true);
635
+ return {
636
+ mode: state.mode,
637
+ arming: state.arming
638
+ ? { active: true, target: 'armed', remaining: remainingSeconds(state.arming.until) }
639
+ : { active: false },
640
+ entry: state.entry
641
+ ? { active: true, zone: state.entry.zoneId, remaining: remainingSeconds(state.entry.until) }
642
+ : { active: false },
643
+ alarmActive: state.alarmActive,
644
+ silentAlarmActive: state.silentAlarmActive,
645
+ sirenActive: state.sirenActive,
646
+ alarmZone: state.alarmZone,
647
+ bypassedZones: bypassed,
648
+ log: state.log.slice(-10),
649
+ };
650
+ }
651
+
652
+ function buildZoneStateSnapshot() {
653
+ return zones.map((zone) => {
654
+ const meta = state.zoneState[zone.id] || { active: false, lastChangeAt: 0, lastTriggerAt: 0 };
655
+ return {
656
+ id: zone.id,
657
+ name: zone.name,
658
+ type: zone.type,
659
+ topic: zone.topic || null,
660
+ topicPattern: zone.topicPattern || null,
661
+ entry: Boolean(zone.entry),
662
+ bypassable: zone.bypassable !== false,
663
+ bypassed: state.bypass[zone.id] === true,
664
+ open: meta.active === true,
665
+ lastChangeAt: meta.lastChangeAt || 0,
666
+ lastTriggerAt: meta.lastTriggerAt || 0,
667
+ };
668
+ });
669
+ }
670
+
671
+ function getUiState() {
672
+ return {
673
+ id: node.id,
674
+ name: node.name || '',
675
+ controlTopic,
676
+ state: snapshotState(),
677
+ zones: buildZoneStateSnapshot(),
678
+ };
679
+ }
680
+
681
+ function buildZoneSummary(zone) {
682
+ return {
683
+ id: zone ? zone.id : null,
684
+ name: zone ? zone.name : null,
685
+ type: zone ? zone.type : null,
686
+ topic: zone ? zone.topic || zone.topicPattern || null : null,
687
+ };
688
+ }
689
+
690
+ function getOpenZonesSnapshot() {
691
+ const openZoneIds = Object.keys(state.zoneState || {}).filter((id) => {
692
+ const meta = state.zoneState[id];
693
+ return meta && meta.active === true;
694
+ });
695
+
696
+ const openZones = openZoneIds.map((id) => {
697
+ const zone = zones.find((z) => z && z.id === id);
698
+ return {
699
+ id,
700
+ name: zone ? zone.name : id,
701
+ type: zone ? zone.type : null,
702
+ topic: zone ? zone.topic || zone.topicPattern || null : null,
703
+ bypassed: state.bypass[id] === true,
704
+ };
705
+ });
706
+
707
+ return {
708
+ anyOpen: openZones.length > 0,
709
+ openZonesCount: openZones.length,
710
+ openZones,
711
+ };
712
+ }
713
+
714
+ function emitAnyZoneOpenIfChanged(baseMsg) {
715
+ const snapshot = getOpenZonesSnapshot();
716
+ if (snapshot.anyOpen === lastAnyZoneOpen && snapshot.openZonesCount === lastOpenZonesCount) {
717
+ return;
718
+ }
719
+ lastAnyZoneOpen = snapshot.anyOpen;
720
+ lastOpenZonesCount = snapshot.openZonesCount;
721
+
722
+ const msg = baseMsg ? REDUtil.cloneMessage(baseMsg) : {};
723
+ msg.topic = `${controlTopic}/anyZoneOpen`;
724
+ msg.payload = snapshot.anyOpen;
725
+ msg.openZonesCount = snapshot.openZonesCount;
726
+ msg.openZones = snapshot.openZones;
727
+ sendSingleOutput(OUTPUT_ANY_ZONE_OPEN, msg);
728
+ }
729
+
730
+ function buildOpenZoneMessage(context, zoneSummary, position, total, baseMsg) {
731
+ const msg = baseMsg ? REDUtil.cloneMessage(baseMsg) : {};
732
+ msg.topic = `${controlTopic}/openZone`;
733
+ msg.event = 'open_zone';
734
+ msg.payload = {
735
+ context,
736
+ position,
737
+ total,
738
+ zone: zoneSummary,
739
+ };
740
+ return msg;
741
+ }
742
+
743
+ function stopOpenZonesDuringArming() {
744
+ if (openZonesArmingInterval) {
745
+ timerBag.clearInterval(openZonesArmingInterval);
746
+ openZonesArmingInterval = null;
747
+ }
748
+ }
749
+
750
+ function stopOpenZonesRequestListing() {
751
+ if (openZonesRequestInterval) {
752
+ timerBag.clearInterval(openZonesRequestInterval);
753
+ openZonesRequestInterval = null;
754
+ }
755
+ }
756
+
757
+ function emitNextOpenZoneDuringArming(baseMsg) {
758
+ const snapshot = getOpenZonesSnapshot();
759
+ const openZones = snapshot.openZones || [];
760
+ if (openZones.length === 0) {
761
+ return;
762
+ }
763
+ openZonesArmingIndex += 1;
764
+ const selected = openZones[(openZonesArmingIndex - 1) % openZones.length];
765
+ const msg = buildOpenZoneMessage(
766
+ 'arming',
767
+ selected,
768
+ ((openZonesArmingIndex - 1) % openZones.length) + 1,
769
+ openZones.length,
770
+ baseMsg
771
+ );
772
+ sendSingleOutput(OUTPUT_OPEN_ZONES_ARMING, msg);
773
+ }
774
+
775
+ function startOpenZonesDuringArming(baseMsg) {
776
+ stopOpenZonesDuringArming();
777
+
778
+ if (!emitOpenZonesDuringArming || openZonesArmingIntervalMs <= 0) {
779
+ return;
780
+ }
781
+
782
+ openZonesArmingIndex = 0;
783
+ emitNextOpenZoneDuringArming(baseMsg);
784
+ openZonesArmingInterval = timerBag.setInterval(() => {
785
+ if (!state.arming) {
786
+ stopOpenZonesDuringArming();
787
+ return;
788
+ }
789
+ emitNextOpenZoneDuringArming(null);
790
+ }, openZonesArmingIntervalMs);
791
+ }
792
+
793
+ function emitOpenZonesOnRequest(baseMsg) {
794
+ stopOpenZonesRequestListing();
795
+
796
+ const snapshot = getOpenZonesSnapshot();
797
+ if (snapshot.openZones.length === 0) {
798
+ const msg = baseMsg ? REDUtil.cloneMessage(baseMsg) : {};
799
+ msg.topic = `${controlTopic}/openZones`;
800
+ msg.event = 'open_zones';
801
+ msg.payload = { total: 0, zones: [] };
802
+ sendSingleOutput(OUTPUT_OPEN_ZONES_ON_REQUEST, msg);
803
+ return;
804
+ }
805
+
806
+ let index = 0;
807
+ const total = snapshot.openZones.length;
808
+
809
+ function sendOne(nextBaseMsg) {
810
+ if (index >= total) {
811
+ stopOpenZonesRequestListing();
812
+ return;
813
+ }
814
+ const zone = snapshot.openZones[index];
815
+ index += 1;
816
+ const msg = buildOpenZoneMessage('request', zone, index, total, nextBaseMsg);
817
+ sendSingleOutput(OUTPUT_OPEN_ZONES_ON_REQUEST, msg);
818
+ }
819
+
820
+ if (openZonesRequestIntervalMs > 0) {
821
+ sendOne(baseMsg);
822
+ openZonesRequestInterval = timerBag.setInterval(() => {
823
+ sendOne(null);
824
+ }, openZonesRequestIntervalMs);
825
+ return;
826
+ }
827
+
828
+ for (let i = 0; i < total; i += 1) {
829
+ sendOne(i === 0 ? baseMsg : null);
830
+ }
831
+ }
832
+
833
+ function sendSiren(active, baseMsg, reason) {
834
+ const topic = config.sirenTopic || `${controlTopic}/siren`;
835
+ const type = active ? config.sirenOnPayloadType || 'bool' : config.sirenOffPayloadType || 'bool';
836
+ const value = active ? config.sirenOnPayload : config.sirenOffPayload;
837
+ const msg = buildOutputMessage(type, value, baseMsg);
838
+ msg.topic = topic;
839
+ msg.event = active ? 'siren_on' : 'siren_off';
840
+ msg.reason = reason;
841
+ sendEventMessage(null, msg);
842
+ }
843
+
844
+ function clearExitTimer() {
845
+ if (exitTimer) {
846
+ timerBag.clearTimeout(exitTimer);
847
+ exitTimer = null;
848
+ }
849
+ }
850
+
851
+ function clearEntryTimer() {
852
+ if (entryTimer) {
853
+ timerBag.clearTimeout(entryTimer);
854
+ entryTimer = null;
855
+ }
856
+ }
857
+
858
+ function clearSirenTimer() {
859
+ if (sirenTimer) {
860
+ timerBag.clearTimeout(sirenTimer);
861
+ sirenTimer = null;
862
+ }
863
+ }
864
+
865
+ function stopSiren(baseMsg, reason) {
866
+ if (!state.sirenActive) {
867
+ return;
868
+ }
869
+ clearSirenTimer();
870
+ state.sirenActive = false;
871
+ try {
872
+ alarmEmitter.emit('siren_state', {
873
+ alarmId: node.id,
874
+ name: node.name || '',
875
+ controlTopic,
876
+ active: false,
877
+ reason,
878
+ ts: now(),
879
+ });
880
+ } catch (_err) {
881
+ // ignore
882
+ }
883
+ sendSiren(false, baseMsg, reason);
884
+ emitEvent('siren_off', { reason }, baseMsg);
885
+ }
886
+
887
+ function startSiren(baseMsg, reason) {
888
+ if (state.sirenActive) {
889
+ return;
890
+ }
891
+ state.sirenActive = true;
892
+ try {
893
+ alarmEmitter.emit('siren_state', {
894
+ alarmId: node.id,
895
+ name: node.name || '',
896
+ controlTopic,
897
+ active: true,
898
+ reason,
899
+ ts: now(),
900
+ });
901
+ } catch (_err) {
902
+ // ignore
903
+ }
904
+ sendEventMessage(
905
+ buildEventMessage('siren_on', { reason }, baseMsg),
906
+ buildSirenMessage(true, baseMsg, reason)
907
+ );
908
+ pushLog({ event: 'siren_on', reason });
909
+ updateStatus();
910
+
911
+ if (sirenLatchUntilDisarm) {
912
+ return;
913
+ }
914
+ if (sirenDurationMs <= 0) {
915
+ return;
916
+ }
917
+ clearSirenTimer();
918
+ sirenTimer = timerBag.setTimeout(() => {
919
+ stopSiren(baseMsg, 'timeout');
920
+ }, sirenDurationMs);
921
+ }
922
+
923
+ function buildEventMessage(event, details, baseMsg) {
924
+ const msg = baseMsg ? REDUtil.cloneMessage(baseMsg) : {};
925
+ msg.topic = `${controlTopic}/event`;
926
+ msg.event = event;
927
+ msg.payload = {
928
+ event,
929
+ mode: state.mode,
930
+ ...(details || {}),
931
+ };
932
+ return msg;
933
+ }
934
+
935
+ function buildSirenMessage(active, baseMsg, reason) {
936
+ const topic = config.sirenTopic || `${controlTopic}/siren`;
937
+ const type = active ? config.sirenOnPayloadType || 'bool' : config.sirenOffPayloadType || 'bool';
938
+ const value = active ? config.sirenOnPayload : config.sirenOffPayload;
939
+ const msg = buildOutputMessage(type, value, baseMsg);
940
+ msg.topic = topic;
941
+ msg.event = active ? 'siren_on' : 'siren_off';
942
+ msg.reason = reason;
943
+ return msg;
944
+ }
945
+
946
+ function triggerAlarm(kind, zone, baseMsg, silent) {
947
+ if (state.alarmActive) {
948
+ return;
949
+ }
950
+ stopOpenZonesDuringArming();
951
+ stopOpenZonesRequestListing();
952
+ state.alarmActive = true;
953
+ state.alarmZone = zone ? zone.id : null;
954
+ state.silentAlarmActive = Boolean(silent);
955
+ clearExitTimer();
956
+ clearEntryTimer();
957
+ state.arming = null;
958
+ state.entry = null;
959
+ startStatusInterval();
960
+
961
+ const eventMsg = buildEventMessage('alarm', {
962
+ kind,
963
+ zone: zone ? { id: zone.id, name: zone.name, type: zone.type, topic: zone.topic || zone.topicPattern } : null,
964
+ silent: Boolean(silent),
965
+ }, baseMsg);
966
+
967
+ let sirenMsg = null;
968
+ if (!silent || (zone && zone.type === 'fire')) {
969
+ if (!state.sirenActive) {
970
+ state.sirenActive = true;
971
+ try {
972
+ alarmEmitter.emit('siren_state', {
973
+ alarmId: node.id,
974
+ name: node.name || '',
975
+ controlTopic,
976
+ active: true,
977
+ reason: kind,
978
+ ts: now(),
979
+ });
980
+ } catch (_err) {
981
+ // ignore
982
+ }
983
+ sirenMsg = buildSirenMessage(true, baseMsg, kind);
984
+ if (!sirenLatchUntilDisarm && sirenDurationMs > 0) {
985
+ clearSirenTimer();
986
+ sirenTimer = timerBag.setTimeout(() => {
987
+ stopSiren(baseMsg, 'timeout');
988
+ }, sirenDurationMs);
989
+ }
990
+ }
991
+ }
992
+
993
+ sendEventMessage(eventMsg, sirenMsg);
994
+ pushLog({
995
+ event: 'alarm',
996
+ kind,
997
+ silent: Boolean(silent),
998
+ zone: zone ? { id: zone.id, name: zone.name, type: zone.type } : null,
999
+ });
1000
+ updateStatus();
1001
+ }
1002
+
1003
+ function disarm(baseMsg, reason, duress) {
1004
+ stopOpenZonesDuringArming();
1005
+ stopOpenZonesRequestListing();
1006
+ clearExitTimer();
1007
+ clearEntryTimer();
1008
+ state.arming = null;
1009
+ state.entry = null;
1010
+ state.alarmActive = false;
1011
+ state.silentAlarmActive = false;
1012
+ state.alarmZone = null;
1013
+ if (state.sirenActive) {
1014
+ stopSiren(baseMsg, 'disarm');
1015
+ }
1016
+ state.mode = 'disarmed';
1017
+ persist();
1018
+ emitEvent('disarmed', { reason, duress: Boolean(duress) }, baseMsg);
1019
+ }
1020
+
1021
+ function violatedZonesForArm() {
1022
+ const violations = [];
1023
+ for (const zone of zones) {
1024
+ if (!zone || zone.alwaysActive) {
1025
+ continue;
1026
+ }
1027
+ if (state.bypass[zone.id] === true) {
1028
+ continue;
1029
+ }
1030
+ const zoneState = state.zoneState[zone.id];
1031
+ if (zoneState && zoneState.active === true) {
1032
+ violations.push({ id: zone.id, name: zone.name, type: zone.type });
1033
+ }
1034
+ }
1035
+ return violations;
1036
+ }
1037
+
1038
+ function arm(baseMsg, reason) {
1039
+ if (state.mode === 'armed' && !state.arming) {
1040
+ emitEvent('already_armed', { target: 'armed' }, baseMsg);
1041
+ return;
1042
+ }
1043
+
1044
+ const violations = blockArmOnViolations ? violatedZonesForArm() : [];
1045
+ if (blockArmOnViolations && violations.length > 0) {
1046
+ emitEvent('arm_blocked', { target: 'armed', violations }, baseMsg);
1047
+ return;
1048
+ }
1049
+
1050
+ stopOpenZonesDuringArming();
1051
+ stopOpenZonesRequestListing();
1052
+ clearExitTimer();
1053
+ clearEntryTimer();
1054
+ state.entry = null;
1055
+ state.alarmActive = false;
1056
+ state.silentAlarmActive = false;
1057
+ state.alarmZone = null;
1058
+ if (state.sirenActive) {
1059
+ stopSiren(baseMsg, 'arm');
1060
+ }
1061
+
1062
+ if (exitDelayMs <= 0) {
1063
+ state.mode = 'armed';
1064
+ state.arming = null;
1065
+ stopOpenZonesDuringArming();
1066
+ persist();
1067
+ emitEvent('armed', { reason }, baseMsg);
1068
+ return;
1069
+ }
1070
+
1071
+ const until = now() + exitDelayMs;
1072
+ state.arming = { until };
1073
+ persist();
1074
+ emitEvent('arming', { target: 'armed', seconds: remainingSeconds(until), reason }, baseMsg);
1075
+ startStatusInterval();
1076
+ startOpenZonesDuringArming(baseMsg);
1077
+
1078
+ exitTimer = timerBag.setTimeout(() => {
1079
+ const stillArming = state.arming && typeof state.arming.until === 'number';
1080
+ if (!stillArming) {
1081
+ return;
1082
+ }
1083
+ const followUpViolations = blockArmOnViolations ? violatedZonesForArm() : [];
1084
+ if (blockArmOnViolations && followUpViolations.length > 0) {
1085
+ state.arming = null;
1086
+ stopOpenZonesDuringArming();
1087
+ persist();
1088
+ emitEvent('arm_blocked', { target: 'armed', violations: followUpViolations }, baseMsg);
1089
+ return;
1090
+ }
1091
+ state.mode = 'armed';
1092
+ state.arming = null;
1093
+ stopOpenZonesDuringArming();
1094
+ persist();
1095
+ emitEvent('armed', { reason }, baseMsg);
1096
+ }, exitDelayMs);
1097
+ }
1098
+
1099
+ function startEntryDelay(zone, baseMsg) {
1100
+ if (state.entry) {
1101
+ return;
1102
+ }
1103
+ const delay = zone && Number.isFinite(zone.entryDelayMs) ? zone.entryDelayMs : entryDelayMs;
1104
+ if (delay <= 0) {
1105
+ triggerAlarm('instant', zone, baseMsg, false);
1106
+ return;
1107
+ }
1108
+ const until = now() + delay;
1109
+ state.entry = { zoneId: zone.id, until };
1110
+ emitEvent('entry_delay', { zone: { id: zone.id, name: zone.name }, seconds: remainingSeconds(until) }, baseMsg);
1111
+ startStatusInterval();
1112
+ clearEntryTimer();
1113
+ entryTimer = timerBag.setTimeout(() => {
1114
+ if (!state.entry || state.entry.zoneId !== zone.id) {
1115
+ return;
1116
+ }
1117
+ state.entry = null;
1118
+ triggerAlarm('entry_timeout', zone, baseMsg, false);
1119
+ }, delay);
1120
+ }
1121
+
1122
+ function shouldConsumeControlMessage(msg) {
1123
+ if (!msg || typeof msg !== 'object') {
1124
+ return false;
1125
+ }
1126
+ if (msg.topic !== controlTopic) {
1127
+ return false;
1128
+ }
1129
+ return true;
1130
+ }
1131
+
1132
+ function resolveCode(msg) {
1133
+ if (!msg || typeof msg !== 'object') {
1134
+ return '';
1135
+ }
1136
+ if (typeof msg.code === 'string') {
1137
+ return msg.code;
1138
+ }
1139
+ if (typeof msg.pin === 'string') {
1140
+ return msg.pin;
1141
+ }
1142
+ return '';
1143
+ }
1144
+
1145
+ function validateCode(msg, action) {
1146
+ const provided = resolveCode(msg).trim();
1147
+ const expects = action === 'arm' ? requireCodeForArm : requireCodeForDisarm;
1148
+ if (!expects) {
1149
+ return { ok: true, duress: false };
1150
+ }
1151
+ if (!armCode.trim()) {
1152
+ return { ok: true, duress: false };
1153
+ }
1154
+ if (provided && duressEnabled && provided === duressCode) {
1155
+ return { ok: true, duress: true };
1156
+ }
1157
+ if (provided && provided === armCode) {
1158
+ return { ok: true, duress: false };
1159
+ }
1160
+ return { ok: false, duress: false };
1161
+ }
1162
+
1163
+ function setBypass(zoneId, enabled, baseMsg) {
1164
+ const id = String(zoneId || '').trim();
1165
+ if (!id) {
1166
+ emitEvent('error', { error: 'missing_zone' }, baseMsg);
1167
+ return;
1168
+ }
1169
+ const zone = zones.find((z) => z && z.id === id);
1170
+ if (!zone) {
1171
+ emitEvent('error', { error: 'unknown_zone', zone: id }, baseMsg);
1172
+ return;
1173
+ }
1174
+ if (enabled && zone.bypassable === false) {
1175
+ emitEvent('error', { error: 'zone_not_bypassable', zone: id }, baseMsg);
1176
+ return;
1177
+ }
1178
+ state.bypass[id] = Boolean(enabled);
1179
+ persist();
1180
+ emitEvent(enabled ? 'bypassed' : 'unbypassed', { zone: { id: zone.id, name: zone.name } }, baseMsg);
1181
+ }
1182
+
1183
+ function handleControlMessage(msg) {
1184
+ const command = typeof msg.command === 'string' ? msg.command.toLowerCase().trim() : '';
1185
+ if (msg.reset === true || command === 'reset') {
1186
+ stopOpenZonesDuringArming();
1187
+ stopOpenZonesRequestListing();
1188
+ clearExitTimer();
1189
+ clearEntryTimer();
1190
+ clearSirenTimer();
1191
+ state = createInitialState();
1192
+ persist();
1193
+ emitAnyZoneOpenIfChanged(msg);
1194
+ emitEvent('reset', {}, msg);
1195
+ return true;
1196
+ }
1197
+
1198
+ if (msg.status === true || command === 'status') {
1199
+ emitStatus(msg);
1200
+ return true;
1201
+ }
1202
+
1203
+ if (command === 'list_open_zones' || command === 'listopenzones' || msg.listOpenZones === true) {
1204
+ emitOpenZonesOnRequest(msg);
1205
+ return true;
1206
+ }
1207
+
1208
+ if (command === 'bypass' || msg.bypass === true) {
1209
+ setBypass(msg.zone || msg.zoneId || msg.zoneName, true, msg);
1210
+ return true;
1211
+ }
1212
+ if (command === 'unbypass' || msg.unbypass === true) {
1213
+ setBypass(msg.zone || msg.zoneId || msg.zoneName, false, msg);
1214
+ return true;
1215
+ }
1216
+
1217
+ if (command === 'siren_on') {
1218
+ startSiren(msg, 'manual');
1219
+ return true;
1220
+ }
1221
+ if (command === 'siren_off') {
1222
+ stopSiren(msg, 'manual');
1223
+ return true;
1224
+ }
1225
+
1226
+ if (command === 'panic' || msg.panic === true) {
1227
+ triggerAlarm('panic', null, msg, false);
1228
+ return true;
1229
+ }
1230
+ if (command === 'panic_silent' || command === 'silent_panic') {
1231
+ triggerAlarm('panic', null, msg, true);
1232
+ return true;
1233
+ }
1234
+
1235
+ if (command === 'disarm' || msg.disarm === true) {
1236
+ const validation = validateCode(msg, 'disarm');
1237
+ if (!validation.ok) {
1238
+ emitEvent('denied', { action: 'disarm' }, msg);
1239
+ return true;
1240
+ }
1241
+ if (validation.duress) {
1242
+ triggerAlarm('duress', null, msg, true);
1243
+ disarm(msg, 'duress', true);
1244
+ return true;
1245
+ }
1246
+ disarm(msg, 'manual', false);
1247
+ return true;
1248
+ }
1249
+
1250
+ const requestedMode =
1251
+ normalizeMode(msg.arm) ||
1252
+ normalizeMode(msg.mode) ||
1253
+ (command === 'arm' ? 'armed' : null) ||
1254
+ (command === 'arm_away' ? 'armed' : null) ||
1255
+ (command === 'arm_home' ? 'armed' : null) ||
1256
+ (command === 'arm_night' ? 'armed' : null) ||
1257
+ (command === 'arm_h24' ? 'armed' : null) ||
1258
+ (command === 'arm_24h' ? 'armed' : null);
1259
+
1260
+ if (requestedMode && requestedMode !== 'disarmed') {
1261
+ const validation = validateCode(msg, 'arm');
1262
+ if (!validation.ok) {
1263
+ emitEvent('denied', { action: 'arm', target: 'armed' }, msg);
1264
+ return true;
1265
+ }
1266
+ if (validation.duress) {
1267
+ triggerAlarm('duress', null, msg, true);
1268
+ }
1269
+ arm(msg, 'manual');
1270
+ return true;
1271
+ }
1272
+
1273
+ return false;
1274
+ }
1275
+
1276
+ function handleSensorMessage(msg) {
1277
+ const zone = findZone(msg.topic);
1278
+ if (!zone) {
1279
+ return;
1280
+ }
1281
+ const resolved = helpers.resolveInput(msg, payloadPropName, config.translatorConfig, RED);
1282
+ const value = resolved.boolean;
1283
+ if (value === undefined) {
1284
+ return;
1285
+ }
1286
+
1287
+ const zoneMeta = state.zoneState[zone.id] || { active: false, lastChangeAt: 0, lastTriggerAt: 0 };
1288
+ const changed = zoneMeta.active !== value;
1289
+ zoneMeta.active = value;
1290
+ zoneMeta.lastChangeAt = now();
1291
+ state.zoneState[zone.id] = zoneMeta;
1292
+
1293
+ if (changed && emitRestoreEvents && value === false) {
1294
+ emitEvent('zone_restore', { zone: { id: zone.id, name: zone.name, type: zone.type } }, msg);
1295
+ }
1296
+
1297
+ if (changed) {
1298
+ emitAnyZoneOpenIfChanged(msg);
1299
+ scheduleFileCacheWrite();
1300
+ try {
1301
+ alarmEmitter.emit('zone_state', {
1302
+ alarmId: node.id,
1303
+ name: node.name || '',
1304
+ controlTopic,
1305
+ zone: buildZoneSummary(zone),
1306
+ open: value === true,
1307
+ bypassed: state.bypass[zone.id] === true,
1308
+ ts: zoneMeta.lastChangeAt,
1309
+ });
1310
+ } catch (_err) {
1311
+ // ignore
1312
+ }
1313
+ }
1314
+
1315
+ if (value !== true) {
1316
+ return;
1317
+ }
1318
+
1319
+ 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);
1321
+ return;
1322
+ }
1323
+
1324
+ const cooldownMs = Number(zone.cooldownMs) || 0;
1325
+ if (cooldownMs > 0 && zoneMeta.lastTriggerAt && now() - zoneMeta.lastTriggerAt < cooldownMs) {
1326
+ return;
1327
+ }
1328
+ zoneMeta.lastTriggerAt = now();
1329
+ state.zoneState[zone.id] = zoneMeta;
1330
+ scheduleFileCacheWrite();
1331
+
1332
+ if (zone.alwaysActive) {
1333
+ triggerAlarm(zone.type, zone, msg, false);
1334
+ return;
1335
+ }
1336
+
1337
+ if (state.arming && !zone.instantDuringExit) {
1338
+ emitEvent('zone_ignored_exit', { zone: { id: zone.id, name: zone.name, type: zone.type } }, msg);
1339
+ return;
1340
+ }
1341
+
1342
+ if (state.mode === 'disarmed') {
1343
+ if (zone.chime) {
1344
+ emitEvent('chime', { zone: { id: zone.id, name: zone.name, type: zone.type } }, msg);
1345
+ }
1346
+ return;
1347
+ }
1348
+
1349
+ if (zone.entry) {
1350
+ startEntryDelay(zone, msg);
1351
+ return;
1352
+ }
1353
+ triggerAlarm('instant', zone, msg, false);
1354
+ }
1355
+
1356
+ node.on('input', (msg) => {
1357
+ if (msg && typeof msg.topic === 'string' && msg.topic === openZonesRequestTopic) {
1358
+ emitOpenZonesOnRequest(msg);
1359
+ return;
1360
+ }
1361
+ if (shouldConsumeControlMessage(msg)) {
1362
+ if (handleControlMessage(msg)) {
1363
+ return;
1364
+ }
1365
+ }
1366
+ handleSensorMessage(msg);
1367
+ });
1368
+
1369
+ updateStatus();
1370
+ emitAnyZoneOpenIfChanged();
1371
+
1372
+ const api = {
1373
+ id: node.id,
1374
+ name: node.name || '',
1375
+ controlTopic,
1376
+ getState: getUiState,
1377
+ command(body) {
1378
+ const payload = body && typeof body === 'object' ? body : {};
1379
+ const msg = { topic: controlTopic };
1380
+
1381
+ if (typeof payload.command === 'string' && payload.command.trim().length > 0) {
1382
+ msg.command = payload.command;
1383
+ } else if (typeof payload.action === 'string' && payload.action.trim().length > 0) {
1384
+ msg.command = payload.action;
1385
+ }
1386
+
1387
+ if (typeof payload.arm === 'string') {
1388
+ msg.arm = payload.arm;
1389
+ }
1390
+ if (typeof payload.mode === 'string') {
1391
+ msg.mode = payload.mode;
1392
+ }
1393
+ if (payload.disarm === true) {
1394
+ msg.disarm = true;
1395
+ }
1396
+ if (typeof payload.code === 'string') {
1397
+ msg.code = payload.code;
1398
+ }
1399
+ if (typeof payload.pin === 'string') {
1400
+ msg.pin = payload.pin;
1401
+ }
1402
+ if (typeof payload.zone === 'string') {
1403
+ msg.zone = payload.zone;
1404
+ }
1405
+
1406
+ node.receive(msg);
1407
+ },
1408
+ };
1409
+
1410
+ alarmInstances.set(node.id, api);
1411
+ node.on('close', () => {
1412
+ flushFileCache();
1413
+ alarmInstances.delete(node.id);
1414
+ });
1415
+ }
1416
+
1417
+ RED.nodes.registerType('AlarmSystemUltimate', AlarmSystemUltimate);
1418
+ };