1 contributor
368 lines | 12.434kb
module.exports = function(RED) {
  function SmartSocketHomeKitNode(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 = normalizeToken(config.accessory || config.mqttSensor || "");
    node.bootstrapDeadlineMs = 10000;
    node.hkCache = Object.create(null);
    node.startTimer = null;
    node.bootstrapTimer = null;
    node.lastMsgContext = null;

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

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

    node.bootstrapState = {
      finalized: false,
      power: false
    };

    node.outletState = {
      active: false,
      site: node.site || "",
      location: node.location || "",
      deviceId: node.accessory || "",
      sourceTopic: "",
      sourceRef: "",
      powerKnown: false,
      power: false
    };

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

    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 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 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 makeSetMsg(baseMsg, payload) {
      var out = RED.util.cloneMessage(baseMsg || {});
      out.topic = buildSetTopic();
      if (node.outletState.sourceTopic || node.outletState.sourceRef) {
        out.payload = {
          value: payload
        };
        if (node.outletState.sourceTopic) out.payload.source_topic = node.outletState.sourceTopic;
        if (node.outletState.sourceRef) out.payload.source_ref = node.outletState.sourceRef;
      } else {
        out.payload = payload;
      }
      out.qos = 1;
      out.retain = false;
      return out;
    }

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

    function buildSetTopic() {
      return [node.site, "home", node.location, "power", node.accessory, "set"].join("/");
    }

    function buildSubscribeMsgs() {
      return [
        { action: "subscribe", topic: buildSubscriptionTopic("last"), qos: 2, rh: 0, rap: true },
        { action: "subscribe", topic: buildSubscriptionTopic("meta"), qos: 2, rh: 0, rap: true },
        { action: "subscribe", topic: buildSubscriptionTopic("value"), qos: 2, rh: 0, rap: true },
        { action: "subscribe", topic: buildSubscriptionTopic("availability"), 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.outletState.deviceId || node.accessory || "?";
      return [
        prefix || state,
        device,
        "l:" + node.stats.last_inputs,
        "m:" + node.stats.meta_inputs,
        "v:" + node.stats.value_inputs,
        "a:" + node.stats.availability_inputs,
        "set:" + node.stats.command_outputs,
        "hk:" + node.stats.hk_updates
      ].join(" ");
    }

    function setNodeStatus(prefix, fill, shape) {
      node.status({
        fill: fill || (node.stats.errors ? "red" : (node.subscriptionState.lastSubscribed ? "yellow" : (node.outletState.active ? "green" : "yellow"))),
        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 buildOutletMsg(baseMsg) {
      if (!node.outletState.powerKnown) return null;
      var payload = {
        On: node.outletState.power ? 1 : 0,
        OutletInUse: (node.outletState.active && node.outletState.power) ? 1 : 0
      };
      if (!shouldPublish("hk:outlet", payload)) return null;
      node.stats.hk_updates += 1;
      return makeHomeKitMsg(baseMsg, payload);
    }

    function clearSnapshotCache() {
      delete node.hkCache["hk:outlet"];
    }

    function buildBootstrapOutputs(baseMsg) {
      clearSnapshotCache();
      return [buildOutletMsg(baseMsg)];
    }

    function unsubscribeLast(reason) {
      if (!node.subscriptionState.lastSubscribed) return null;
      node.subscriptionState.lastSubscribed = false;
      node.stats.controls += 1;
      return buildUnsubscribeLastMsg(reason);
    }

    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[3] !== "power") return { ignored: true };
      if (tokens[5] !== "value" && tokens[5] !== "last" && tokens[5] !== "meta" && tokens[5] !== "availability") return { ignored: true };
      if ((node.site && tokens[0] !== node.site) || (node.location && tokens[2] !== node.location) || (node.accessory && tokens[4] !== node.accessory)) return { ignored: true };
      return {
        site: tokens[0],
        location: tokens[2],
        capability: tokens[3],
        deviceId: tokens[4],
        stream: tokens[5]
      };
    }

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

    function processAvailability(baseMsg, value) {
      var active = asBool(value);
      if (active === null) return null;
      node.outletState.active = active;
      node.lastMsgContext = cloneBaseMsg(baseMsg);
      return buildOutletMsg(baseMsg);
    }

    function processMeta(baseMsg, parsed, value) {
      if (!value || typeof value !== "object" || Array.isArray(value)) return null;
      node.outletState.site = parsed.site;
      node.outletState.location = parsed.location;
      node.outletState.deviceId = parsed.deviceId;
      if (typeof value.source_topic === "string" && value.source_topic.trim()) {
        node.outletState.sourceTopic = value.source_topic.trim();
      }
      if (typeof value.source_ref === "string" && value.source_ref.trim()) {
        node.outletState.sourceRef = value.source_ref.trim();
      }
      node.lastMsgContext = cloneBaseMsg(baseMsg);
      return null;
    }

    function processPower(baseMsg, parsed, value) {
      var power = asBool(value);
      if (power === null) return null;
      node.outletState.active = true;
      node.outletState.site = parsed.site;
      node.outletState.location = parsed.location;
      node.outletState.deviceId = parsed.deviceId;
      node.outletState.powerKnown = true;
      node.outletState.power = power;
      node.lastMsgContext = cloneBaseMsg(baseMsg);
      return buildOutletMsg(baseMsg);
    }

    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], null, controlMsg]);
      setNodeStatus("live");
      return true;
    }

    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.metaSubscribed = true;
      node.subscriptionState.valueSubscribed = true;
      node.subscriptionState.availabilitySubscribed = true;
      clearBootstrapTimer();
      node.bootstrapTimer = setTimeout(function() {
        finalizeBootstrap("bootstrap-timeout");
      }, node.bootstrapDeadlineMs);
      node.stats.controls += 1;
      node.send([null, null, buildSubscribeMsgs()]);
      setNodeStatus("cold");
    }

    function translateHomeKitCommand(msg) {
      var payload = msg && msg.payload;
      if (!payload || typeof payload !== "object" || Array.isArray(payload)) return null;
      if (!Object.prototype.hasOwnProperty.call(payload, "On")) return null;
      var desired = asBool(payload.On);
      if (desired === null) return null;
      node.stats.command_outputs += 1;
      return makeSetMsg(cloneBaseMsg(msg), desired);
    }

    node.on("input", function(msg, send, done) {
      send = send || function() { node.send.apply(node, arguments); };
      try {
        var commandMsg = translateHomeKitCommand(msg);
        if (commandMsg) {
          send([null, commandMsg, null]);
          setNodeStatus("set");
          if (done) done();
          return;
        }

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

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

        if (parsed.stream === "last") {
          node.stats.last_inputs += 1;
          output = processPower(msg, parsed, value);
          node.bootstrapState.power = node.outletState.powerKnown;
        } else if (parsed.stream === "meta") {
          node.stats.meta_inputs += 1;
          output = processMeta(msg, parsed, value);
        } else if (parsed.stream === "value") {
          node.stats.value_inputs += 1;
          output = processPower(msg, parsed, value);
          node.bootstrapState.power = node.outletState.powerKnown;
        } else {
          node.stats.availability_inputs += 1;
          output = processAvailability(msg, value);
        }

        if (node.subscriptionState.lastSubscribed) {
          setNodeStatus("cold");
        } else {
          send([output, null, null]);
          setNodeStatus();
        }

        if (done) done();
      } catch (err) {
        noteError(err.message, msg);
        if (done) done(err);
        else node.error(err, msg);
      }
    });

    node.on("close", function() {
      clearBootstrapTimer();
      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("smart-socket-homekit-adapter", SmartSocketHomeKitNode);
};