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