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); };