1 contributor
987 lines | 36.692kb
module.exports = function(RED) {
  function Z2MZG204ZVHomeBusNode(config) {
    RED.nodes.createNode(this, config);
    var node = this;

    node.adapterId = "z2m-zg-204zv";
    node.sourceTopic = "zigbee2mqtt/ZG-204ZV/#";
    node.subscriptionStarted = false;
    node.startTimer = null;
    node.legacyRoom = normalizeToken(config.mqttRoom);
    node.legacySensor = normalizeToken(config.mqttSensor);
    node.legacyBus = normalizeToken(config.mqttBus);
    node.publishCache = Object.create(null);
    node.retainedCache = Object.create(null);
    node.seenOptionalCapabilities = Object.create(null);
    node.detectedDevices = Object.create(null);
    node.stats = {
      processed_inputs: 0,
      devices_detected: 0,
      home_messages: 0,
      last_messages: 0,
      meta_messages: 0,
      home_availability_messages: 0,
      operational_messages: 0,
      invalid_messages: 0,
      invalid_topics: 0,
      invalid_payloads: 0,
      unmapped_messages: 0,
      adapter_exceptions: 0,
      errors: 0,
      dlq: 0
    };
    node.statsPublishEvery = 25;
    node.batteryLowThreshold = Number(config.batteryLowThreshold);
    if (!Number.isFinite(node.batteryLowThreshold)) node.batteryLowThreshold = 20;
    node.batteryType = normalizeToken(config.batteryType).toLowerCase() || "alkaline";

    var CAPABILITY_MAPPINGS = [
      {
        sourceSystem: "zigbee2mqtt",
        sourceTopicMatch: "zigbee2mqtt/ZG-204ZV/<site>/<location>/<device_id>",
        sourceFields: ["presence", "occupancy"],
        targetBus: "home",
        targetCapability: "motion",
        core: true,
        stream: "value",
        payloadProfile: "scalar",
        dataType: "boolean",
        historianMode: "event",
        historianEnabled: true,
        applies: function(payload) {
          return readNumber(payload, "fading_time") === 0;
        },
        read: function(payload) {
          return readBool(payload, this.sourceFields);
        }
      },
      {
        sourceSystem: "zigbee2mqtt",
        sourceTopicMatch: "zigbee2mqtt/ZG-204ZV/<site>/<location>/<device_id>",
        sourceFields: ["presence", "occupancy"],
        targetBus: "home",
        targetCapability: "presence",
        core: true,
        stream: "value",
        payloadProfile: "scalar",
        dataType: "boolean",
        historianMode: "state",
        historianEnabled: true,
        applies: function(payload) {
          var fadingTime = readNumber(payload, "fading_time");
          return fadingTime === undefined || fadingTime !== 0;
        },
        read: function(payload) {
          return readBool(payload, this.sourceFields);
        }
      },
      {
        sourceSystem: "zigbee2mqtt",
        sourceTopicMatch: "zigbee2mqtt/ZG-204ZV/<site>/<location>/<device_id>",
        sourceFields: ["temperature"],
        targetBus: "home",
        targetCapability: "temperature",
        core: true,
        stream: "value",
        payloadProfile: "scalar",
        dataType: "number",
        unit: "C",
        precision: 0.1,
        historianMode: "sample",
        historianEnabled: true,
        read: function(payload) {
          return readNumber(payload, this.sourceFields[0]);
        }
      },
      {
        sourceSystem: "zigbee2mqtt",
        sourceTopicMatch: "zigbee2mqtt/ZG-204ZV/<site>/<location>/<device_id>",
        sourceFields: ["humidity"],
        targetBus: "home",
        targetCapability: "humidity",
        core: true,
        stream: "value",
        payloadProfile: "scalar",
        dataType: "number",
        unit: "%",
        precision: 1,
        historianMode: "sample",
        historianEnabled: true,
        read: function(payload) {
          return readNumber(payload, this.sourceFields[0]);
        }
      },
      {
        sourceSystem: "zigbee2mqtt",
        sourceTopicMatch: "zigbee2mqtt/ZG-204ZV/<site>/<location>/<device_id>",
        sourceFields: ["illuminance"],
        targetBus: "home",
        targetCapability: "illuminance",
        core: true,
        stream: "value",
        payloadProfile: "scalar",
        dataType: "number",
        unit: "lx",
        precision: 1,
        historianMode: "sample",
        historianEnabled: true,
        read: function(payload) {
          return readNumber(payload, this.sourceFields[0]);
        }
      },
      {
        sourceSystem: "zigbee2mqtt",
        sourceTopicMatch: "zigbee2mqtt/ZG-204ZV/<site>/<location>/<device_id>",
        sourceFields: ["battery"],
        targetBus: "home",
        targetCapability: "battery",
        core: true,
        stream: "value",
        payloadProfile: "scalar",
        dataType: "number",
        unit: "%",
        precision: 1,
        historianMode: "sample",
        historianEnabled: true,
        read: function(payload) {
          var value = readNumber(payload, this.sourceFields[0]);
          if (value === undefined) return undefined;
          return translateBatteryLevel(value);
        }
      },
      {
        sourceSystem: "zigbee2mqtt",
        sourceTopicMatch: "zigbee2mqtt/ZG-204ZV/<site>/<location>/<device_id>",
        sourceFields: ["battery_low", "batteryLow", "battery"],
        targetBus: "home",
        targetCapability: "battery_low",
        core: true,
        stream: "value",
        payloadProfile: "scalar",
        dataType: "boolean",
        historianMode: "state",
        historianEnabled: true,
        read: function(payload) {
          var raw = readBool(payload, this.sourceFields.slice(0, 2));
          if (raw !== undefined) return raw;
          var battery = translateBatteryLevel(readNumber(payload, this.sourceFields[2]));
          if (battery === undefined) return undefined;
          return battery <= node.batteryLowThreshold;
        }
      },
      {
        sourceSystem: "zigbee2mqtt",
        sourceTopicMatch: "zigbee2mqtt/ZG-204ZV/<site>/<location>/<device_id>",
        sourceFields: ["tamper", "tampered", "tamper_alarm", "alarm_tamper"],
        targetBus: "home",
        targetCapability: "tamper",
        core: false,
        stream: "value",
        payloadProfile: "scalar",
        dataType: "boolean",
        historianMode: "state",
        historianEnabled: true,
        read: function(payload) {
          return readBool(payload, this.sourceFields);
        }
      }
    ];

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

    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 undefined;
      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 translateBatteryLevel(rawValue) {
      if (rawValue === undefined) return undefined;
      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 topicTokens(topic) {
      if (typeof topic !== "string") return [];
      return topic.split("/").map(function(token) { return token.trim(); }).filter(function(token) { return !!token; });
    }

    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 pickFirst(obj, keys) {
      if (!obj || typeof obj !== "object") return undefined;
      for (var i = 0; i < keys.length; i++) {
        if (Object.prototype.hasOwnProperty.call(obj, keys[i])) return obj[keys[i]];
      }
      return undefined;
    }

    function pickPath(obj, path) {
      if (!obj || typeof obj !== "object") return undefined;
      var parts = path.split(".");
      var current = obj;
      for (var i = 0; i < parts.length; i++) {
        if (!current || typeof current !== "object" || !Object.prototype.hasOwnProperty.call(current, parts[i])) return undefined;
        current = current[parts[i]];
      }
      return current;
    }

    function pickFirstPath(obj, paths) {
      for (var i = 0; i < paths.length; i++) {
        var value = pickPath(obj, paths[i]);
        if (value !== undefined && value !== null && normalizeToken(value) !== "") return value;
      }
      return undefined;
    }

    function readBool(payload, fields) {
      var raw = asBool(pickFirst(payload, fields));
      return raw === null ? undefined : raw;
    }

    function readNumber(payload, field) {
      if (!payload || typeof payload !== "object") return undefined;
      var value = payload[field];
      if (typeof value !== "number" || !isFinite(value)) return undefined;
      return Number(value);
    }

    function toIsoTimestamp(value) {
      if (value === undefined || value === null) return "";
      if (value instanceof Date && !isNaN(value.getTime())) return value.toISOString();
      if (typeof value === "number" && isFinite(value)) {
        var ms = value < 100000000000 ? value * 1000 : value;
        var dateFromNumber = new Date(ms);
        return isNaN(dateFromNumber.getTime()) ? "" : dateFromNumber.toISOString();
      }
      if (typeof value === "string") {
        var trimmed = value.trim();
        if (!trimmed) return "";
        if (/^\d+$/.test(trimmed)) return toIsoTimestamp(Number(trimmed));
        var dateFromString = new Date(trimmed);
        return isNaN(dateFromString.getTime()) ? "" : dateFromString.toISOString();
      }
      return "";
    }

    function canonicalSourceTopic(topic) {
      var t = normalizeToken(topic);
      if (!t) return "";
      if (/\/availability$/i.test(t)) return t.replace(/\/availability$/i, "");
      return t;
    }

    function inferFromTopic(topic) {
      var tokens = topicTokens(topic);
      var result = {
        deviceType: "",
        site: "",
        location: "",
        deviceId: "",
        friendlyName: "",
        isAvailabilityTopic: false
      };

      if (tokens.length >= 2 && tokens[0].toLowerCase() === "zigbee2mqtt") {
        result.deviceType = tokens[1];

        if (tokens.length >= 6 && tokens[5].toLowerCase() === "availability") {
          result.site = tokens[2];
          result.location = tokens[3];
          result.deviceId = tokens[4];
          result.friendlyName = tokens.slice(1, 5).join("/");
          result.isAvailabilityTopic = true;
          return result;
        }

        if (tokens.length >= 5 && tokens[4].toLowerCase() !== "availability") {
          result.site = tokens[2];
          result.location = tokens[3];
          result.deviceId = tokens[4];
          result.friendlyName = tokens.slice(1, 5).join("/");
          return result;
        }

        if (tokens.length >= 5 && tokens[4].toLowerCase() === "availability") {
          result.location = tokens[2];
          result.deviceId = tokens[3];
          result.friendlyName = tokens.slice(1, 4).join("/");
          result.isAvailabilityTopic = true;
          return result;
        }

        if (tokens.length >= 4) {
          result.location = tokens[2];
          result.deviceId = tokens[3];
          result.friendlyName = tokens.slice(1, 4).join("/");
          return result;
        }

        result.friendlyName = tokens.slice(1).join("/");
        result.deviceId = tokens[1];
        result.isAvailabilityTopic = tokens.length >= 3 && tokens[tokens.length - 1].toLowerCase() === "availability";
        return result;
      }

      if (tokens.length >= 5 && tokens[1].toLowerCase() === "home") {
        result.site = tokens[0];
        result.location = tokens[2];
        result.deviceId = tokens[4];
        result.isAvailabilityTopic = tokens.length >= 6 && tokens[5].toLowerCase() === "availability";
        return result;
      }

      if (tokens.length >= 4) {
        result.site = tokens[0];
        result.location = tokens[2];
        result.deviceId = tokens[3];
      } else if (tokens.length >= 2) {
        result.location = tokens[tokens.length - 2];
        result.deviceId = tokens[tokens.length - 1];
      } else if (tokens.length === 1) {
        result.deviceId = tokens[0];
      }

      return result;
    }

    function resolveIdentity(msg, payload) {
      var safeMsg = (msg && typeof msg === "object") ? msg : {};
      var inferred = inferFromTopic(safeMsg.topic);
      var siteRaw = inferred.site
        || normalizeToken(pickFirst(payload, ["site", "homeSite"]))
        || normalizeToken(pickFirst(safeMsg, ["site", "homeSite"]))
        || "";

      var locationRaw = inferred.location
        || normalizeToken(pickFirst(payload, ["location", "room", "homeLocation", "mqttRoom"]))
        || normalizeToken(pickFirst(safeMsg, ["location", "room", "homeLocation", "mqttRoom"]))
        || node.legacyRoom
        || "";

      var deviceRaw = inferred.deviceId
        || normalizeToken(pickFirst(payload, ["deviceId", "device_id", "sensor", "mqttSensor", "friendly_name", "friendlyName", "name", "device"]))
        || normalizeToken(pickFirst(safeMsg, ["deviceId", "device_id", "sensor", "mqttSensor"]))
        || node.legacySensor
        || inferred.friendlyName
        || inferred.deviceType;

      var displayName = normalizeToken(pickFirst(payload, ["display_name", "displayName", "friendly_name", "friendlyName", "name"]))
        || normalizeToken(pickFirst(safeMsg, ["display_name", "displayName", "friendly_name", "friendlyName", "name"]))
        || inferred.friendlyName
        || deviceRaw
        || "ZG-204ZV";

      var sourceRef = normalizeToken(pickFirstPath(payload, ["source_ref", "sourceRef", "ieee_address", "ieeeAddr", "device.ieee_address", "device.ieeeAddr"]))
        || normalizeToken(pickFirstPath(safeMsg, ["source_ref", "sourceRef", "ieee_address", "ieeeAddr", "device.ieee_address", "device.ieeeAddr"]))
        || canonicalSourceTopic(safeMsg.topic)
        || inferred.friendlyName
        || inferred.deviceType
        || deviceRaw
        || "z2m-zg-204zv";

      return {
        site: toKebabCase(siteRaw, "unknown"),
        location: toKebabCase(locationRaw, "unknown"),
        deviceId: toKebabCase(deviceRaw, toKebabCase(inferred.deviceType, "zg-204zv")),
        displayName: displayName,
        sourceRef: sourceRef,
        sourceTopic: canonicalSourceTopic(safeMsg.topic),
        isAvailabilityTopic: inferred.isAvailabilityTopic
      };
    }

    function noteDevice(identity) {
      if (!identity) return;
      var key = identity.site + "/" + identity.location + "/" + identity.deviceId;
      if (node.detectedDevices[key]) return;
      node.detectedDevices[key] = true;
      node.stats.devices_detected += 1;
    }

    function validateInboundTopic(topic) {
      var rawTopic = normalizeToken(topic);
      if (!rawTopic) {
        return {
          valid: false,
          reason: "Topic must be a non-empty string"
        };
      }

      var tokens = rawTopic.split("/").map(function(token) {
        return token.trim();
      });
      if (tokens.length < 5) {
        return {
          valid: false,
          reason: "Topic must match zigbee2mqtt/ZG-204ZV/<site>/<location>/<device_id>[/availability]"
        };
      }
      if (tokens[0].toLowerCase() !== "zigbee2mqtt" || tokens[1].toLowerCase() !== "zg-204zv") {
        return {
          valid: false,
          reason: "Topic must start with zigbee2mqtt/ZG-204ZV"
        };
      }
      if (!tokens[2] || !tokens[3] || !tokens[4]) {
        return {
          valid: false,
          reason: "Topic must contain non-empty site, location and device_id segments"
        };
      }
      if (tokens.length === 5) {
        return {
          valid: true,
          isAvailabilityTopic: false
        };
      }
      if (tokens.length === 6 && tokens[5].toLowerCase() === "availability") {
        return {
          valid: true,
          isAvailabilityTopic: true
        };
      }
      return {
        valid: false,
        reason: "Topic must not contain extra segments beyond an optional /availability suffix"
      };
    }

    function translatedMessageCount() {
      return node.stats.home_messages + node.stats.last_messages + node.stats.meta_messages + node.stats.home_availability_messages;
    }

    function updateNodeStatus(fill, shape, suffix) {
      var parts = [
        "dev " + node.stats.devices_detected,
        "in " + node.stats.processed_inputs,
        "tr " + translatedMessageCount()
      ];
      if (node.stats.operational_messages > 0) parts.push("op " + node.stats.operational_messages);
      if (node.stats.errors > 0) parts.push("err " + node.stats.errors);
      if (node.stats.invalid_topics > 0) parts.push("topic " + node.stats.invalid_topics);
      if (node.stats.invalid_messages > 0) parts.push("msg " + node.stats.invalid_messages);
      if (node.stats.invalid_payloads > 0) parts.push("payload " + node.stats.invalid_payloads);
      if (node.stats.unmapped_messages > 0) parts.push("unmapped " + node.stats.unmapped_messages);
      if (node.stats.adapter_exceptions > 0) parts.push("exc " + node.stats.adapter_exceptions);
      if (node.stats.dlq > 0) parts.push("dlq " + node.stats.dlq);
      if (suffix) parts.push(suffix);
      node.status({
        fill: fill,
        shape: shape,
        text: parts.join(" | ")
      });
    }

    function buildHomeTopic(identity, mapping, stream) {
      return identity.site + "/" + mapping.targetBus + "/" + identity.location + "/" + mapping.targetCapability + "/" + identity.deviceId + "/" + stream;
    }

    function buildSysTopic(site, stream) {
      return site + "/sys/adapter/" + node.adapterId + "/" + stream;
    }

    function makePublishMsg(topic, payload, retain) {
      return {
        topic: topic,
        payload: payload,
        qos: 1,
        retain: !!retain
      };
    }

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

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

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

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

    function humanizeCapability(capability) {
      return capability.replace(/_/g, " ").replace(/\b[a-z]/g, function(ch) { return ch.toUpperCase(); });
    }

    function resolveObservation(msg, payloadObject) {
      var safeMsg = (msg && typeof msg === "object") ? msg : {};
      var observedAtRaw = pickFirstPath(payloadObject, [
        "observed_at",
        "observedAt",
        "timestamp",
        "time",
        "ts",
        "last_seen",
        "lastSeen",
        "device.last_seen",
        "device.lastSeen"
      ]) || pickFirstPath(safeMsg, [
        "observed_at",
        "observedAt",
        "timestamp",
        "time",
        "ts"
      ]);
      var observedAt = toIsoTimestamp(observedAtRaw);
      if (observedAt) {
        return {
          observedAt: observedAt,
          quality: "good"
        };
      }
      return {
        observedAt: new Date().toISOString(),
        quality: "estimated"
      };
    }

    function buildMetaPayload(identity, mapping) {
      var payload = {
        schema_ref: "mqbus.home.v1",
        payload_profile: mapping.payloadProfile,
        stream_payload_profiles: {
          value: "scalar",
          last: "envelope"
        },
        data_type: mapping.dataType,
        adapter_id: node.adapterId,
        source: mapping.sourceSystem,
        source_ref: identity.sourceRef,
        source_topic: identity.sourceTopic,
        display_name: identity.displayName + " " + humanizeCapability(mapping.targetCapability),
        tags: [mapping.sourceSystem, "zg-204zv", mapping.targetBus],
        historian: {
          enabled: !!mapping.historianEnabled,
          mode: mapping.historianMode
        }
      };

      if (mapping.unit) payload.unit = mapping.unit;
      if (mapping.precision !== undefined) payload.precision = mapping.precision;

      return payload;
    }

    function buildLastPayload(value, observation) {
      var payload = {
        value: value,
        observed_at: observation.observedAt
      };
      if (observation.quality && observation.quality !== "good") payload.quality = observation.quality;
      return payload;
    }

    function noteMessage(kind) {
      if (!Object.prototype.hasOwnProperty.call(node.stats, kind)) return;
      node.stats[kind] += 1;
    }

    function noteErrorKind(code) {
      if (code === "invalid_message") {
        noteMessage("invalid_messages");
      } else if (code === "invalid_topic") {
        noteMessage("invalid_topics");
      } else if (code === "payload_not_object" || code === "invalid_availability_payload") {
        noteMessage("invalid_payloads");
      } else if (code === "no_mapped_fields") {
        noteMessage("unmapped_messages");
      } else if (code === "adapter_exception") {
        noteMessage("adapter_exceptions");
      }
    }

    function summarizeForLog(value) {
      if (value === undefined) return "undefined";
      if (value === null) return "null";
      if (typeof value === "string") {
        return value.length > 180 ? value.slice(0, 177) + "..." : value;
      }
      try {
        var serialized = JSON.stringify(value);
        if (serialized.length > 180) return serialized.slice(0, 177) + "...";
        return serialized;
      } catch (err) {
        return String(value);
      }
    }

    function logIssue(level, code, reason, msg, rawPayload) {
      var parts = [
        "[" + node.adapterId + "]",
        code + ":",
        reason
      ];
      var sourceTopic = msg && typeof msg === "object" ? normalizeToken(msg.topic) : "";
      if (sourceTopic) parts.push("topic=" + sourceTopic);
      if (rawPayload !== undefined) parts.push("payload=" + summarizeForLog(rawPayload));

      var text = parts.join(" ");
      if (level === "error") {
        node.error(text, msg);
        return;
      }
      node.warn(text);
    }

    function enqueueHomeMeta(messages, identity, mapping) {
      var topic = buildHomeTopic(identity, mapping, "meta");
      var payload = buildMetaPayload(identity, mapping);
      if (!shouldPublishRetained("meta:" + topic, payload)) return;
      messages.push(makePublishMsg(topic, payload, true));
      noteMessage("meta_messages");
    }

    function enqueueHomeAvailability(messages, identity, mapping, online) {
      var topic = buildHomeTopic(identity, mapping, "availability");
      var payload = online ? "online" : "offline";
      if (!shouldPublishRetained("availability:" + topic, payload)) return;
      messages.push(makePublishMsg(topic, payload, true));
      noteMessage("home_availability_messages");
    }

    function enqueueHomeLast(messages, identity, mapping, value, observation) {
      var topic = buildHomeTopic(identity, mapping, "last");
      var payload = buildLastPayload(value, observation);
      if (!shouldPublishRetained("last:" + topic, payload)) return;
      messages.push(makePublishMsg(topic, payload, true));
      noteMessage("last_messages");
    }

    function enqueueHomeValue(messages, identity, mapping, value) {
      var topic = buildHomeTopic(identity, mapping, "value");
      if (!shouldPublishLiveValue("value:" + topic, value)) return;
      messages.push(makePublishMsg(topic, value, false));
      noteMessage("home_messages");
    }

    function enqueueAdapterAvailability(messages, site, online) {
      var topic = buildSysTopic(site, "availability");
      var payload = online ? "online" : "offline";
      if (!shouldPublishRetained("sys:availability:" + topic, payload)) return;
      messages.push(makePublishMsg(topic, payload, true));
      noteMessage("operational_messages");
    }

    function enqueueAdapterStats(messages, site, force) {
      if (!force && node.stats.processed_inputs !== 1 && (node.stats.processed_inputs % node.statsPublishEvery) !== 0) return;
      var topic = buildSysTopic(site, "stats");
      var payload = {
        processed_inputs: node.stats.processed_inputs,
        devices_detected: node.stats.devices_detected,
        translated_messages: translatedMessageCount(),
        home_messages: node.stats.home_messages,
        last_messages: node.stats.last_messages,
        meta_messages: node.stats.meta_messages,
        home_availability_messages: node.stats.home_availability_messages,
        operational_messages: node.stats.operational_messages,
        invalid_messages: node.stats.invalid_messages,
        invalid_topics: node.stats.invalid_topics,
        invalid_payloads: node.stats.invalid_payloads,
        unmapped_messages: node.stats.unmapped_messages,
        adapter_exceptions: node.stats.adapter_exceptions,
        errors: node.stats.errors,
        dlq: node.stats.dlq
      };
      messages.push(makePublishMsg(topic, payload, true));
      noteMessage("operational_messages");
    }

    function enqueueError(messages, site, code, reason, sourceTopic) {
      var payload = {
        code: code,
        reason: reason,
        source_topic: normalizeToken(sourceTopic),
        adapter_id: node.adapterId
      };
      messages.push(makePublishMsg(buildSysTopic(site, "error"), payload, false));
      noteErrorKind(code);
      noteMessage("errors");
      noteMessage("operational_messages");
    }

    function enqueueDlq(messages, site, code, sourceTopic, rawPayload) {
      var payload = {
        code: code,
        source_topic: normalizeToken(sourceTopic),
        payload: rawPayload
      };
      messages.push(makePublishMsg(buildSysTopic(site, "dlq"), payload, false));
      noteMessage("dlq");
      noteMessage("operational_messages");
    }

    function activeMappings(payloadObject) {
      var result = [];
      for (var i = 0; i < CAPABILITY_MAPPINGS.length; i++) {
        var mapping = CAPABILITY_MAPPINGS[i];
        if (typeof mapping.applies === "function" && !mapping.applies(payloadObject)) continue;
        var value = payloadObject ? mapping.read(payloadObject) : undefined;
        var seen = mapping.core || node.seenOptionalCapabilities[mapping.targetCapability];
        if (value !== undefined) node.seenOptionalCapabilities[mapping.targetCapability] = true;
        if (mapping.core || seen || value !== undefined) {
          result.push({
            mapping: mapping,
            hasValue: value !== undefined,
            value: value
          });
        }
      }
      return result;
    }

    function resolveOnlineState(payloadObject, availabilityValue) {
      if (availabilityValue !== null && availabilityValue !== undefined) return !!availabilityValue;
      if (!payloadObject) return true;
      var active = true;
      if (typeof payloadObject.availability === "string") active = payloadObject.availability.trim().toLowerCase() !== "offline";
      if (typeof payloadObject.online === "boolean") active = payloadObject.online;
      return active;
    }

    function flush(send, done, controlMessages, publishMessages, error) {
      send([
        publishMessages.length ? publishMessages : null,
        controlMessages.length ? controlMessages : null
      ]);
      if (done) done(error);
    }

    function startSubscriptions() {
      if (node.subscriptionStarted) return;
      node.subscriptionStarted = true;
      node.send([null, makeSubscribeMsg(node.sourceTopic)]);
      updateNodeStatus("grey", "dot", "subscribed");
    }

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

      try {
        var controlMessages = [];
        if (!msg || typeof msg !== "object") {
          var invalidMessages = [];
          var invalidSite = resolveIdentity({}, null).site;
          enqueueAdapterAvailability(invalidMessages, invalidSite, true);
          enqueueError(invalidMessages, invalidSite, "invalid_message", "Input must be an object", "");
          enqueueDlq(invalidMessages, invalidSite, "invalid_message", "", null);
          enqueueAdapterStats(invalidMessages, invalidSite, true);
          logIssue("warn", "invalid_message", "Input must be an object", null, msg);
          updateNodeStatus("yellow", "ring", "bad msg");
          flush(send, done, controlMessages, invalidMessages);
          return;
        }

        var payload = msg.payload;
        var payloadObject = payload && typeof payload === "object" && !Array.isArray(payload) ? payload : null;
        var identity = resolveIdentity(msg, payloadObject);
        var observation = resolveObservation(msg, payloadObject);
        var messages = [];
        var topicValidation = validateInboundTopic(msg.topic);
        var availabilityValue = null;

        enqueueAdapterAvailability(messages, identity.site, true);

        if (!topicValidation.valid) {
          enqueueError(messages, identity.site, "invalid_topic", topicValidation.reason, msg.topic);
          enqueueDlq(messages, identity.site, "invalid_topic", msg.topic, payload);
          enqueueAdapterStats(messages, identity.site, true);
          logIssue("warn", "invalid_topic", topicValidation.reason, msg, payload);
          updateNodeStatus("yellow", "ring", "bad topic");
          flush(send, done, controlMessages, messages);
          return;
        }

        identity.isAvailabilityTopic = topicValidation.isAvailabilityTopic;
        noteDevice(identity);

        var isAvailabilityTopic = topicValidation.isAvailabilityTopic;

        if (isAvailabilityTopic) {
          availabilityValue = asBool(payload);
          if (availabilityValue === null) {
            enqueueError(messages, identity.site, "invalid_availability_payload", "Availability payload must be online/offline or boolean", msg.topic);
            enqueueDlq(messages, identity.site, "invalid_availability_payload", msg.topic, payload);
            enqueueAdapterStats(messages, identity.site, true);
            logIssue("warn", "invalid_availability_payload", "Availability payload must be online/offline or boolean", msg, payload);
            updateNodeStatus("yellow", "ring", "invalid availability");
            flush(send, done, controlMessages, messages);
            return;
          }
        } else if (!payloadObject) {
          enqueueError(messages, identity.site, "payload_not_object", "Telemetry payload must be an object", msg.topic);
          enqueueDlq(messages, identity.site, "payload_not_object", msg.topic, payload);
          enqueueAdapterStats(messages, identity.site, true);
          logIssue("warn", "payload_not_object", "Telemetry payload must be an object", msg, payload);
          updateNodeStatus("yellow", "ring", "payload not object");
          flush(send, done, controlMessages, messages);
          return;
        }

        var online = resolveOnlineState(payloadObject, availabilityValue);
        var mappings = activeMappings(payloadObject);
        var hasMappedValue = false;
        for (var i = 0; i < mappings.length; i++) {
          if (mappings[i].hasValue) {
            hasMappedValue = true;
            break;
          }
        }
        var hasAvailabilityField = isAvailabilityTopic || (payloadObject && (typeof payloadObject.availability === "string" || typeof payloadObject.online === "boolean"));

        if (!hasMappedValue && !hasAvailabilityField) {
          enqueueError(messages, identity.site, "no_mapped_fields", "Payload did not contain any supported ZG-204ZV fields", msg.topic);
          enqueueDlq(messages, identity.site, "no_mapped_fields", msg.topic, payloadObject);
          logIssue("warn", "no_mapped_fields", "Payload did not contain any supported ZG-204ZV fields", msg, payloadObject);
        }

        for (var i = 0; i < mappings.length; i++) {
          enqueueHomeMeta(messages, identity, mappings[i].mapping);
          enqueueHomeAvailability(messages, identity, mappings[i].mapping, online);
          if (mappings[i].hasValue) {
            enqueueHomeLast(messages, identity, mappings[i].mapping, mappings[i].value, observation);
            enqueueHomeValue(messages, identity, mappings[i].mapping, mappings[i].value);
          }
        }

        enqueueAdapterStats(messages, identity.site, false);

        if (!hasMappedValue && !hasAvailabilityField) {
          updateNodeStatus("yellow", "ring", "unmapped");
        } else {
          updateNodeStatus(online ? "green" : "yellow", online ? "dot" : "ring", online ? null : "offline");
        }

        flush(send, done, controlMessages, messages);
      } catch (err) {
        var errorPayload = msg && msg.payload && typeof msg.payload === "object" && !Array.isArray(msg.payload) ? msg.payload : null;
        var errorIdentity = resolveIdentity(msg, errorPayload);
        noteDevice(errorIdentity);
        var errorMessages = [];
        enqueueAdapterAvailability(errorMessages, errorIdentity.site, true);
        enqueueError(errorMessages, errorIdentity.site, "adapter_exception", err.message, msg && msg.topic);
        enqueueAdapterStats(errorMessages, errorIdentity.site, true);
        logIssue("error", "adapter_exception", err.message, msg, msg && msg.payload);
        updateNodeStatus("red", "ring", "error");
        flush(send, done, [], errorMessages, err);
      }
    });

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

    node.startTimer = setTimeout(startSubscriptions, 250);
    updateNodeStatus("grey", "ring", "waiting");
  }

  RED.nodes.registerType("z2m-zg-204zv-homebus", Z2MZG204ZVHomeBusNode);
};