mynodes / presence-detector / presence-detector.js
1 contributor
208 lines | 7.445kb
module.exports = function(RED) {
  function PresenceDetectorNode(config) {
    RED.nodes.createNode(this, config);
    var node = this;

    // Configuration
    node.motionTimeout = Number(config.motionTimeout) || 30000; // ms
    node.delayBetween = Number(config.delayBetween) || 3000; // ms between payload #1 and #2
    node.payload1Text = (config.payload1Text || "").trim();
    node.payload2Text = (config.payload2Text || "").trim();
    node.treatMissingDoorAsOpen = config.treatMissingDoorAsOpen === "true" || config.treatMissingDoorAsOpen === true;
    node.resetOnMotion = config.resetOnMotion === "true" || config.resetOnMotion === true;
    node.resetOnDoorOpen = config.resetOnDoorOpen === "true" || config.resetOnDoorOpen === true;

    // Internal state
    node.presence = false;
    node.doorOpen = node.treatMissingDoorAsOpen; // default initial
    node.lastMotion = false;
    node.timer = null;
    node.timerType = null; // "door" or "motion"

    function parsePayloadText(text) {
      if (!text) return null;
      try {
        return JSON.parse(text);
      } catch (e) {
        return text;
      }
    }

    node.payload1 = parsePayloadText(node.payload1Text);
    node.payload2 = parsePayloadText(node.payload2Text);

    // Helper: format milliseconds to HH:MM:SS
    function formatMs(ms) {
      ms = Math.max(0, Math.floor(ms/1000));
      var hrs = Math.floor(ms / 3600);
      var mins = Math.floor((ms % 3600) / 60);
      var secs = ms % 60;
      return [hrs, mins, secs].map(function(n){ return String(n).padStart(2, '0'); }).join(':');
    }

    function sendPresence(p) {
      if (node.presence === p) return; // only send on change
      node.presence = p;
      node.status({fill: p ? "green" : "grey", shape: "dot", text: p ? "present" : "absent"});
      var out = {payload: {presenceDetected: p}};
      node.send([out, null]);
    }

    function sendControl(payload) {
      node.send([null, {payload: payload}]);
    }

    function clearTimer() {
      if (node.timer) {
        clearTimeout(node.timer);
        node.timer = null;
      }
      if (node.updateInterval) {
        clearInterval(node.updateInterval);
        node.updateInterval = null;
      }
      node.timerType = null;
      node.timerEnd = null;
      node._lastStatusUpdate = null;
      node.status({fill: node.presence ? "green" : "grey", shape: "dot", text: node.presence ? "present" : "idle"});
    }

    function startTimer(type, timeoutMs) {
      clearTimer();
      node.timerType = type;
      node.timerEnd = Date.now() + timeoutMs;
      node._lastStatusUpdate = 0;

      function statusUpdater() {
        var now = Date.now();
        var remaining = Math.max(0, node.timerEnd - now);
        // update every 10s when remaining > 10s, otherwise every second
        var shouldUpdate = false;
        if (remaining <= 10000) {
          shouldUpdate = true;
        } else {
          if (!node._lastStatusUpdate || (now - node._lastStatusUpdate) >= 10000) shouldUpdate = true;
        }
        if (shouldUpdate) {
          node._lastStatusUpdate = now;
          node.status({fill: "yellow", shape: "ring", text: `waiting (${type}) ${formatMs(remaining)}`});
        }
      }

      // initial status
      statusUpdater();
      node.updateInterval = setInterval(statusUpdater, 1000);

      node.timer = setTimeout(function() {
        // clear interval and timers
        if (node.updateInterval) { clearInterval(node.updateInterval); node.updateInterval = null; }
        node.timer = null;
        node.timerType = null;
        node.timerEnd = null;
        node._lastStatusUpdate = null;

        // timeout fires -> absence
        sendPresence(false);
        // control payload sequence
        if (node.payload1 !== null) sendControl(node.payload1);
        if (node.payload2 !== null) {
          setTimeout(function() {
            sendControl(node.payload2);
          }, node.delayBetween);
        }
      }, timeoutMs);
    }

    function handleMotion(val) {
      var motion = (val === true || val === 1 || String(val) === "1" || String(val).toLowerCase() === "true");
      node.lastMotion = motion;
      if (motion) {
        // someone detected
        sendPresence(true);
        if (node.payload2 !== null) sendControl(node.payload2);
        if (node.resetOnMotion) clearTimer();
      } else {
        // no motion -> only start timer if door is open (or no door sensor)
        if (node.doorOpen) {
          // If the door is open and motion stops, start motion timeout
          startTimer("motion", node.motionTimeout);
        } else {
          // door closed — keep presence as-is until door opens
          node.status({fill: node.presence ? "green" : "grey", shape: "dot", text: node.presence ? "present (door closed)" : "idle"});
        }
      }
    }

    function handleDoor(val) {
      var open = null;
      if (typeof val === 'string') {
        var v = val.toLowerCase();
        if (v === 'open' || v === '1' || v === 'true') open = true;
        if (v === 'closed' || v === '0' || v === 'false') open = false;
      } else if (typeof val === 'number') {
        open = (val === 1);
      } else if (typeof val === 'boolean') {
        open = val;
      } else if (val && typeof val === 'object') {
        if ('ContactSensorState' in val) {
          open = (val.ContactSensorState === 1);
        } else if ('doorOpen' in val) {
          open = !!val.doorOpen;
        }
      }
      if (open === null) return; // unknown message

      node.doorOpen = open;
      if (open) {
        // door opened -> start deducing occupancy
        if (node.resetOnDoorOpen) clearTimer();
        if (node.lastMotion) {
          // motion present now -> confirm presence
          sendPresence(true);
          if (node.payload2 !== null) sendControl(node.payload2);
        } else {
          // no motion now -> start motion timeout to decide absence
          startTimer("motion", node.motionTimeout);
        }
      } else {
        // door closed -> stop any running timeout and keep current presence until door opens
        clearTimer();
        // presence remains as-is
      }
    }

    node.on('input', function(msg) {
      try {
        // Only accept explicit object payloads with fields: motionDetected, doorOpen, presenceDetected
        if (!msg.payload || typeof msg.payload !== 'object') return;

        var p = msg.payload;

        // Explicit presence override
        if ('presenceDetected' in p) {
          var pres = !!p.presenceDetected;
          sendPresence(pres);
          if (pres && node.payload2 !== null) sendControl(node.payload2);
          return;
        }

        var didSomething = false;
        if ('motionDetected' in p) { handleMotion(p.motionDetected); didSomething = true; }
        if ('motionDtected' in p) { handleMotion(p.motionDtected); didSomething = true; }
        if ('doorOpen' in p) { handleDoor(p.doorOpen); didSomething = true; }
        if ('ContactSensorState' in p) { handleDoor(p); didSomething = true; }
        // ignore other payloads
      } catch (err) {
        node.error(err.message);
      }
    });

    node.on('close', function() {
      clearTimer();
    });

    // initialize status
    node.status({fill: node.presence ? "green" : "grey", shape: "dot", text: node.presence ? "present" : "idle"});
  }
  RED.nodes.registerType("presence-detector", PresenceDetectorNode);
};