module.exports = function(RED) { function Z2MSNZB04PHomeBusNode(config) { RED.nodes.createNode(this, config); var node = this; node.adapterId = "z2m-snzb-04p"; node.sourceTopic = "zigbee2mqtt/SNZB-04P/#"; node.subscriptionStarted = false; node.startTimer = null; node.site = normalizeToken(config.site || config.mqttSite); node.legacyRoom = normalizeToken(config.mqttRoom); node.legacySensor = normalizeToken(config.mqttSensor); 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; var CAPABILITY_MAPPINGS = [ { sourceSystem: "zigbee2mqtt", sourceTopicMatch: "zigbee2mqtt/SNZB-04P///", sourceFields: ["contact"], targetBus: "home", targetCapability: "contact", core: true, stream: "value", payloadProfile: "scalar", dataType: "boolean", historianMode: "state", historianEnabled: true, read: function(payload) { return readBool(payload, this.sourceFields); } }, { sourceSystem: "zigbee2mqtt", sourceTopicMatch: "zigbee2mqtt/SNZB-04P///", 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 clamp(Math.round(value), 0, 100); } }, { sourceSystem: "zigbee2mqtt", sourceTopicMatch: "zigbee2mqtt/SNZB-04P///", 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 = readNumber(payload, this.sourceFields[2]); if (battery === undefined) return undefined; return battery <= node.batteryLowThreshold; } }, { sourceSystem: "zigbee2mqtt", sourceTopicMatch: "zigbee2mqtt/SNZB-04P///", sourceFields: ["voltage"], targetBus: "home", targetCapability: "voltage", core: false, stream: "value", payloadProfile: "scalar", dataType: "number", unit: "mV", precision: 1, historianMode: "sample", historianEnabled: true, read: function(payload) { return readNumber(payload, this.sourceFields[0]); } }, { sourceSystem: "zigbee2mqtt", sourceTopicMatch: "zigbee2mqtt/SNZB-04P///", 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)); } 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; } } 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"])) || node.site || ""; 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 || "SNZB-04P"; 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-snzb-04p"; return { site: toKebabCase(siteRaw, "unknown"), location: toKebabCase(locationRaw, "unknown"), deviceId: toKebabCase(deviceRaw, "snzb-04p"), 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/SNZB-04P///[/availability]" }; } if (tokens[0].toLowerCase() !== "zigbee2mqtt" || tokens[1].toLowerCase() !== "snzb-04p") { return { valid: false, reason: "Topic must start with zigbee2mqtt/SNZB-04P" }; } 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 (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 shouldPublish(cacheKey, payload, retain) { var sig = signature(payload) + "::" + (retain ? "r" : "n"); 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 buildLastEnvelope(value, observedAt, quality) { return { value: value, observed_at: observedAt || new Date().toISOString(), quality: quality || "source" }; } function emitCapability(out, identity, mapping, value, observedAt, quality) { var valueTopic = buildHomeTopic(identity, mapping, "value"); var lastTopic = buildHomeTopic(identity, mapping, "last"); var valueKey = valueTopic + "::value"; var lastPayload = buildLastEnvelope(value, observedAt, quality); if (shouldPublish(valueKey, value, false)) { out.push(makePublishMsg(valueTopic, value, false)); node.stats.home_messages += 1; } if (shouldPublishRetained(lastTopic, lastPayload)) { out.push(makePublishMsg(lastTopic, lastPayload, true)); node.stats.last_messages += 1; } node.seenOptionalCapabilities[mapping.targetCapability] = true; } function emitMeta(out, identity, sourcePayload) { var topic = identity.site + "/home/" + identity.location + "/meta/" + identity.deviceId + "/last"; var payload = { adapter_id: node.adapterId, device_type: "SNZB-04P", display_name: identity.displayName, source_ref: identity.sourceRef, source_topic: identity.sourceTopic }; if (sourcePayload && sourcePayload.update) payload.update = sourcePayload.update; if (shouldPublishRetained(topic, payload)) { out.push(makePublishMsg(topic, payload, true)); node.stats.meta_messages += 1; } } function emitAvailability(out, identity, online) { var topic = identity.site + "/home/" + identity.location + "/availability/" + identity.deviceId + "/last"; var payload = online ? "online" : "offline"; if (shouldPublishRetained(topic, payload)) { out.push(makePublishMsg(topic, payload, true)); node.stats.home_availability_messages += 1; } } function emitOperational(out, site, stream, payload, retain) { out.push(makePublishMsg(buildSysTopic(site || "unknown", stream), payload, retain)); node.stats.operational_messages += 1; } function processTelemetry(msg) { var out = []; var payload = (msg && typeof msg.payload === "object" && msg.payload && !Array.isArray(msg.payload)) ? msg.payload : null; if (!payload) { node.stats.invalid_payloads += 1; emitOperational(out, "unknown", "dlq", { reason: "invalid-payload", topic: msg && msg.topic }, false); return out; } var validity = validateInboundTopic(msg.topic); if (!validity.valid) { node.stats.invalid_topics += 1; emitOperational(out, "unknown", "dlq", { reason: validity.reason, topic: msg.topic }, false); return out; } var identity = resolveIdentity(msg, payload); noteDevice(identity); emitMeta(out, identity, payload); var observedAt = toIsoTimestamp(payload.last_seen) || new Date().toISOString(); var quality = toIsoTimestamp(payload.last_seen) ? "source" : "estimated"; for (var i = 0; i < CAPABILITY_MAPPINGS.length; i++) { var mapping = CAPABILITY_MAPPINGS[i]; try { var value = mapping.read(payload); if (value === undefined) continue; emitCapability(out, identity, mapping, value, observedAt, quality); } catch (err) { node.stats.adapter_exceptions += 1; emitOperational(out, identity.site, "error", { capability: mapping.targetCapability, error: err.message }, false); } } if (validity.isAvailabilityTopic || payload.availability !== undefined || payload.online !== undefined) { var online = asBool(payload.online); if (online === null) online = asBool(payload.availability); if (online !== null) emitAvailability(out, identity, online); } return out; } function startSubscriptions() { if (node.subscriptionStarted) return; node.subscriptionStarted = true; node.send([null, makeSubscribeMsg(node.sourceTopic)]); updateNodeStatus("yellow", "ring", "subscribed"); } node.on("input", function(msg, send, done) { send = send || function() { node.send.apply(node, arguments); }; try { node.stats.processed_inputs += 1; var outputs = processTelemetry(msg); send([outputs, null]); updateNodeStatus(node.stats.errors ? "red" : "green", "dot"); if (done) done(); } catch (err) { node.stats.errors += 1; updateNodeStatus("red", "ring", 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); updateNodeStatus("grey", "ring", "starting"); } RED.nodes.registerType("z2m-snzb-04p-homebus", Z2MSNZB04PHomeBusNode); };