1 contributor
570 lines | 19.071kb
module.exports = function(RED) {
  function Z2MZG204ZVNode(config) {
    RED.nodes.createNode(this, config);
    var node = this;

    node.site = normalizeToken(config.site || config.mqttSite || "");
    node.location = normalizeToken(config.location || config.mqttRoom || "");
    node.accessory = normalizeLegacyDeviceId(normalizeToken(config.accessory || config.mqttSensor || ""));
    node.batteryLowThreshold = parseNumber(config.batteryLowThreshold, 20, 0);
    node.occupancyFadingTimeSec = parseNumber(config.occupancyFadingTimeSec, 300, 0);
    node.bootstrapDeadlineMs = 10000;
    node.hkCache = Object.create(null);
    node.occupancyTimer = null;
    node.startTimer = null;
    node.bootstrapTimer = null;
    node.lastMsgContext = null;

    node.stats = {
      controls: 0,
      last_inputs: 0,
      value_inputs: 0,
      hk_updates: 0,
      errors: 0
    };

    node.subscriptionState = {
      started: false,
      lastSubscribed: false,
      valueSubscribed: false
    };

    node.bootstrapState = {
      finalized: false,
      motion: false,
      temperature: false,
      humidity: false,
      illuminance: false,
      battery: false
    };

    node.sensorState = {
      active: false,
      site: node.site || "",
      location: node.location || "",
      deviceId: node.accessory || "",
      motionKnown: false,
      motion: false,
      occupancyKnown: false,
      occupancy: false,
      temperatureKnown: false,
      temperature: null,
      humidityKnown: false,
      humidity: null,
      illuminanceKnown: false,
      illuminance: null,
      batteryKnown: false,
      battery: null,
      batteryLowKnown: false,
      batteryLow: false,
      tamperedKnown: false,
      tampered: false
    };

    function parseNumber(value, fallback, min) {
      var n = Number(value);
      if (!Number.isFinite(n)) return fallback;
      if (typeof min === "number" && n < min) return fallback;
      return n;
    }

    function asBool(value) {
      if (typeof value === "boolean") return value;
      if (typeof value === "number") return value !== 0;
      if (typeof value === "string") {
        var v = value.trim().toLowerCase();
        if (v === "true" || v === "1" || v === "on" || v === "yes" || v === "online") return true;
        if (v === "false" || v === "0" || v === "off" || v === "no" || v === "offline") return false;
      }
      return null;
    }

    function asNumber(value) {
      if (typeof value === "number" && isFinite(value)) return value;
      if (typeof value === "string") {
        var trimmed = value.trim();
        if (!trimmed) return null;
        var parsed = Number(trimmed);
        if (isFinite(parsed)) return parsed;
      }
      return null;
    }

    function clamp(n, min, max) {
      return Math.max(min, Math.min(max, n));
    }

    function normalizeToken(value) {
      if (value === undefined || value === null) return "";
      return String(value).trim();
    }

    function normalizeLegacyDeviceId(value) {
      if (!value) return "";
      if (/^radar-/i.test(value)) return value.replace(/^radar-/i, "");
      return value;
    }

    function signature(value) {
      return JSON.stringify(value);
    }

    function shouldPublish(cacheKey, payload) {
      var sig = signature(payload);
      if (node.hkCache[cacheKey] === sig) return false;
      node.hkCache[cacheKey] = sig;
      return true;
    }

    function cloneBaseMsg(msg) {
      if (!msg || typeof msg !== "object") return {};
      var out = {};
      if (typeof msg.topic === "string") out.topic = msg.topic;
      if (msg._msgid) out._msgid = msg._msgid;
      return out;
    }

    function buildSubscriptionTopic(stream) {
      return [
        node.site,
        "home",
        node.location,
        "+",
        node.accessory,
        stream
      ].join("/");
    }

    function buildSubscribeMsgs() {
      return [
        {
          action: "subscribe",
          topic: buildSubscriptionTopic("last"),
          qos: 2,
          rh: 0,
          rap: true
        },
        {
          action: "subscribe",
          topic: buildSubscriptionTopic("value"),
          qos: 2,
          rh: 0,
          rap: true
        }
      ];
    }

    function buildUnsubscribeLastMsg(reason) {
      return {
        action: "unsubscribe",
        topic: buildSubscriptionTopic("last"),
        reason: reason
      };
    }

    function statusText(prefix) {
      var state = node.subscriptionState.lastSubscribed ? "cold" : (node.subscriptionState.started ? "live" : "idle");
      var device = node.sensorState.deviceId || node.accessory || "?";
      return [
        prefix || state,
        device,
        "l:" + node.stats.last_inputs,
        "v:" + node.stats.value_inputs,
        "hk:" + node.stats.hk_updates
      ].join(" ");
    }

    function setNodeStatus(prefix, fill, shape) {
      node.status({
        fill: fill || (node.stats.errors ? "red" : (node.subscriptionState.lastSubscribed ? "yellow" : "green")),
        shape: shape || "dot",
        text: statusText(prefix)
      });
    }

    function noteError(text, msg) {
      node.stats.errors += 1;
      node.warn(text);
      node.status({ fill: "red", shape: "ring", text: text });
      if (msg) node.debug(msg);
    }

    function clearOccupancyTimer() {
      if (node.occupancyTimer) {
        clearTimeout(node.occupancyTimer);
        node.occupancyTimer = null;
      }
    }

    function clearBootstrapTimer() {
      if (!node.bootstrapTimer) return;
      clearTimeout(node.bootstrapTimer);
      node.bootstrapTimer = null;
    }

    function makeHomeKitMsg(baseMsg, payload) {
      var out = RED.util.cloneMessage(baseMsg || {});
      out.payload = payload;
      return out;
    }

    function buildStatusFields() {
      return {
        StatusActive: !!node.sensorState.active,
        StatusFault: node.sensorState.active ? 0 : 1,
        StatusLowBattery: node.sensorState.batteryLow ? 1 : 0,
        StatusTampered: node.sensorState.tampered ? 1 : 0
      };
    }

    function buildMotionMsg(baseMsg) {
      if (!node.sensorState.motionKnown) return null;
      var payload = buildStatusFields();
      payload.MotionDetected = node.sensorState.motion ? 1 : 0;
      if (!shouldPublish("hk:motion", payload)) return null;
      node.stats.hk_updates += 1;
      return makeHomeKitMsg(baseMsg, payload);
    }

    function buildOccupancyMsg(baseMsg) {
      if (!node.sensorState.occupancyKnown) return null;
      var payload = buildStatusFields();
      payload.OccupancyDetected = node.sensorState.occupancy ? 1 : 0;
      if (!shouldPublish("hk:occupancy", payload)) return null;
      node.stats.hk_updates += 1;
      return makeHomeKitMsg(baseMsg, payload);
    }

    function buildTemperatureMsg(baseMsg) {
      if (!node.sensorState.temperatureKnown) return null;
      var payload = {
        CurrentTemperature: clamp(Number(node.sensorState.temperature), -100, 100)
      };
      if (!shouldPublish("hk:temperature", payload)) return null;
      node.stats.hk_updates += 1;
      return makeHomeKitMsg(baseMsg, payload);
    }

    function buildHumidityMsg(baseMsg) {
      if (!node.sensorState.humidityKnown) return null;
      var payload = {
        CurrentRelativeHumidity: clamp(Number(node.sensorState.humidity), 0, 100)
      };
      if (!shouldPublish("hk:humidity", payload)) return null;
      node.stats.hk_updates += 1;
      return makeHomeKitMsg(baseMsg, payload);
    }

    function buildLightMsg(baseMsg) {
      if (!node.sensorState.illuminanceKnown) return null;
      var lux = Number(node.sensorState.illuminance);
      var payload = {
        CurrentAmbientLightLevel: lux <= 0 ? 0.0001 : clamp(lux, 0.0001, 100000)
      };
      if (!shouldPublish("hk:light", payload)) return null;
      node.stats.hk_updates += 1;
      return makeHomeKitMsg(baseMsg, payload);
    }

    function buildBatteryMsg(baseMsg) {
      if (!node.sensorState.batteryKnown && !node.sensorState.batteryLowKnown) return null;
      var batteryLevel = node.sensorState.batteryKnown
        ? clamp(Math.round(Number(node.sensorState.battery)), 0, 100)
        : (node.sensorState.batteryLow ? 1 : 100);
      var payload = {
        StatusLowBattery: node.sensorState.batteryLow ? 1 : 0,
        BatteryLevel: batteryLevel,
        ChargingState: 2
      };
      if (!shouldPublish("hk:battery", payload)) return null;
      node.stats.hk_updates += 1;
      return makeHomeKitMsg(baseMsg, payload);
    }

    function clearSnapshotCache() {
      delete node.hkCache["hk:motion"];
      delete node.hkCache["hk:occupancy"];
      delete node.hkCache["hk:temperature"];
      delete node.hkCache["hk:humidity"];
      delete node.hkCache["hk:light"];
      delete node.hkCache["hk:battery"];
    }

    function buildBootstrapOutputs(baseMsg) {
      clearSnapshotCache();
      return [
        buildMotionMsg(baseMsg),
        buildOccupancyMsg(baseMsg),
        buildTemperatureMsg(baseMsg),
        buildHumidityMsg(baseMsg),
        buildLightMsg(baseMsg),
        buildBatteryMsg(baseMsg)
      ];
    }

    function emitTimerOccupancyClear() {
      node.occupancyTimer = null;
      if (!node.sensorState.occupancyKnown || !node.sensorState.occupancy) return;
      node.sensorState.occupancy = false;
      var baseMsg = cloneBaseMsg(node.lastMsgContext);
      var occupancyMsg = buildOccupancyMsg(baseMsg);
      if (occupancyMsg) {
        node.send([null, occupancyMsg, null, null, null, null, null]);
      }
      setNodeStatus();
    }

    function applyMotionSample(rawDetected) {
      var detected = !!rawDetected;
      node.sensorState.motionKnown = true;
      node.sensorState.motion = detected;

      if (node.occupancyFadingTimeSec <= 0) {
        clearOccupancyTimer();
        node.sensorState.occupancyKnown = true;
        node.sensorState.occupancy = detected;
        return;
      }

      node.sensorState.occupancyKnown = true;
      if (detected) {
        node.sensorState.occupancy = true;
        clearOccupancyTimer();
        node.occupancyTimer = setTimeout(emitTimerOccupancyClear, Math.round(node.occupancyFadingTimeSec * 1000));
      } else if (!node.sensorState.occupancy) {
        clearOccupancyTimer();
      }
    }

    function unsubscribeLast(reason, send) {
      if (!node.subscriptionState.lastSubscribed) return null;
      node.subscriptionState.lastSubscribed = false;
      node.stats.controls += 1;
      var controlMsg = buildUnsubscribeLastMsg(reason);
      if (typeof send === "function") {
        send([null, null, null, null, null, null, controlMsg]);
      }
      return controlMsg;
    }

    function markBootstrapSatisfied(capability) {
      if (capability === "motion" && node.sensorState.motionKnown) {
        node.bootstrapState.motion = true;
      } else if (capability === "temperature" && node.sensorState.temperatureKnown) {
        node.bootstrapState.temperature = true;
      } else if (capability === "humidity" && node.sensorState.humidityKnown) {
        node.bootstrapState.humidity = true;
      } else if (capability === "illuminance" && node.sensorState.illuminanceKnown) {
        node.bootstrapState.illuminance = true;
      } else if ((capability === "battery" || capability === "battery_low") && (node.sensorState.batteryKnown || node.sensorState.batteryLowKnown)) {
        node.bootstrapState.battery = true;
      }
    }

    function isBootstrapComplete() {
      return node.bootstrapState.motion
        && node.bootstrapState.temperature
        && node.bootstrapState.humidity
        && node.bootstrapState.illuminance
        && node.bootstrapState.battery;
    }

    function finalizeBootstrap(reason, send) {
      if (node.bootstrapState.finalized) return false;
      if (!node.subscriptionState.lastSubscribed) return false;
      node.bootstrapState.finalized = true;
      clearBootstrapTimer();
      send = send || function(msgs) { node.send(msgs); };
      var outputs = buildBootstrapOutputs(cloneBaseMsg(node.lastMsgContext));
      var controlMsg = unsubscribeLast(reason);
      send([
        outputs[0],
        outputs[1],
        outputs[2],
        outputs[3],
        outputs[4],
        outputs[5],
        controlMsg
      ]);
      setNodeStatus("live");
      return true;
    }

    function parseTopic(topic) {
      if (typeof topic !== "string") return null;
      var tokens = topic.split("/").map(function(token) {
        return token.trim();
      }).filter(function(token) {
        return !!token;
      });
      if (tokens.length !== 6) return null;
      if (tokens[1] !== "home") return null;
      if (tokens[5] !== "value" && tokens[5] !== "last") return null;
      if (node.site && tokens[0] !== node.site) return null;
      if (node.location && tokens[2] !== node.location) return null;
      if (node.accessory && tokens[4] !== node.accessory) return null;
      return {
        site: tokens[0],
        location: tokens[2],
        capability: tokens[3],
        deviceId: tokens[4],
        stream: tokens[5]
      };
    }

    function extractValue(stream, payload) {
      if (payload && typeof payload === "object" && !Array.isArray(payload) && Object.prototype.hasOwnProperty.call(payload, "value")) {
        return payload.value;
      }
      return payload;
    }

    function updateBatteryLowFromThreshold() {
      if (!node.sensorState.batteryKnown || node.sensorState.batteryLowKnown) return;
      node.sensorState.batteryLow = Number(node.sensorState.battery) <= node.batteryLowThreshold;
    }

    function processCapability(baseMsg, parsed, value) {
      var motionMsg = null;
      var occupancyMsg = null;
      var temperatureMsg = null;
      var humidityMsg = null;
      var lightMsg = null;
      var batteryMsg = null;

      node.sensorState.active = true;
      node.sensorState.site = parsed.site;
      node.sensorState.location = parsed.location;
      node.sensorState.deviceId = parsed.deviceId;
      node.lastMsgContext = cloneBaseMsg(baseMsg);

      if (parsed.capability === "motion") {
        var detected = asBool(value);
        if (detected === null) return [null, null, null, null, null, null];
        applyMotionSample(detected);
        motionMsg = buildMotionMsg(baseMsg);
        occupancyMsg = buildOccupancyMsg(baseMsg);
      } else if (parsed.capability === "presence") {
        return [null, null, null, null, null, null];
      } else if (parsed.capability === "temperature") {
        var temperature = asNumber(value);
        if (temperature === null) return [null, null, null, null, null, null];
        node.sensorState.temperatureKnown = true;
        node.sensorState.temperature = temperature;
        temperatureMsg = buildTemperatureMsg(baseMsg);
      } else if (parsed.capability === "humidity") {
        var humidity = asNumber(value);
        if (humidity === null) return [null, null, null, null, null, null];
        node.sensorState.humidityKnown = true;
        node.sensorState.humidity = humidity;
        humidityMsg = buildHumidityMsg(baseMsg);
      } else if (parsed.capability === "illuminance") {
        var illuminance = asNumber(value);
        if (illuminance === null) return [null, null, null, null, null, null];
        node.sensorState.illuminanceKnown = true;
        node.sensorState.illuminance = illuminance;
        lightMsg = buildLightMsg(baseMsg);
      } else if (parsed.capability === "battery") {
        var battery = asNumber(value);
        if (battery === null) return [null, null, null, null, null, null];
        node.sensorState.batteryKnown = true;
        node.sensorState.battery = clamp(Math.round(battery), 0, 100);
        updateBatteryLowFromThreshold();
        batteryMsg = buildBatteryMsg(baseMsg);
        motionMsg = buildMotionMsg(baseMsg);
        occupancyMsg = buildOccupancyMsg(baseMsg);
      } else if (parsed.capability === "battery_low") {
        var batteryLow = asBool(value);
        if (batteryLow === null) return [null, null, null, null, null, null];
        node.sensorState.batteryLowKnown = true;
        node.sensorState.batteryLow = batteryLow;
        batteryMsg = buildBatteryMsg(baseMsg);
        motionMsg = buildMotionMsg(baseMsg);
        occupancyMsg = buildOccupancyMsg(baseMsg);
      } else if (parsed.capability === "tamper") {
        var tampered = asBool(value);
        if (tampered === null) return [null, null, null, null, null, null];
        node.sensorState.tamperedKnown = true;
        node.sensorState.tampered = tampered;
        motionMsg = buildMotionMsg(baseMsg);
        occupancyMsg = buildOccupancyMsg(baseMsg);
        batteryMsg = buildBatteryMsg(baseMsg);
      } else {
        return [null, null, null, null, null, null];
      }

      return [motionMsg, occupancyMsg, temperatureMsg, humidityMsg, lightMsg, batteryMsg];
    }

    function startSubscriptions() {
      if (node.subscriptionState.started) return;
      if (!node.site || !node.location || !node.accessory) {
        noteError("missing site, location or accessory");
        return;
      }
      node.subscriptionState.started = true;
      node.subscriptionState.lastSubscribed = true;
      node.subscriptionState.valueSubscribed = true;
      clearBootstrapTimer();
      node.bootstrapTimer = setTimeout(function() {
        finalizeBootstrap("bootstrap-timeout");
      }, node.bootstrapDeadlineMs);
      node.stats.controls += 1;
      node.send([null, null, null, null, null, null, buildSubscribeMsgs()]);
      setNodeStatus("cold");
    }

    node.on("input", function(msg, send, done) {
      send = send || function() { node.send.apply(node, arguments); };

      try {
        var parsed = parseTopic(msg && msg.topic);
        if (!parsed) {
          noteError("invalid topic");
          if (done) done();
          return;
        }

        var value = extractValue(parsed.stream, msg.payload);
        var controlMsg = null;

        if (parsed.stream === "last") node.stats.last_inputs += 1;
        else node.stats.value_inputs += 1;

        var outputs = processCapability(msg, parsed, value);
        markBootstrapSatisfied(parsed.capability);
        send([
          outputs[0],
          outputs[1],
          outputs[2],
          outputs[3],
          outputs[4],
          outputs[5],
          controlMsg
        ]);

        setNodeStatus();
        if (done) done();
      } catch (err) {
        node.stats.errors += 1;
        node.status({ fill: "red", shape: "ring", text: "error: " + err.message });
        if (done) done(err);
        else node.error(err, msg);
      }
    });

    node.on("close", function() {
      clearBootstrapTimer();
      clearOccupancyTimer();
      if (node.startTimer) {
        clearTimeout(node.startTimer);
        node.startTimer = null;
      }
    });

    node.startTimer = setTimeout(startSubscriptions, 250);
    node.status({ fill: "grey", shape: "ring", text: "starting" });
  }

  RED.nodes.registerType("zg-204zv-homekit-adapter", Z2MZG204ZVNode);
};