|
Bogdan Timofte
authored
2 weeks ago
|
1
|
module.exports = function(RED) {
|
|
|
2
|
function PresenceDetectorNode(config) {
|
|
|
3
|
RED.nodes.createNode(this, config);
|
|
|
4
|
var node = this;
|
|
|
5
|
|
|
|
6
|
// Configuration
|
|
|
7
|
node.motionTimeout = Number(config.motionTimeout) || 30000; // ms
|
|
|
8
|
node.delayBetween = Number(config.delayBetween) || 3000; // ms between payload #1 and #2
|
|
|
9
|
node.payload1Text = (config.payload1Text || "").trim();
|
|
|
10
|
node.payload2Text = (config.payload2Text || "").trim();
|
|
|
11
|
node.treatMissingDoorAsOpen = config.treatMissingDoorAsOpen === "true" || config.treatMissingDoorAsOpen === true;
|
|
|
12
|
node.resetOnMotion = config.resetOnMotion === "true" || config.resetOnMotion === true;
|
|
|
13
|
node.resetOnDoorOpen = config.resetOnDoorOpen === "true" || config.resetOnDoorOpen === true;
|
|
|
14
|
|
|
|
15
|
// Internal state
|
|
|
16
|
node.presence = false;
|
|
|
17
|
node.doorOpen = node.treatMissingDoorAsOpen; // default initial
|
|
|
18
|
node.lastMotion = false;
|
|
|
19
|
node.timer = null;
|
|
|
20
|
node.timerType = null; // "door" or "motion"
|
|
|
21
|
|
|
|
22
|
function parsePayloadText(text) {
|
|
|
23
|
if (!text) return null;
|
|
|
24
|
try {
|
|
|
25
|
return JSON.parse(text);
|
|
|
26
|
} catch (e) {
|
|
|
27
|
return text;
|
|
|
28
|
}
|
|
|
29
|
}
|
|
|
30
|
|
|
|
31
|
node.payload1 = parsePayloadText(node.payload1Text);
|
|
|
32
|
node.payload2 = parsePayloadText(node.payload2Text);
|
|
|
33
|
|
|
|
34
|
// Helper: format milliseconds to HH:MM:SS
|
|
|
35
|
function formatMs(ms) {
|
|
|
36
|
ms = Math.max(0, Math.floor(ms/1000));
|
|
|
37
|
var hrs = Math.floor(ms / 3600);
|
|
|
38
|
var mins = Math.floor((ms % 3600) / 60);
|
|
|
39
|
var secs = ms % 60;
|
|
|
40
|
return [hrs, mins, secs].map(function(n){ return String(n).padStart(2, '0'); }).join(':');
|
|
|
41
|
}
|
|
|
42
|
|
|
|
43
|
function sendPresence(p) {
|
|
|
44
|
if (node.presence === p) return; // only send on change
|
|
|
45
|
node.presence = p;
|
|
|
46
|
node.status({fill: p ? "green" : "grey", shape: "dot", text: p ? "present" : "absent"});
|
|
|
47
|
var out = {payload: {presenceDetected: p}};
|
|
|
48
|
node.send([out, null]);
|
|
|
49
|
}
|
|
|
50
|
|
|
|
51
|
function sendControl(payload) {
|
|
|
52
|
node.send([null, {payload: payload}]);
|
|
|
53
|
}
|
|
|
54
|
|
|
|
55
|
function clearTimer() {
|
|
|
56
|
if (node.timer) {
|
|
|
57
|
clearTimeout(node.timer);
|
|
|
58
|
node.timer = null;
|
|
|
59
|
}
|
|
|
60
|
if (node.updateInterval) {
|
|
|
61
|
clearInterval(node.updateInterval);
|
|
|
62
|
node.updateInterval = null;
|
|
|
63
|
}
|
|
|
64
|
node.timerType = null;
|
|
|
65
|
node.timerEnd = null;
|
|
|
66
|
node._lastStatusUpdate = null;
|
|
|
67
|
node.status({fill: node.presence ? "green" : "grey", shape: "dot", text: node.presence ? "present" : "idle"});
|
|
|
68
|
}
|
|
|
69
|
|
|
|
70
|
function startTimer(type, timeoutMs) {
|
|
|
71
|
clearTimer();
|
|
|
72
|
node.timerType = type;
|
|
|
73
|
node.timerEnd = Date.now() + timeoutMs;
|
|
|
74
|
node._lastStatusUpdate = 0;
|
|
|
75
|
|
|
|
76
|
function statusUpdater() {
|
|
|
77
|
var now = Date.now();
|
|
|
78
|
var remaining = Math.max(0, node.timerEnd - now);
|
|
|
79
|
// update every 10s when remaining > 10s, otherwise every second
|
|
|
80
|
var shouldUpdate = false;
|
|
|
81
|
if (remaining <= 10000) {
|
|
|
82
|
shouldUpdate = true;
|
|
|
83
|
} else {
|
|
|
84
|
if (!node._lastStatusUpdate || (now - node._lastStatusUpdate) >= 10000) shouldUpdate = true;
|
|
|
85
|
}
|
|
|
86
|
if (shouldUpdate) {
|
|
|
87
|
node._lastStatusUpdate = now;
|
|
|
88
|
node.status({fill: "yellow", shape: "ring", text: `waiting (${type}) ${formatMs(remaining)}`});
|
|
|
89
|
}
|
|
|
90
|
}
|
|
|
91
|
|
|
|
92
|
// initial status
|
|
|
93
|
statusUpdater();
|
|
|
94
|
node.updateInterval = setInterval(statusUpdater, 1000);
|
|
|
95
|
|
|
|
96
|
node.timer = setTimeout(function() {
|
|
|
97
|
// clear interval and timers
|
|
|
98
|
if (node.updateInterval) { clearInterval(node.updateInterval); node.updateInterval = null; }
|
|
|
99
|
node.timer = null;
|
|
|
100
|
node.timerType = null;
|
|
|
101
|
node.timerEnd = null;
|
|
|
102
|
node._lastStatusUpdate = null;
|
|
|
103
|
|
|
|
104
|
// timeout fires -> absence
|
|
|
105
|
sendPresence(false);
|
|
|
106
|
// control payload sequence
|
|
|
107
|
if (node.payload1 !== null) sendControl(node.payload1);
|
|
|
108
|
if (node.payload2 !== null) {
|
|
|
109
|
setTimeout(function() {
|
|
|
110
|
sendControl(node.payload2);
|
|
|
111
|
}, node.delayBetween);
|
|
|
112
|
}
|
|
|
113
|
}, timeoutMs);
|
|
|
114
|
}
|
|
|
115
|
|
|
|
116
|
function handleMotion(val) {
|
|
|
117
|
var motion = (val === true || val === 1 || String(val) === "1" || String(val).toLowerCase() === "true");
|
|
|
118
|
node.lastMotion = motion;
|
|
|
119
|
if (motion) {
|
|
|
120
|
// someone detected
|
|
|
121
|
sendPresence(true);
|
|
|
122
|
if (node.payload2 !== null) sendControl(node.payload2);
|
|
|
123
|
if (node.resetOnMotion) clearTimer();
|
|
|
124
|
} else {
|
|
|
125
|
// no motion -> only start timer if door is open (or no door sensor)
|
|
|
126
|
if (node.doorOpen) {
|
|
|
127
|
// If the door is open and motion stops, start motion timeout
|
|
|
128
|
startTimer("motion", node.motionTimeout);
|
|
|
129
|
} else {
|
|
|
130
|
// door closed — keep presence as-is until door opens
|
|
|
131
|
node.status({fill: node.presence ? "green" : "grey", shape: "dot", text: node.presence ? "present (door closed)" : "idle"});
|
|
|
132
|
}
|
|
|
133
|
}
|
|
|
134
|
}
|
|
|
135
|
|
|
|
136
|
function handleDoor(val) {
|
|
|
137
|
var open = null;
|
|
|
138
|
if (typeof val === 'string') {
|
|
|
139
|
var v = val.toLowerCase();
|
|
|
140
|
if (v === 'open' || v === '1' || v === 'true') open = true;
|
|
|
141
|
if (v === 'closed' || v === '0' || v === 'false') open = false;
|
|
|
142
|
} else if (typeof val === 'number') {
|
|
|
143
|
open = (val === 1);
|
|
|
144
|
} else if (typeof val === 'boolean') {
|
|
|
145
|
open = val;
|
|
|
146
|
} else if (val && typeof val === 'object') {
|
|
|
147
|
if ('ContactSensorState' in val) {
|
|
|
148
|
open = (val.ContactSensorState === 1);
|
|
|
149
|
} else if ('doorOpen' in val) {
|
|
|
150
|
open = !!val.doorOpen;
|
|
|
151
|
}
|
|
|
152
|
}
|
|
|
153
|
if (open === null) return; // unknown message
|
|
|
154
|
|
|
|
155
|
node.doorOpen = open;
|
|
|
156
|
if (open) {
|
|
|
157
|
// door opened -> start deducing occupancy
|
|
|
158
|
if (node.resetOnDoorOpen) clearTimer();
|
|
|
159
|
if (node.lastMotion) {
|
|
|
160
|
// motion present now -> confirm presence
|
|
|
161
|
sendPresence(true);
|
|
|
162
|
if (node.payload2 !== null) sendControl(node.payload2);
|
|
|
163
|
} else {
|
|
|
164
|
// no motion now -> start motion timeout to decide absence
|
|
|
165
|
startTimer("motion", node.motionTimeout);
|
|
|
166
|
}
|
|
|
167
|
} else {
|
|
|
168
|
// door closed -> stop any running timeout and keep current presence until door opens
|
|
|
169
|
clearTimer();
|
|
|
170
|
// presence remains as-is
|
|
|
171
|
}
|
|
|
172
|
}
|
|
|
173
|
|
|
|
174
|
node.on('input', function(msg) {
|
|
|
175
|
try {
|
|
|
176
|
// Only accept explicit object payloads with fields: motionDetected, doorOpen, presenceDetected
|
|
|
177
|
if (!msg.payload || typeof msg.payload !== 'object') return;
|
|
|
178
|
|
|
|
179
|
var p = msg.payload;
|
|
|
180
|
|
|
|
181
|
// Explicit presence override
|
|
|
182
|
if ('presenceDetected' in p) {
|
|
|
183
|
var pres = !!p.presenceDetected;
|
|
|
184
|
sendPresence(pres);
|
|
|
185
|
if (pres && node.payload2 !== null) sendControl(node.payload2);
|
|
|
186
|
return;
|
|
|
187
|
}
|
|
|
188
|
|
|
|
189
|
var didSomething = false;
|
|
|
190
|
if ('motionDetected' in p) { handleMotion(p.motionDetected); didSomething = true; }
|
|
|
191
|
if ('motionDtected' in p) { handleMotion(p.motionDtected); didSomething = true; }
|
|
|
192
|
if ('doorOpen' in p) { handleDoor(p.doorOpen); didSomething = true; }
|
|
|
193
|
if ('ContactSensorState' in p) { handleDoor(p); didSomething = true; }
|
|
|
194
|
// ignore other payloads
|
|
|
195
|
} catch (err) {
|
|
|
196
|
node.error(err.message);
|
|
|
197
|
}
|
|
|
198
|
});
|
|
|
199
|
|
|
|
200
|
node.on('close', function() {
|
|
|
201
|
clearTimer();
|
|
|
202
|
});
|
|
|
203
|
|
|
|
204
|
// initialize status
|
|
|
205
|
node.status({fill: node.presence ? "green" : "grey", shape: "dot", text: node.presence ? "present" : "idle"});
|
|
|
206
|
}
|
|
|
207
|
RED.nodes.registerType("presence-detector", PresenceDetectorNode);
|
|
|
208
|
};
|