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.
- package/README.md +3 -0
- package/examples/README.md +80 -3
- package/examples/alarm-ultimate-dashboard-controls.json +31 -2
- package/examples/alarm-ultimate-dashboard-v2.json +74 -2
- package/examples/alarm-ultimate-dashboard.json +31 -2
- package/examples/alarm-ultimate-home-assistant-alarm-panel.json +335 -0
- package/nodes/AlarmSystemUltimate.html +166 -28
- package/nodes/AlarmSystemUltimate.js +119 -4
- package/package.json +1 -1
- package/test/alarm-system.spec.js +61 -0
- package/tools/alarm-json-mapper.html +55 -36
- package/tools/alarm-panel.html +390 -33
|
@@ -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
|
@@ -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
|
-
|
|
772
|
-
|
|
773
|
-
|
|
774
|
-
|
|
775
|
-
|
|
776
|
-
|
|
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
|
-
|
|
796
|
-
|
|
797
|
-
|
|
798
|
-
|
|
799
|
-
|
|
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
|
-
|
|
1928
|
-
|
|
1929
|
-
|
|
1930
|
-
|
|
1931
|
-
|
|
1932
|
-
|
|
1933
|
-
|
|
1934
|
-
|
|
1935
|
-
|
|
1936
|
-
|
|
1937
|
-
|
|
1938
|
-
|
|
1939
|
-
|
|
1940
|
-
|
|
1941
|
-
|
|
1942
|
-
|
|
1943
|
-
|
|
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
|
-
|
|
2000
|
-
|
|
2001
|
-
|
|
2002
|
-
|
|
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) => {
|