node-red-contrib-alarm-ultimate 0.1.2 → 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.
@@ -32,6 +32,19 @@ function writeJsonFileAtomicSync(filePath, data) {
32
32
  module.exports = function (RED) {
33
33
  const helpers = require('./lib/node-helpers.js');
34
34
 
35
+ function parseJsonObject(value) {
36
+ const raw = String(value || '').trim();
37
+ if (!raw) {
38
+ return null;
39
+ }
40
+ try {
41
+ const parsed = JSON.parse(raw);
42
+ return parsed && typeof parsed === 'object' && !Array.isArray(parsed) ? parsed : null;
43
+ } catch (_err) {
44
+ return null;
45
+ }
46
+ }
47
+
35
48
  if (RED && RED.httpAdmin && typeof RED.httpAdmin.get === 'function') {
36
49
  const needsRead =
37
50
  RED.auth && typeof RED.auth.needsPermission === 'function'
@@ -79,6 +92,27 @@ module.exports = function (RED) {
79
92
  res.json(api.getState());
80
93
  });
81
94
 
95
+ RED.httpAdmin.get('/alarm-ultimate/alarm/:id/log', needsRead, (req, res) => {
96
+ const api = alarmInstances.get(req.params.id);
97
+ if (!api) {
98
+ res.sendStatus(404);
99
+ return;
100
+ }
101
+ const getLog = api.getLog && typeof api.getLog === 'function' ? api.getLog : null;
102
+ if (!getLog) {
103
+ res.status(501).json({ ok: false, error: 'log_not_supported' });
104
+ return;
105
+ }
106
+ const since = Number(req.query && req.query.since);
107
+ const limit = Number(req.query && req.query.limit);
108
+ res.json(
109
+ getLog({
110
+ since: Number.isFinite(since) ? since : null,
111
+ limit: Number.isFinite(limit) ? limit : null,
112
+ })
113
+ );
114
+ });
115
+
82
116
  RED.httpAdmin.post('/alarm-ultimate/alarm/:id/command', needsWrite, (req, res) => {
83
117
  const api = alarmInstances.get(req.params.id);
84
118
  if (!api) {
@@ -106,6 +140,7 @@ module.exports = function (RED) {
106
140
 
107
141
  const controlTopic = config.controlTopic || 'alarm';
108
142
  const payloadPropName = config.payloadPropName || 'payload';
143
+ const syncTargetsConfig = parseJsonObject(config.syncTargets) || {};
109
144
 
110
145
  const requireCodeForArm = config.requireCodeForArm === true;
111
146
  const requireCodeForDisarm = config.requireCodeForDisarm !== false;
@@ -697,6 +732,27 @@ module.exports = function (RED) {
697
732
  };
698
733
  }
699
734
 
735
+ function getLogSnapshot(opts) {
736
+ const options = opts && typeof opts === 'object' ? opts : {};
737
+ const since = Number.isFinite(Number(options.since)) ? Number(options.since) : null;
738
+ const limit = Number.isFinite(Number(options.limit)) ? clampInt(options.limit, 200, 0, 500) : null;
739
+
740
+ const all = Array.isArray(state.log) ? state.log : [];
741
+ const filtered = since ? all.filter((e) => (e && Number(e.ts)) > since) : all;
742
+ const out = limit === null ? filtered : limit === 0 ? [] : filtered.slice(-limit);
743
+
744
+ return {
745
+ id: node.id,
746
+ name: node.name || '',
747
+ now: now(),
748
+ total: all.length,
749
+ returned: out.length,
750
+ since,
751
+ limit,
752
+ log: out.map((e) => ({ ...(e || {}) })),
753
+ };
754
+ }
755
+
700
756
  function buildZoneSummary(zone) {
701
757
  return {
702
758
  id: zone ? zone.id : null,
@@ -1035,6 +1091,7 @@ module.exports = function (RED) {
1035
1091
  state.mode = 'disarmed';
1036
1092
  persist();
1037
1093
  emitEvent('disarmed', { reason, duress: Boolean(duress) }, baseMsg);
1094
+ return true;
1038
1095
  }
1039
1096
 
1040
1097
  function violatedZonesForArm() {
@@ -1057,13 +1114,13 @@ module.exports = function (RED) {
1057
1114
  function arm(baseMsg, reason) {
1058
1115
  if (state.mode === 'armed' && !state.arming) {
1059
1116
  emitEvent('already_armed', { target: 'armed' }, baseMsg);
1060
- return;
1117
+ return true;
1061
1118
  }
1062
1119
 
1063
1120
  const violations = blockArmOnViolations ? violatedZonesForArm() : [];
1064
1121
  if (blockArmOnViolations && violations.length > 0) {
1065
1122
  emitEvent('arm_blocked', { target: 'armed', violations }, baseMsg);
1066
- return;
1123
+ return false;
1067
1124
  }
1068
1125
 
1069
1126
  stopOpenZonesDuringArming();
@@ -1084,7 +1141,7 @@ module.exports = function (RED) {
1084
1141
  stopOpenZonesDuringArming();
1085
1142
  persist();
1086
1143
  emitEvent('armed', { reason }, baseMsg);
1087
- return;
1144
+ return true;
1088
1145
  }
1089
1146
 
1090
1147
  const until = now() + exitDelayMs;
@@ -1113,6 +1170,55 @@ module.exports = function (RED) {
1113
1170
  persist();
1114
1171
  emitEvent('armed', { reason }, baseMsg);
1115
1172
  }, exitDelayMs);
1173
+ return true;
1174
+ }
1175
+
1176
+ function isSyncedControlMessage(msg) {
1177
+ return Boolean(msg && typeof msg === 'object' && msg._alarmUltimateSync);
1178
+ }
1179
+
1180
+ function normalizeSyncAction(value) {
1181
+ const v = String(value || '').toLowerCase().trim();
1182
+ if (v === 'arm') return 'arm';
1183
+ if (v === 'disarm') return 'disarm';
1184
+ return 'leave';
1185
+ }
1186
+
1187
+ function syncOtherAlarms(trigger, baseMsg) {
1188
+ if (!syncTargetsConfig || typeof syncTargetsConfig !== 'object') {
1189
+ return;
1190
+ }
1191
+ if (isSyncedControlMessage(baseMsg)) {
1192
+ return;
1193
+ }
1194
+ const when = trigger === 'disarm' ? 'onDisarm' : 'onArm';
1195
+ const entries = Object.entries(syncTargetsConfig);
1196
+ if (!entries.length) {
1197
+ return;
1198
+ }
1199
+ for (const [targetId, rule] of entries) {
1200
+ if (!targetId || targetId === node.id) continue;
1201
+ const action = normalizeSyncAction(rule && typeof rule === 'object' ? rule[when] : '');
1202
+ if (action === 'leave') continue;
1203
+ const api = alarmInstances.get(targetId);
1204
+ if (!api || typeof api.command !== 'function') continue;
1205
+
1206
+ const payload = {
1207
+ command: action,
1208
+ _alarmUltimateSync: {
1209
+ origin: node.id,
1210
+ trigger,
1211
+ },
1212
+ };
1213
+ if (typeof baseMsg.code === 'string') payload.code = baseMsg.code;
1214
+ if (typeof baseMsg.pin === 'string') payload.pin = baseMsg.pin;
1215
+
1216
+ try {
1217
+ api.command(payload);
1218
+ } catch (_err) {
1219
+ // ignore
1220
+ }
1221
+ }
1116
1222
  }
1117
1223
 
1118
1224
  function startEntryDelay(zone, baseMsg) {
@@ -1260,9 +1366,11 @@ module.exports = function (RED) {
1260
1366
  if (validation.duress) {
1261
1367
  triggerAlarm('duress', null, msg, true);
1262
1368
  disarm(msg, 'duress', true);
1369
+ syncOtherAlarms('disarm', msg);
1263
1370
  return true;
1264
1371
  }
1265
1372
  disarm(msg, 'manual', false);
1373
+ syncOtherAlarms('disarm', msg);
1266
1374
  return true;
1267
1375
  }
1268
1376
 
@@ -1285,7 +1393,10 @@ module.exports = function (RED) {
1285
1393
  if (validation.duress) {
1286
1394
  triggerAlarm('duress', null, msg, true);
1287
1395
  }
1288
- arm(msg, 'manual');
1396
+ const accepted = arm(msg, 'manual');
1397
+ if (accepted) {
1398
+ syncOtherAlarms('arm', msg);
1399
+ }
1289
1400
  return true;
1290
1401
  }
1291
1402
 
@@ -1405,6 +1516,7 @@ module.exports = function (RED) {
1405
1516
  name: node.name || '',
1406
1517
  controlTopic,
1407
1518
  getState: getUiState,
1519
+ getLog: getLogSnapshot,
1408
1520
  command(body) {
1409
1521
  const payload = body && typeof body === 'object' ? body : {};
1410
1522
  const msg = { topic: controlTopic };
@@ -1433,6 +1545,9 @@ module.exports = function (RED) {
1433
1545
  if (typeof payload.zone === 'string') {
1434
1546
  msg.zone = payload.zone;
1435
1547
  }
1548
+ if (payload._alarmUltimateSync && typeof payload._alarmUltimateSync === 'object') {
1549
+ msg._alarmUltimateSync = payload._alarmUltimateSync;
1550
+ }
1436
1551
 
1437
1552
  node.receive(msg);
1438
1553
  },
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "node-red-contrib-alarm-ultimate",
3
- "version": "0.1.2",
3
+ "version": "0.1.3",
4
4
  "description": "Alarm System node for Node-RED.",
5
5
  "author": "MAssimo Saccani (https://github.com/Supergiovane)",
6
6
  "license": "MIT",
@@ -518,4 +518,65 @@ describe('AlarmSystemUltimate node', function () {
518
518
  })
519
519
  .catch(done);
520
520
  });
521
+
522
+ it('syncs arm/disarm to other Alarm nodes', function (done) {
523
+ const flowId = 'alarm-sync';
524
+ const flow = [
525
+ { id: flowId, type: 'tab', label: 'alarm-sync' },
526
+ {
527
+ id: 'alarmA',
528
+ type: 'AlarmSystemUltimate',
529
+ z: flowId,
530
+ name: 'Alarm A',
531
+ controlTopic: 'alarmA',
532
+ exitDelaySeconds: 0,
533
+ requireCodeForDisarm: false,
534
+ syncTargets: JSON.stringify({
535
+ alarmB: { onArm: 'arm', onDisarm: 'disarm' },
536
+ }),
537
+ wires: [['aEvents']],
538
+ },
539
+ { id: 'aEvents', type: 'helper', z: flowId },
540
+ {
541
+ id: 'alarmB',
542
+ type: 'AlarmSystemUltimate',
543
+ z: flowId,
544
+ name: 'Alarm B',
545
+ controlTopic: 'alarmB',
546
+ exitDelaySeconds: 0,
547
+ requireCodeForDisarm: false,
548
+ wires: [['bEvents']],
549
+ },
550
+ { id: 'bEvents', type: 'helper', z: flowId },
551
+ ];
552
+
553
+ loadAlarm(flow)
554
+ .then(() => {
555
+ const alarmA = helper.getNode('alarmA');
556
+ const bEvents = helper.getNode('bEvents');
557
+
558
+ const seenB = [];
559
+ bEvents.on('input', (msg) => {
560
+ if (msg && typeof msg.event === 'string') {
561
+ seenB.push(msg.event);
562
+ }
563
+ });
564
+
565
+ alarmA.receive({ topic: 'alarmA', command: 'arm' });
566
+ setTimeout(() => {
567
+ alarmA.receive({ topic: 'alarmA', command: 'disarm' });
568
+ }, 60);
569
+
570
+ setTimeout(() => {
571
+ try {
572
+ expect(seenB).to.include('armed');
573
+ expect(seenB).to.include('disarmed');
574
+ done();
575
+ } catch (err) {
576
+ done(err);
577
+ }
578
+ }, 250);
579
+ })
580
+ .catch(done);
581
+ });
521
582
  });
@@ -768,13 +768,13 @@
768
768
  return payload;
769
769
  }
770
770
 
771
- function scheduleAutosave(errors) {
772
- if (isEditingZoneTable) {
773
- if (autoSaveTimer) window.clearTimeout(autoSaveTimer);
774
- autoSaveTimer = null;
775
- setAutosaveHint("Autosave paused while editing zones.");
776
- return;
777
- }
771
+ function scheduleAutosave(errors) {
772
+ if (isEditingZoneTable) {
773
+ if (autoSaveTimer) window.clearTimeout(autoSaveTimer);
774
+ autoSaveTimer = null;
775
+ setAutosaveHint("Autosave paused while editing zones.");
776
+ return;
777
+ }
778
778
  if (!page.alarmNodeId) {
779
779
  setAutosaveHint("");
780
780
  setConnectionState("disconnected");
@@ -792,12 +792,22 @@
792
792
  setAutosaveHint("Autosave paused: fix zone errors to save.");
793
793
  return;
794
794
  }
795
- if (autoSaveTimer) window.clearTimeout(autoSaveTimer);
796
- autoSaveTimer = window.setTimeout(() => {
797
- autoSaveTimer = null;
798
- sendZonesToEditor(true);
799
- }, 250);
800
- }
795
+ if (autoSaveTimer) window.clearTimeout(autoSaveTimer);
796
+ autoSaveTimer = window.setTimeout(() => {
797
+ autoSaveTimer = null;
798
+ sendZonesToEditor(true);
799
+ }, 80);
800
+ }
801
+
802
+ function flushZonesToEditor(quiet) {
803
+ if (!page.alarmNodeId) return;
804
+ if (!canTalkToEditor()) return;
805
+ const errors = validateModel();
806
+ if (errors && errors.length > 0) return;
807
+ if (autoSaveTimer) window.clearTimeout(autoSaveTimer);
808
+ autoSaveTimer = null;
809
+ sendZonesToEditor(quiet !== false);
810
+ }
801
811
 
802
812
  function validateModel() {
803
813
  const errors = [];
@@ -1924,24 +1934,26 @@
1924
1934
  }
1925
1935
  });
1926
1936
 
1927
- // Track editing state to avoid autosave while typing.
1928
- els.zoneTableBody.addEventListener("focusin", () => {
1929
- isEditingZoneTable = true;
1930
- if (autoSaveTimer) window.clearTimeout(autoSaveTimer);
1931
- autoSaveTimer = null;
1932
- setAutosaveHint("Autosave paused while editing zones.");
1933
- });
1934
-
1935
- els.zoneTableBody.addEventListener("focusout", () => {
1936
- setTimeout(() => {
1937
- const active = document.activeElement;
1938
- const stillInside =
1939
- active && els.zoneTableBody && els.zoneTableBody.contains(active);
1940
- if (stillInside) return;
1941
- isEditingZoneTable = false;
1942
- refreshZonesMeta();
1943
- }, 0);
1944
- });
1937
+ // Track editing state to avoid autosave while typing.
1938
+ els.zoneTableBody.addEventListener("focusin", () => {
1939
+ isEditingZoneTable = true;
1940
+ if (autoSaveTimer) window.clearTimeout(autoSaveTimer);
1941
+ autoSaveTimer = null;
1942
+ setAutosaveHint("Autosave paused while editing zones.");
1943
+ });
1944
+
1945
+ els.zoneTableBody.addEventListener("focusout", () => {
1946
+ setTimeout(() => {
1947
+ const active = document.activeElement;
1948
+ const stillInside =
1949
+ active && els.zoneTableBody && els.zoneTableBody.contains(active);
1950
+ if (stillInside) return;
1951
+ isEditingZoneTable = false;
1952
+ refreshZonesMeta();
1953
+ // Flush as soon as the user leaves the zone table, to reduce lost changes.
1954
+ flushZonesToEditor(true);
1955
+ }, 0);
1956
+ });
1945
1957
 
1946
1958
  els.zoneTableBody.addEventListener("click", (evt) => {
1947
1959
  const target = evt.target;
@@ -1996,11 +2008,18 @@
1996
2008
  }
1997
2009
  }
1998
2010
 
1999
- window.addEventListener("message", (evt) => {
2000
- if (evt.origin !== page.origin) return;
2001
- const data = evt.data && typeof evt.data === "object" ? evt.data : null;
2002
- handleEditorZonesMessage(data);
2003
- });
2011
+ window.addEventListener("message", (evt) => {
2012
+ if (evt.origin !== page.origin) return;
2013
+ const data = evt.data && typeof evt.data === "object" ? evt.data : null;
2014
+ handleEditorZonesMessage(data);
2015
+ });
2016
+
2017
+ // Best-effort flush on close/navigation away, so edits are not lost if the user closes quickly.
2018
+ window.addEventListener("beforeunload", () => flushZonesToEditor(true));
2019
+ window.addEventListener("pagehide", () => flushZonesToEditor(true));
2020
+ document.addEventListener("visibilitychange", () => {
2021
+ if (document.hidden) flushZonesToEditor(true);
2022
+ });
2004
2023
 
2005
2024
  if (bc) {
2006
2025
  bc.addEventListener("message", (evt) => {