1 contributor
340 lines | 12.62kb
module.exports = function(RED) {
  function PA44ZHomeBusNode(config) {
    RED.nodes.createNode(this, config);
    var node = this;

    node.adapterId = "z2m-pa-44z";
    node.sourceTopic = "zigbee2mqtt/PA-44Z/#";
    node.site = normalizeToken(config.site || config.mqttSite || "unknown");
    node.batteryType = normalizeToken(config.batteryType || "alkaline").toLowerCase() || "alkaline";
    node.batteryLowThreshold = parseNumber(config.batteryLowThreshold, 20, 0);
    node.publishCache = Object.create(null);
    node.startTimer = null;
    node.subscriptionStarted = false;

    node.stats = {
      processed: 0,
      published: 0,
      invalid: 0,
      errors: 0
    };

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

    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 transliterate(value) {
      var s = normalizeToken(value);
      if (!s) return "";
      if (typeof s.normalize === "function") {
        s = s.normalize("NFKD").replace(/[\u0300-\u036f]/g, "");
      }
      return s;
    }

    function toKebabCase(value, fallback) {
      var s = transliterate(value).toLowerCase().replace(/[^a-z0-9]+/g, "-");
      s = s.replace(/^-+|-+$/g, "").replace(/-{2,}/g, "-");
      return s || fallback || "";
    }

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

    var ALKALINE_BATTERY_CURVE = [
      { pct: 100, voltage: 1.60 },
      { pct: 90, voltage: 1.55 },
      { pct: 80, voltage: 1.50 },
      { pct: 70, voltage: 1.46 },
      { pct: 60, voltage: 1.42 },
      { pct: 50, voltage: 1.36 },
      { pct: 40, voltage: 1.30 },
      { pct: 30, voltage: 1.25 },
      { pct: 20, voltage: 1.20 },
      { pct: 10, voltage: 1.10 },
      { pct: 0, voltage: 0.90 }
    ];

    var NIMH_BATTERY_CURVE = [
      { pct: 100, voltage: 1.40 },
      { pct: 95, voltage: 1.33 },
      { pct: 90, voltage: 1.28 },
      { pct: 85, voltage: 1.24 },
      { pct: 80, voltage: 1.20 },
      { pct: 75, voltage: 1.19 },
      { pct: 70, voltage: 1.18 },
      { pct: 60, voltage: 1.17 },
      { pct: 50, voltage: 1.16 },
      { pct: 40, voltage: 1.15 },
      { pct: 30, voltage: 1.13 },
      { pct: 20, voltage: 1.11 },
      { pct: 10, voltage: 1.07 },
      { pct: 0, voltage: 1.00 }
    ];

    function interpolateCurve(points, inputKey, outputKey, inputValue) {
      if (!Array.isArray(points) || points.length === 0) return null;
      if (inputValue >= points[0][inputKey]) return points[0][outputKey];

      for (var i = 1; i < points.length; i++) {
        var upper = points[i - 1];
        var lower = points[i];
        var upperInput = upper[inputKey];
        var lowerInput = lower[inputKey];

        if (inputValue >= lowerInput) {
          var range = upperInput - lowerInput;
          if (range <= 0) return lower[outputKey];
          var ratio = (inputValue - lowerInput) / range;
          return lower[outputKey] + ratio * (upper[outputKey] - lower[outputKey]);
        }
      }

      return points[points.length - 1][outputKey];
    }

    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 toIsoTimestamp(value) {
      if (value === undefined || value === null) return "";
      if (typeof value === "string" && value.trim()) {
        var fromString = new Date(value);
        if (!isNaN(fromString.getTime())) return fromString.toISOString();
      }
      return new Date().toISOString();
    }

    function translateBatteryLevel(rawValue) {
      if (rawValue === null || rawValue === undefined) return null;
      var raw = clamp(Math.round(Number(rawValue)), 0, 100);
      if (node.batteryType !== "nimh") return raw;

      // Reinterpret the reported percentage on an alkaline discharge curve,
      // then project the equivalent cell voltage onto a flatter NiMH curve.
      var estimatedVoltage = interpolateCurve(ALKALINE_BATTERY_CURVE, "pct", "voltage", raw);
      var nimhPct = interpolateCurve(NIMH_BATTERY_CURVE, "voltage", "pct", estimatedVoltage);
      return clamp(Math.round(nimhPct), 0, 100);
    }

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

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

    function makePublish(topic, payload, retain) {
      return {
        topic: topic,
        payload: payload,
        qos: 2,
        retain: !!retain
      };
    }

    function setStatus(prefix, fill) {
      node.status({
        fill: fill || (node.stats.errors ? "red" : "green"),
        shape: "dot",
        text: [prefix || "live", "in:" + node.stats.processed, "out:" + node.stats.published, "err:" + node.stats.errors].join(" ")
      });
    }

    function buildTopic(site, location, capability, deviceId, stream) {
      return [site, "home", location, capability, deviceId, stream].join("/");
    }

    function pushCapability(messages, identity, capability, value, observedAt, meta) {
      if (value === null || value === undefined) return;

      var valueTopic = buildTopic(identity.site, identity.location, capability, identity.deviceId, "value");
      var lastTopic = buildTopic(identity.site, identity.location, capability, identity.deviceId, "last");
      var metaTopic = buildTopic(identity.site, identity.location, capability, identity.deviceId, "meta");

      if (shouldPublish(valueTopic, value)) {
        messages.push(makePublish(valueTopic, value, false));
        node.stats.published += 1;
      }

      var lastPayload = {
        value: value,
        observed_at: observedAt,
        quality: "reported"
      };
      if (shouldPublish(lastTopic, lastPayload)) {
        messages.push(makePublish(lastTopic, lastPayload, true));
        node.stats.published += 1;
      }

      if (meta && shouldPublish(metaTopic, meta)) {
        messages.push(makePublish(metaTopic, meta, true));
        node.stats.published += 1;
      }
    }

    function publishAvailability(messages, identity, active, observedAt) {
      var availabilityTopic = buildTopic(identity.site, identity.location, "adapter", identity.deviceId, "availability");
      var availabilityPayload = active;
      if (shouldPublish(availabilityTopic, availabilityPayload)) {
        messages.push(makePublish(availabilityTopic, availabilityPayload, true));
        node.stats.published += 1;
      }

      var lastTopic = buildTopic(identity.site, identity.location, "adapter", identity.deviceId, "last");
      var lastPayload = { value: active, observed_at: observedAt, quality: "reported" };
      if (shouldPublish(lastTopic, lastPayload)) {
        messages.push(makePublish(lastTopic, lastPayload, true));
        node.stats.published += 1;
      }
    }

    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 !== 5 && tokens.length !== 6) return null;
      if (tokens[0].toLowerCase() !== "zigbee2mqtt" || tokens[1].toLowerCase() !== "pa-44z") return null;
      if (tokens.length === 6 && tokens[5].toLowerCase() !== "availability") return null;
      return {
        site: toKebabCase(tokens[2], node.site || "unknown"),
        location: toKebabCase(tokens[3], "unknown"),
        deviceId: toKebabCase(tokens[4], "pa-44z"),
        availabilityTopic: tokens.length === 6
      };
    }

    function buildIdentity(parsed) {
      return {
        site: parsed.site || node.site || "unknown",
        location: parsed.location || "unknown",
        deviceId: parsed.deviceId || "pa-44z"
      };
    }

    function buildMeta(identity, capability, unit, core) {
      var out = {
        adapter_id: node.adapterId,
        device_type: "PA-44Z",
        capability: capability,
        core: !!core,
        source_ref: ["zigbee2mqtt", "PA-44Z", identity.site, identity.location, identity.deviceId].join("/")
      };
      if (unit) out.unit = unit;
      return out;
    }

    function processTelemetry(identity, payload, observedAt) {
      var messages = [];
      var smoke = asBool(payload.smoke);
      var battery = translateBatteryLevel(asNumber(payload.battery));
      var batteryLowRaw = asBool(payload.battery_low);
      var batteryLow = batteryLowRaw === null ? (battery !== null && battery <= node.batteryLowThreshold) : batteryLowRaw;
      var deviceFault = asBool(payload.device_fault);
      var silence = asBool(payload.silence);
      var test = asBool(payload.test);
      var concentration = asNumber(payload.smoke_concentration);

      publishAvailability(messages, identity, true, observedAt);
      pushCapability(messages, identity, "smoke", smoke, observedAt, buildMeta(identity, "smoke", "", true));
      pushCapability(messages, identity, "battery", battery, observedAt, buildMeta(identity, "battery", "%", true));
      pushCapability(messages, identity, "battery_low", batteryLow, observedAt, buildMeta(identity, "battery_low", "", true));
      pushCapability(messages, identity, "device_fault", deviceFault, observedAt, buildMeta(identity, "device_fault", "", true));
      pushCapability(messages, identity, "silence", silence, observedAt, buildMeta(identity, "silence", "", false));
      pushCapability(messages, identity, "test", test, observedAt, buildMeta(identity, "test", "", false));
      pushCapability(messages, identity, "smoke_concentration", concentration, observedAt, buildMeta(identity, "smoke_concentration", "ppm", false));

      return messages;
    }

    function startSubscriptions() {
      if (node.subscriptionStarted) return;
      node.subscriptionStarted = true;
      node.send([null, { action: "subscribe", topic: node.sourceTopic, qos: 2, rh: 0, rap: true }]);
      setStatus("subscribed", "yellow");
    }

    node.on("input", function(msg, send, done) {
      send = send || function() { node.send.apply(node, arguments); };
      try {
        node.stats.processed += 1;
        var parsed = parseTopic(msg && msg.topic);
        if (!parsed) {
          node.stats.invalid += 1;
          setStatus("invalid", "red");
          if (done) done();
          return;
        }

        var identity = buildIdentity(parsed);
        var observedAt = toIsoTimestamp(msg && msg.payload && msg.payload.last_seen);
        var messages = [];

        if (parsed.availabilityTopic) {
          var active = asBool(msg.payload);
          if (active === null) active = false;
          publishAvailability(messages, identity, active, observedAt);
        } else if (msg.payload && typeof msg.payload === "object" && !Array.isArray(msg.payload)) {
          messages = processTelemetry(identity, msg.payload, observedAt);
        } else {
          node.stats.invalid += 1;
          setStatus("invalid", "red");
          if (done) done();
          return;
        }

        if (messages.length) send([messages, null]);
        setStatus();
        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() {
      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("z2m-pa-44z-homebus", PA44ZHomeBusNode);
};