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.
- package/LICENSE +22 -0
- package/README.md +73 -0
- package/examples/README.md +21 -0
- package/examples/alarm-ultimate-basic.json +636 -0
- package/examples/alarm-ultimate-dashboard.json +99 -0
- package/nodes/AlarmSystemUltimate.html +697 -0
- package/nodes/AlarmSystemUltimate.js +1418 -0
- package/nodes/AlarmUltimateSiren.html +94 -0
- package/nodes/AlarmUltimateSiren.js +83 -0
- package/nodes/AlarmUltimateState.html +95 -0
- package/nodes/AlarmUltimateState.js +87 -0
- package/nodes/AlarmUltimateZone.html +130 -0
- package/nodes/AlarmUltimateZone.js +91 -0
- package/nodes/lib/alarm-registry.js +15 -0
- package/nodes/lib/node-helpers.js +96 -0
- package/nodes/utils.js +95 -0
- package/package.json +33 -0
- package/test/alarm-system.spec.js +470 -0
- package/test/helpers.js +28 -0
- package/test/output-nodes.spec.js +155 -0
- package/tools/alarm-json-mapper.html +596 -0
- package/tools/alarm-panel.html +728 -0
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
<script type="text/javascript">
|
|
2
|
+
RED.nodes.registerType("AlarmUltimateSiren", {
|
|
3
|
+
category: "Alarm Ultimate",
|
|
4
|
+
color: "#ffb3b3",
|
|
5
|
+
defaults: {
|
|
6
|
+
name: { value: "" },
|
|
7
|
+
alarmId: { value: "", required: true },
|
|
8
|
+
topic: { value: "" },
|
|
9
|
+
outputInitialState: { value: true },
|
|
10
|
+
},
|
|
11
|
+
inputs: 0,
|
|
12
|
+
outputs: 1,
|
|
13
|
+
icon: "font-awesome/fa-bell",
|
|
14
|
+
label: function () {
|
|
15
|
+
return this.name || "Alarm Siren";
|
|
16
|
+
},
|
|
17
|
+
paletteLabel: function () {
|
|
18
|
+
return "Alarm Siren";
|
|
19
|
+
},
|
|
20
|
+
oneditprepare: function () {
|
|
21
|
+
const alarmSelect = $("#node-input-alarmId");
|
|
22
|
+
const httpAdminRoot = (RED.settings && RED.settings.httpAdminRoot) || "/";
|
|
23
|
+
const root = httpAdminRoot.endsWith("/") ? httpAdminRoot : `${httpAdminRoot}/`;
|
|
24
|
+
const url = `${root}alarm-ultimate/alarm/nodes`;
|
|
25
|
+
const panelButton = $("#node-input-alarm-panel");
|
|
26
|
+
|
|
27
|
+
panelButton.off("click").on("click", (evt) => {
|
|
28
|
+
evt.preventDefault();
|
|
29
|
+
const alarmId = alarmSelect.val() || this.alarmId || "";
|
|
30
|
+
const idPart = alarmId ? `?id=${encodeURIComponent(alarmId)}` : "";
|
|
31
|
+
window.open(
|
|
32
|
+
`${root}alarm-ultimate/alarm-panel${idPart}`,
|
|
33
|
+
"_blank",
|
|
34
|
+
"noopener,noreferrer",
|
|
35
|
+
);
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
try {
|
|
39
|
+
$.getJSON(url)
|
|
40
|
+
.done((data) => {
|
|
41
|
+
const nodes = Array.isArray(data && data.nodes) ? data.nodes : [];
|
|
42
|
+
alarmSelect.empty();
|
|
43
|
+
alarmSelect.append($("<option></option>").attr("value", "").text("-- select --"));
|
|
44
|
+
nodes.forEach((n) => {
|
|
45
|
+
const label = n.name ? `${n.name} (${n.id})` : n.id;
|
|
46
|
+
alarmSelect.append($("<option></option>").attr("value", n.id).text(label));
|
|
47
|
+
});
|
|
48
|
+
if (this.alarmId) alarmSelect.val(this.alarmId);
|
|
49
|
+
})
|
|
50
|
+
.fail(() => {});
|
|
51
|
+
} catch (err) {}
|
|
52
|
+
},
|
|
53
|
+
});
|
|
54
|
+
</script>
|
|
55
|
+
|
|
56
|
+
<script type="text/html" data-template-name="AlarmUltimateSiren">
|
|
57
|
+
<div class="form-row">
|
|
58
|
+
<label> </label>
|
|
59
|
+
<button type="button" class="red-ui-button" id="node-input-alarm-panel">
|
|
60
|
+
<i class="fa fa-keyboard-o"></i> Panel
|
|
61
|
+
</button>
|
|
62
|
+
</div>
|
|
63
|
+
|
|
64
|
+
<div class="form-row">
|
|
65
|
+
<label for="node-input-name"><i class="icon-tag"></i> Name</label>
|
|
66
|
+
<input type="text" id="node-input-name" placeholder="Name" />
|
|
67
|
+
</div>
|
|
68
|
+
|
|
69
|
+
<div class="form-row">
|
|
70
|
+
<label for="node-input-alarmId"><i class="fa fa-link"></i> Alarm node</label>
|
|
71
|
+
<select id="node-input-alarmId" style="width: 70%"></select>
|
|
72
|
+
</div>
|
|
73
|
+
|
|
74
|
+
<div class="form-row">
|
|
75
|
+
<label for="node-input-topic"><i class="fa fa-tag"></i> Topic</label>
|
|
76
|
+
<input type="text" id="node-input-topic" placeholder="(default: <controlTopic>/sirenState)" />
|
|
77
|
+
</div>
|
|
78
|
+
|
|
79
|
+
<div class="form-row">
|
|
80
|
+
<label for="node-input-outputInitialState"><i class="fa fa-bolt"></i> Emit on deploy</label>
|
|
81
|
+
<input type="checkbox" id="node-input-outputInitialState" style="width:auto; margin-top:7px;" />
|
|
82
|
+
</div>
|
|
83
|
+
</script>
|
|
84
|
+
|
|
85
|
+
<script type="text/markdown" data-help-name="AlarmUltimateSiren">
|
|
86
|
+
Emits the siren state from a selected **Alarm System Ultimate** node.
|
|
87
|
+
|
|
88
|
+
- `msg.payload`: `true` when the siren is on, otherwise `false`
|
|
89
|
+
- `msg.reason`: `manual|timeout|arm|disarm|...`
|
|
90
|
+
|
|
91
|
+
Notes:
|
|
92
|
+
|
|
93
|
+
- Use the **Panel** button to open the Alarm Panel preselected on the chosen Alarm node.
|
|
94
|
+
</script>
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const helpers = require('./lib/node-helpers.js');
|
|
4
|
+
const { alarmInstances, alarmEmitter } = require('./lib/alarm-registry.js');
|
|
5
|
+
|
|
6
|
+
module.exports = function (RED) {
|
|
7
|
+
function AlarmUltimateSiren(config) {
|
|
8
|
+
RED.nodes.createNode(this, config);
|
|
9
|
+
const node = this;
|
|
10
|
+
|
|
11
|
+
const timerBag = helpers.createTimerBag(node);
|
|
12
|
+
const setNodeStatus = helpers.createStatus(node);
|
|
13
|
+
|
|
14
|
+
const alarmId = String(config.alarmId || '').trim();
|
|
15
|
+
const configuredTopic = typeof config.topic === 'string' ? config.topic.trim() : '';
|
|
16
|
+
const outputInitialState = config.outputInitialState !== false;
|
|
17
|
+
|
|
18
|
+
let lastActive = null;
|
|
19
|
+
|
|
20
|
+
function buildTopic(controlTopic) {
|
|
21
|
+
if (configuredTopic) return configuredTopic;
|
|
22
|
+
const base = typeof controlTopic === 'string' && controlTopic.trim().length > 0 ? controlTopic.trim() : 'alarm';
|
|
23
|
+
return `${base}/sirenState`;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
function emitSiren(active, evt, reason) {
|
|
27
|
+
if (typeof active !== 'boolean') return;
|
|
28
|
+
if (lastActive === active && reason !== 'init') return;
|
|
29
|
+
lastActive = active;
|
|
30
|
+
|
|
31
|
+
const msg = {
|
|
32
|
+
topic: buildTopic(evt && evt.controlTopic),
|
|
33
|
+
payload: active,
|
|
34
|
+
alarmId: evt ? evt.alarmId : alarmId,
|
|
35
|
+
name: evt ? evt.name || '' : '',
|
|
36
|
+
reason: evt && evt.reason ? evt.reason : reason,
|
|
37
|
+
ts: evt && evt.ts ? evt.ts : Date.now(),
|
|
38
|
+
};
|
|
39
|
+
node.send(msg);
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function emitCurrent(reason) {
|
|
43
|
+
if (!alarmId) {
|
|
44
|
+
setNodeStatus({ fill: 'red', shape: 'ring', text: 'Missing alarmId' });
|
|
45
|
+
return;
|
|
46
|
+
}
|
|
47
|
+
const api = alarmInstances.get(alarmId);
|
|
48
|
+
if (!api) {
|
|
49
|
+
setNodeStatus({ fill: 'yellow', shape: 'ring', text: 'Waiting for alarm node' });
|
|
50
|
+
return;
|
|
51
|
+
}
|
|
52
|
+
const ui = api.getState && typeof api.getState === 'function' ? api.getState() : null;
|
|
53
|
+
const state = ui && ui.state ? ui.state : null;
|
|
54
|
+
const active = state ? Boolean(state.sirenActive) : null;
|
|
55
|
+
setNodeStatus({ fill: 'green', shape: 'dot', text: `Connected (${active ? 'on' : 'off'})` });
|
|
56
|
+
emitSiren(active, { alarmId, controlTopic: ui.controlTopic, name: ui.name }, reason);
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
function onSirenState(evt) {
|
|
60
|
+
if (!evt || evt.alarmId !== alarmId) return;
|
|
61
|
+
emitSiren(Boolean(evt.active), evt, 'siren_state');
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
alarmEmitter.on('siren_state', onSirenState);
|
|
65
|
+
node.on('close', () => {
|
|
66
|
+
alarmEmitter.off('siren_state', onSirenState);
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
if (outputInitialState) {
|
|
70
|
+
timerBag.setTimeout(() => emitCurrent('init'), 0);
|
|
71
|
+
timerBag.setInterval(() => {
|
|
72
|
+
if (lastActive === null) {
|
|
73
|
+
emitCurrent('init_retry');
|
|
74
|
+
}
|
|
75
|
+
}, 1000);
|
|
76
|
+
} else {
|
|
77
|
+
setNodeStatus({ fill: 'grey', shape: 'ring', text: 'Ready' });
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
RED.nodes.registerType('AlarmUltimateSiren', AlarmUltimateSiren);
|
|
82
|
+
};
|
|
83
|
+
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
<script type="text/javascript">
|
|
2
|
+
RED.nodes.registerType("AlarmUltimateState", {
|
|
3
|
+
category: "Alarm Ultimate",
|
|
4
|
+
color: "#ffb3b3",
|
|
5
|
+
defaults: {
|
|
6
|
+
name: { value: "" },
|
|
7
|
+
alarmId: { value: "", required: true },
|
|
8
|
+
topic: { value: "" },
|
|
9
|
+
outputInitialState: { value: true },
|
|
10
|
+
},
|
|
11
|
+
inputs: 0,
|
|
12
|
+
outputs: 1,
|
|
13
|
+
icon: "font-awesome/fa-shield",
|
|
14
|
+
label: function () {
|
|
15
|
+
return this.name || "Alarm State";
|
|
16
|
+
},
|
|
17
|
+
paletteLabel: function () {
|
|
18
|
+
return "Alarm State";
|
|
19
|
+
},
|
|
20
|
+
oneditprepare: function () {
|
|
21
|
+
const alarmSelect = $("#node-input-alarmId");
|
|
22
|
+
const httpAdminRoot = (RED.settings && RED.settings.httpAdminRoot) || "/";
|
|
23
|
+
const root = httpAdminRoot.endsWith("/") ? httpAdminRoot : `${httpAdminRoot}/`;
|
|
24
|
+
const url = `${root}alarm-ultimate/alarm/nodes`;
|
|
25
|
+
const panelButton = $("#node-input-alarm-panel");
|
|
26
|
+
|
|
27
|
+
panelButton.off("click").on("click", (evt) => {
|
|
28
|
+
evt.preventDefault();
|
|
29
|
+
const alarmId = alarmSelect.val() || this.alarmId || "";
|
|
30
|
+
const idPart = alarmId ? `?id=${encodeURIComponent(alarmId)}` : "";
|
|
31
|
+
window.open(
|
|
32
|
+
`${root}alarm-ultimate/alarm-panel${idPart}`,
|
|
33
|
+
"_blank",
|
|
34
|
+
"noopener,noreferrer",
|
|
35
|
+
);
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
try {
|
|
39
|
+
$.getJSON(url)
|
|
40
|
+
.done((data) => {
|
|
41
|
+
const nodes = Array.isArray(data && data.nodes) ? data.nodes : [];
|
|
42
|
+
alarmSelect.empty();
|
|
43
|
+
alarmSelect.append($("<option></option>").attr("value", "").text("-- select --"));
|
|
44
|
+
nodes.forEach((n) => {
|
|
45
|
+
const label = n.name ? `${n.name} (${n.id})` : n.id;
|
|
46
|
+
alarmSelect.append($("<option></option>").attr("value", n.id).text(label));
|
|
47
|
+
});
|
|
48
|
+
if (this.alarmId) alarmSelect.val(this.alarmId);
|
|
49
|
+
})
|
|
50
|
+
.fail(() => {});
|
|
51
|
+
} catch (err) {}
|
|
52
|
+
},
|
|
53
|
+
});
|
|
54
|
+
</script>
|
|
55
|
+
|
|
56
|
+
<script type="text/html" data-template-name="AlarmUltimateState">
|
|
57
|
+
<div class="form-row">
|
|
58
|
+
<label> </label>
|
|
59
|
+
<button type="button" class="red-ui-button" id="node-input-alarm-panel">
|
|
60
|
+
<i class="fa fa-keyboard-o"></i> Panel
|
|
61
|
+
</button>
|
|
62
|
+
</div>
|
|
63
|
+
|
|
64
|
+
<div class="form-row">
|
|
65
|
+
<label for="node-input-name"><i class="icon-tag"></i> Name</label>
|
|
66
|
+
<input type="text" id="node-input-name" placeholder="Name" />
|
|
67
|
+
</div>
|
|
68
|
+
|
|
69
|
+
<div class="form-row">
|
|
70
|
+
<label for="node-input-alarmId"><i class="fa fa-link"></i> Alarm node</label>
|
|
71
|
+
<select id="node-input-alarmId" style="width: 70%"></select>
|
|
72
|
+
</div>
|
|
73
|
+
|
|
74
|
+
<div class="form-row">
|
|
75
|
+
<label for="node-input-topic"><i class="fa fa-tag"></i> Topic</label>
|
|
76
|
+
<input type="text" id="node-input-topic" placeholder="(default: <controlTopic>/state)" />
|
|
77
|
+
</div>
|
|
78
|
+
|
|
79
|
+
<div class="form-row">
|
|
80
|
+
<label for="node-input-outputInitialState"><i class="fa fa-bolt"></i> Emit on deploy</label>
|
|
81
|
+
<input type="checkbox" id="node-input-outputInitialState" style="width:auto; margin-top:7px;" />
|
|
82
|
+
</div>
|
|
83
|
+
</script>
|
|
84
|
+
|
|
85
|
+
<script type="text/markdown" data-help-name="AlarmUltimateState">
|
|
86
|
+
Emits the alarm arming state from a selected **Alarm System Ultimate** node.
|
|
87
|
+
|
|
88
|
+
- `msg.payload`: `armed` or `disarmed`
|
|
89
|
+
- `msg.topic`: configurable (default: `controlTopic + "/state"`)
|
|
90
|
+
- `msg.alarmId`, `msg.name`
|
|
91
|
+
|
|
92
|
+
Notes:
|
|
93
|
+
|
|
94
|
+
- Use the **Panel** button to open the Alarm Panel preselected on the chosen Alarm node.
|
|
95
|
+
</script>
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const helpers = require('./lib/node-helpers.js');
|
|
4
|
+
const { alarmInstances, alarmEmitter } = require('./lib/alarm-registry.js');
|
|
5
|
+
|
|
6
|
+
module.exports = function (RED) {
|
|
7
|
+
function AlarmUltimateState(config) {
|
|
8
|
+
RED.nodes.createNode(this, config);
|
|
9
|
+
const node = this;
|
|
10
|
+
|
|
11
|
+
const timerBag = helpers.createTimerBag(node);
|
|
12
|
+
const setNodeStatus = helpers.createStatus(node);
|
|
13
|
+
|
|
14
|
+
const alarmId = String(config.alarmId || '').trim();
|
|
15
|
+
const configuredTopic = typeof config.topic === 'string' ? config.topic.trim() : '';
|
|
16
|
+
const outputInitialState = config.outputInitialState !== false;
|
|
17
|
+
|
|
18
|
+
let lastMode = null;
|
|
19
|
+
|
|
20
|
+
function buildTopic(controlTopic) {
|
|
21
|
+
if (configuredTopic) return configuredTopic;
|
|
22
|
+
const base = typeof controlTopic === 'string' && controlTopic.trim().length > 0 ? controlTopic.trim() : 'alarm';
|
|
23
|
+
return `${base}/state`;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
function emitMode(mode, api, reason) {
|
|
27
|
+
if (mode !== 'armed' && mode !== 'disarmed') {
|
|
28
|
+
return;
|
|
29
|
+
}
|
|
30
|
+
if (lastMode === mode && reason !== 'init') {
|
|
31
|
+
return;
|
|
32
|
+
}
|
|
33
|
+
lastMode = mode;
|
|
34
|
+
|
|
35
|
+
const msg = {
|
|
36
|
+
topic: buildTopic(api && api.controlTopic),
|
|
37
|
+
payload: mode,
|
|
38
|
+
alarmId: api ? api.id : alarmId,
|
|
39
|
+
name: api ? api.name || '' : '',
|
|
40
|
+
reason,
|
|
41
|
+
};
|
|
42
|
+
node.send(msg);
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function emitCurrent(reason) {
|
|
46
|
+
if (!alarmId) {
|
|
47
|
+
setNodeStatus({ fill: 'red', shape: 'ring', text: 'Missing alarmId' });
|
|
48
|
+
return;
|
|
49
|
+
}
|
|
50
|
+
const api = alarmInstances.get(alarmId);
|
|
51
|
+
if (!api) {
|
|
52
|
+
setNodeStatus({ fill: 'yellow', shape: 'ring', text: 'Waiting for alarm node' });
|
|
53
|
+
return;
|
|
54
|
+
}
|
|
55
|
+
const state = api.getState && typeof api.getState === 'function' ? api.getState() : null;
|
|
56
|
+
const mode = state && state.state ? state.state.mode : null;
|
|
57
|
+
setNodeStatus({ fill: 'green', shape: 'dot', text: `Connected (${mode || 'unknown'})` });
|
|
58
|
+
emitMode(mode, api, reason);
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
function onAlarmEvent(evt) {
|
|
62
|
+
if (!evt || evt.alarmId !== alarmId) return;
|
|
63
|
+
if (evt.event === 'armed' || evt.event === 'disarmed' || evt.event === 'reset') {
|
|
64
|
+
emitMode(evt.state && evt.state.mode, evt, evt.event);
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
alarmEmitter.on('event', onAlarmEvent);
|
|
69
|
+
node.on('close', () => {
|
|
70
|
+
alarmEmitter.off('event', onAlarmEvent);
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
if (outputInitialState) {
|
|
74
|
+
timerBag.setTimeout(() => emitCurrent('init'), 0);
|
|
75
|
+
timerBag.setInterval(() => {
|
|
76
|
+
if (lastMode === null) {
|
|
77
|
+
emitCurrent('init_retry');
|
|
78
|
+
}
|
|
79
|
+
}, 1000);
|
|
80
|
+
} else {
|
|
81
|
+
setNodeStatus({ fill: 'grey', shape: 'ring', text: 'Ready' });
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
RED.nodes.registerType('AlarmUltimateState', AlarmUltimateState);
|
|
86
|
+
};
|
|
87
|
+
|
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
<script type="text/javascript">
|
|
2
|
+
RED.nodes.registerType("AlarmUltimateZone", {
|
|
3
|
+
category: "Alarm Ultimate",
|
|
4
|
+
color: "#ffb3b3",
|
|
5
|
+
defaults: {
|
|
6
|
+
name: { value: "" },
|
|
7
|
+
alarmId: { value: "", required: true },
|
|
8
|
+
zoneId: { value: "", required: true },
|
|
9
|
+
topic: { value: "" },
|
|
10
|
+
outputInitialState: { value: true },
|
|
11
|
+
},
|
|
12
|
+
inputs: 0,
|
|
13
|
+
outputs: 1,
|
|
14
|
+
icon: "font-awesome/fa-bullseye",
|
|
15
|
+
label: function () {
|
|
16
|
+
return this.name || "Alarm Zone";
|
|
17
|
+
},
|
|
18
|
+
paletteLabel: function () {
|
|
19
|
+
return "Alarm Zone";
|
|
20
|
+
},
|
|
21
|
+
oneditprepare: function () {
|
|
22
|
+
const alarmSelect = $("#node-input-alarmId");
|
|
23
|
+
const zoneSelect = $("#node-input-zoneId");
|
|
24
|
+
const httpAdminRoot = (RED.settings && RED.settings.httpAdminRoot) || "/";
|
|
25
|
+
const root = httpAdminRoot.endsWith("/") ? httpAdminRoot : `${httpAdminRoot}/`;
|
|
26
|
+
const panelButton = $("#node-input-alarm-panel");
|
|
27
|
+
|
|
28
|
+
function apiUrl(path) {
|
|
29
|
+
return `${root}${path.replace(/^\//, "")}`;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
panelButton.off("click").on("click", (evt) => {
|
|
33
|
+
evt.preventDefault();
|
|
34
|
+
const alarmId = alarmSelect.val() || this.alarmId || "";
|
|
35
|
+
const idPart = alarmId ? `?id=${encodeURIComponent(alarmId)}` : "";
|
|
36
|
+
window.open(
|
|
37
|
+
`${root}alarm-ultimate/alarm-panel${idPart}`,
|
|
38
|
+
"_blank",
|
|
39
|
+
"noopener,noreferrer",
|
|
40
|
+
);
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
function loadAlarms() {
|
|
44
|
+
return $.getJSON(apiUrl("alarm-ultimate/alarm/nodes"))
|
|
45
|
+
.done((data) => {
|
|
46
|
+
const nodes = Array.isArray(data && data.nodes) ? data.nodes : [];
|
|
47
|
+
alarmSelect.empty();
|
|
48
|
+
alarmSelect.append($("<option></option>").attr("value", "").text("-- select --"));
|
|
49
|
+
nodes.forEach((n) => {
|
|
50
|
+
const label = n.name ? `${n.name} (${n.id})` : n.id;
|
|
51
|
+
alarmSelect.append($("<option></option>").attr("value", n.id).text(label));
|
|
52
|
+
});
|
|
53
|
+
if (this.alarmId) alarmSelect.val(this.alarmId);
|
|
54
|
+
})
|
|
55
|
+
.fail(() => {});
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function loadZones(alarmId) {
|
|
59
|
+
zoneSelect.empty();
|
|
60
|
+
zoneSelect.append($("<option></option>").attr("value", "").text("-- select alarm first --"));
|
|
61
|
+
if (!alarmId) return;
|
|
62
|
+
$.getJSON(apiUrl(`alarm-ultimate/alarm/${encodeURIComponent(alarmId)}/state`))
|
|
63
|
+
.done((data) => {
|
|
64
|
+
const zones = Array.isArray(data && data.zones) ? data.zones : [];
|
|
65
|
+
zoneSelect.empty();
|
|
66
|
+
zoneSelect.append($("<option></option>").attr("value", "").text("-- select --"));
|
|
67
|
+
zones.forEach((z) => {
|
|
68
|
+
const label = z.name ? `${z.name} (${z.id})` : z.id;
|
|
69
|
+
zoneSelect.append($("<option></option>").attr("value", z.id).text(label));
|
|
70
|
+
});
|
|
71
|
+
if (this.zoneId) zoneSelect.val(this.zoneId);
|
|
72
|
+
})
|
|
73
|
+
.fail(() => {});
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
alarmSelect.on("change", () => loadZones(alarmSelect.val()));
|
|
77
|
+
|
|
78
|
+
try {
|
|
79
|
+
loadAlarms.call(this).always(() => loadZones.call(this, this.alarmId));
|
|
80
|
+
} catch (err) {}
|
|
81
|
+
},
|
|
82
|
+
});
|
|
83
|
+
</script>
|
|
84
|
+
|
|
85
|
+
<script type="text/html" data-template-name="AlarmUltimateZone">
|
|
86
|
+
<div class="form-row">
|
|
87
|
+
<label> </label>
|
|
88
|
+
<button type="button" class="red-ui-button" id="node-input-alarm-panel">
|
|
89
|
+
<i class="fa fa-keyboard-o"></i> Panel
|
|
90
|
+
</button>
|
|
91
|
+
</div>
|
|
92
|
+
|
|
93
|
+
<div class="form-row">
|
|
94
|
+
<label for="node-input-name"><i class="icon-tag"></i> Name</label>
|
|
95
|
+
<input type="text" id="node-input-name" placeholder="Name" />
|
|
96
|
+
</div>
|
|
97
|
+
|
|
98
|
+
<div class="form-row">
|
|
99
|
+
<label for="node-input-alarmId"><i class="fa fa-link"></i> Alarm node</label>
|
|
100
|
+
<select id="node-input-alarmId" style="width: 70%"></select>
|
|
101
|
+
</div>
|
|
102
|
+
|
|
103
|
+
<div class="form-row">
|
|
104
|
+
<label for="node-input-zoneId"><i class="fa fa-bullseye"></i> Zone</label>
|
|
105
|
+
<select id="node-input-zoneId" style="width: 70%"></select>
|
|
106
|
+
</div>
|
|
107
|
+
|
|
108
|
+
<div class="form-row">
|
|
109
|
+
<label for="node-input-topic"><i class="fa fa-tag"></i> Topic</label>
|
|
110
|
+
<input type="text" id="node-input-topic" placeholder="(default: <controlTopic>/zone/<zoneId>)" />
|
|
111
|
+
</div>
|
|
112
|
+
|
|
113
|
+
<div class="form-row">
|
|
114
|
+
<label for="node-input-outputInitialState"><i class="fa fa-bolt"></i> Emit on deploy</label>
|
|
115
|
+
<input type="checkbox" id="node-input-outputInitialState" style="width:auto; margin-top:7px;" />
|
|
116
|
+
</div>
|
|
117
|
+
</script>
|
|
118
|
+
|
|
119
|
+
<script type="text/markdown" data-help-name="AlarmUltimateZone">
|
|
120
|
+
Emits the state of a single configured zone from a selected **Alarm System Ultimate** node.
|
|
121
|
+
|
|
122
|
+
- `msg.payload`: `true` when the zone is open/active, otherwise `false`
|
|
123
|
+
- `msg.zone`: `{ id, name?, type? }`
|
|
124
|
+
- `msg.bypassed`: `true|false`
|
|
125
|
+
|
|
126
|
+
Notes:
|
|
127
|
+
|
|
128
|
+
- The zone list is loaded from the selected Alarm node state (`/alarm-ultimate/alarm/:id/state`).
|
|
129
|
+
- Use the **Panel** button to open the Alarm Panel preselected on the chosen Alarm node.
|
|
130
|
+
</script>
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const helpers = require('./lib/node-helpers.js');
|
|
4
|
+
const { alarmInstances, alarmEmitter } = require('./lib/alarm-registry.js');
|
|
5
|
+
|
|
6
|
+
module.exports = function (RED) {
|
|
7
|
+
function AlarmUltimateZone(config) {
|
|
8
|
+
RED.nodes.createNode(this, config);
|
|
9
|
+
const node = this;
|
|
10
|
+
|
|
11
|
+
const timerBag = helpers.createTimerBag(node);
|
|
12
|
+
const setNodeStatus = helpers.createStatus(node);
|
|
13
|
+
|
|
14
|
+
const alarmId = String(config.alarmId || '').trim();
|
|
15
|
+
const zoneId = String(config.zoneId || '').trim();
|
|
16
|
+
const configuredTopic = typeof config.topic === 'string' ? config.topic.trim() : '';
|
|
17
|
+
const outputInitialState = config.outputInitialState !== false;
|
|
18
|
+
|
|
19
|
+
let lastOpen = null;
|
|
20
|
+
|
|
21
|
+
function buildTopic(controlTopic) {
|
|
22
|
+
if (configuredTopic) return configuredTopic;
|
|
23
|
+
const base = typeof controlTopic === 'string' && controlTopic.trim().length > 0 ? controlTopic.trim() : 'alarm';
|
|
24
|
+
const z = zoneId || 'zone';
|
|
25
|
+
return `${base}/zone/${z}`;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
function emitZone(open, evt, reason) {
|
|
29
|
+
if (typeof open !== 'boolean') return;
|
|
30
|
+
if (lastOpen === open && reason !== 'init') return;
|
|
31
|
+
lastOpen = open;
|
|
32
|
+
|
|
33
|
+
const msg = {
|
|
34
|
+
topic: buildTopic(evt && evt.controlTopic),
|
|
35
|
+
payload: open,
|
|
36
|
+
alarmId: evt ? evt.alarmId : alarmId,
|
|
37
|
+
zone: evt && evt.zone ? evt.zone : { id: zoneId || null },
|
|
38
|
+
bypassed: Boolean(evt && evt.bypassed),
|
|
39
|
+
ts: evt && evt.ts ? evt.ts : Date.now(),
|
|
40
|
+
reason,
|
|
41
|
+
};
|
|
42
|
+
node.send(msg);
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function emitCurrent(reason) {
|
|
46
|
+
if (!alarmId || !zoneId) {
|
|
47
|
+
setNodeStatus({ fill: 'red', shape: 'ring', text: 'Missing alarmId/zoneId' });
|
|
48
|
+
return;
|
|
49
|
+
}
|
|
50
|
+
const api = alarmInstances.get(alarmId);
|
|
51
|
+
if (!api) {
|
|
52
|
+
setNodeStatus({ fill: 'yellow', shape: 'ring', text: 'Waiting for alarm node' });
|
|
53
|
+
return;
|
|
54
|
+
}
|
|
55
|
+
const ui = api.getState && typeof api.getState === 'function' ? api.getState() : null;
|
|
56
|
+
const zones = ui && Array.isArray(ui.zones) ? ui.zones : [];
|
|
57
|
+
const selected = zones.find((z) => z && z.id === zoneId);
|
|
58
|
+
if (!selected) {
|
|
59
|
+
setNodeStatus({ fill: 'red', shape: 'ring', text: `Unknown zone (${zoneId})` });
|
|
60
|
+
return;
|
|
61
|
+
}
|
|
62
|
+
setNodeStatus({ fill: 'green', shape: 'dot', text: `Connected (${zoneId}: ${selected.open ? 'open' : 'closed'})` });
|
|
63
|
+
emitZone(Boolean(selected.open), { alarmId, controlTopic: ui.controlTopic, zone: { id: selected.id, name: selected.name, type: selected.type } }, reason);
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
function onZoneState(evt) {
|
|
67
|
+
if (!evt || evt.alarmId !== alarmId) return;
|
|
68
|
+
if (!evt.zone || evt.zone.id !== zoneId) return;
|
|
69
|
+
emitZone(Boolean(evt.open), evt, 'zone_state');
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
alarmEmitter.on('zone_state', onZoneState);
|
|
73
|
+
node.on('close', () => {
|
|
74
|
+
alarmEmitter.off('zone_state', onZoneState);
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
if (outputInitialState) {
|
|
78
|
+
timerBag.setTimeout(() => emitCurrent('init'), 0);
|
|
79
|
+
timerBag.setInterval(() => {
|
|
80
|
+
if (lastOpen === null) {
|
|
81
|
+
emitCurrent('init_retry');
|
|
82
|
+
}
|
|
83
|
+
}, 1000);
|
|
84
|
+
} else {
|
|
85
|
+
setNodeStatus({ fill: 'grey', shape: 'ring', text: 'Ready' });
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
RED.nodes.registerType('AlarmUltimateZone', AlarmUltimateZone);
|
|
90
|
+
};
|
|
91
|
+
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const { EventEmitter } = require('events');
|
|
4
|
+
|
|
5
|
+
const alarmInstances = new Map();
|
|
6
|
+
const alarmEmitter = new EventEmitter();
|
|
7
|
+
|
|
8
|
+
// Allow many listeners (multiple nodes can subscribe).
|
|
9
|
+
alarmEmitter.setMaxListeners(0);
|
|
10
|
+
|
|
11
|
+
module.exports = {
|
|
12
|
+
alarmInstances,
|
|
13
|
+
alarmEmitter,
|
|
14
|
+
};
|
|
15
|
+
|