1 contributor
module.exports = function(RED) {
function Z2MZG204ZVNode(config) {
RED.nodes.createNode(this, config);
var node = this;
node.site = normalizeToken(config.site || config.mqttSite || "");
node.location = normalizeToken(config.location || config.mqttRoom || "");
node.accessory = normalizeLegacyDeviceId(normalizeToken(config.accessory || config.mqttSensor || ""));
node.batteryLowThreshold = parseNumber(config.batteryLowThreshold, 20, 0);
node.occupancyFadingTimeSec = parseNumber(config.occupancyFadingTimeSec, 300, 0);
node.bootstrapDeadlineMs = 10000;
node.hkCache = Object.create(null);
node.occupancyTimer = null;
node.startTimer = null;
node.bootstrapTimer = null;
node.lastMsgContext = null;
node.stats = {
controls: 0,
last_inputs: 0,
value_inputs: 0,
hk_updates: 0,
errors: 0
};
node.subscriptionState = {
started: false,
lastSubscribed: false,
valueSubscribed: false
};
node.bootstrapState = {
finalized: false,
motion: false,
temperature: false,
humidity: false,
illuminance: false,
battery: false
};
node.sensorState = {
active: false,
site: node.site || "",
location: node.location || "",
deviceId: node.accessory || "",
motionKnown: false,
motion: false,
occupancyKnown: false,
occupancy: false,
temperatureKnown: false,
temperature: null,
humidityKnown: false,
humidity: null,
illuminanceKnown: false,
illuminance: null,
batteryKnown: false,
battery: null,
batteryLowKnown: false,
batteryLow: false,
tamperedKnown: false,
tampered: false
};
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 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 clamp(n, min, max) {
return Math.max(min, Math.min(max, n));
}
function normalizeToken(value) {
if (value === undefined || value === null) return "";
return String(value).trim();
}
function normalizeLegacyDeviceId(value) {
if (!value) return "";
if (/^radar-/i.test(value)) return value.replace(/^radar-/i, "");
return value;
}
function signature(value) {
return JSON.stringify(value);
}
function shouldPublish(cacheKey, payload) {
var sig = signature(payload);
if (node.hkCache[cacheKey] === sig) return false;
node.hkCache[cacheKey] = sig;
return true;
}
function cloneBaseMsg(msg) {
if (!msg || typeof msg !== "object") return {};
var out = {};
if (typeof msg.topic === "string") out.topic = msg.topic;
if (msg._msgid) out._msgid = msg._msgid;
return out;
}
function buildSubscriptionTopic(stream) {
return [
node.site,
"home",
node.location,
"+",
node.accessory,
stream
].join("/");
}
function buildSubscribeMsgs() {
return [
{
action: "subscribe",
topic: buildSubscriptionTopic("last"),
qos: 2,
rh: 0,
rap: true
},
{
action: "subscribe",
topic: buildSubscriptionTopic("value"),
qos: 2,
rh: 0,
rap: true
}
];
}
function buildUnsubscribeLastMsg(reason) {
return {
action: "unsubscribe",
topic: buildSubscriptionTopic("last"),
reason: reason
};
}
function statusText(prefix) {
var state = node.subscriptionState.lastSubscribed ? "cold" : (node.subscriptionState.started ? "live" : "idle");
var device = node.sensorState.deviceId || node.accessory || "?";
return [
prefix || state,
device,
"l:" + node.stats.last_inputs,
"v:" + node.stats.value_inputs,
"hk:" + node.stats.hk_updates
].join(" ");
}
function setNodeStatus(prefix, fill, shape) {
node.status({
fill: fill || (node.stats.errors ? "red" : (node.subscriptionState.lastSubscribed ? "yellow" : "green")),
shape: shape || "dot",
text: statusText(prefix)
});
}
function noteError(text, msg) {
node.stats.errors += 1;
node.warn(text);
node.status({ fill: "red", shape: "ring", text: text });
if (msg) node.debug(msg);
}
function clearOccupancyTimer() {
if (node.occupancyTimer) {
clearTimeout(node.occupancyTimer);
node.occupancyTimer = null;
}
}
function clearBootstrapTimer() {
if (!node.bootstrapTimer) return;
clearTimeout(node.bootstrapTimer);
node.bootstrapTimer = null;
}
function makeHomeKitMsg(baseMsg, payload) {
var out = RED.util.cloneMessage(baseMsg || {});
out.payload = payload;
return out;
}
function buildStatusFields() {
return {
StatusActive: !!node.sensorState.active,
StatusFault: node.sensorState.active ? 0 : 1,
StatusLowBattery: node.sensorState.batteryLow ? 1 : 0,
StatusTampered: node.sensorState.tampered ? 1 : 0
};
}
function buildMotionMsg(baseMsg) {
if (!node.sensorState.motionKnown) return null;
var payload = buildStatusFields();
payload.MotionDetected = node.sensorState.motion ? 1 : 0;
if (!shouldPublish("hk:motion", payload)) return null;
node.stats.hk_updates += 1;
return makeHomeKitMsg(baseMsg, payload);
}
function buildOccupancyMsg(baseMsg) {
if (!node.sensorState.occupancyKnown) return null;
var payload = buildStatusFields();
payload.OccupancyDetected = node.sensorState.occupancy ? 1 : 0;
if (!shouldPublish("hk:occupancy", payload)) return null;
node.stats.hk_updates += 1;
return makeHomeKitMsg(baseMsg, payload);
}
function buildTemperatureMsg(baseMsg) {
if (!node.sensorState.temperatureKnown) return null;
var payload = {
CurrentTemperature: clamp(Number(node.sensorState.temperature), -100, 100)
};
if (!shouldPublish("hk:temperature", payload)) return null;
node.stats.hk_updates += 1;
return makeHomeKitMsg(baseMsg, payload);
}
function buildHumidityMsg(baseMsg) {
if (!node.sensorState.humidityKnown) return null;
var payload = {
CurrentRelativeHumidity: clamp(Number(node.sensorState.humidity), 0, 100)
};
if (!shouldPublish("hk:humidity", payload)) return null;
node.stats.hk_updates += 1;
return makeHomeKitMsg(baseMsg, payload);
}
function buildLightMsg(baseMsg) {
if (!node.sensorState.illuminanceKnown) return null;
var lux = Number(node.sensorState.illuminance);
var payload = {
CurrentAmbientLightLevel: lux <= 0 ? 0.0001 : clamp(lux, 0.0001, 100000)
};
if (!shouldPublish("hk:light", payload)) return null;
node.stats.hk_updates += 1;
return makeHomeKitMsg(baseMsg, payload);
}
function buildBatteryMsg(baseMsg) {
if (!node.sensorState.batteryKnown && !node.sensorState.batteryLowKnown) return null;
var batteryLevel = node.sensorState.batteryKnown
? clamp(Math.round(Number(node.sensorState.battery)), 0, 100)
: (node.sensorState.batteryLow ? 1 : 100);
var payload = {
StatusLowBattery: node.sensorState.batteryLow ? 1 : 0,
BatteryLevel: batteryLevel,
ChargingState: 2
};
if (!shouldPublish("hk:battery", payload)) return null;
node.stats.hk_updates += 1;
return makeHomeKitMsg(baseMsg, payload);
}
function clearSnapshotCache() {
delete node.hkCache["hk:motion"];
delete node.hkCache["hk:occupancy"];
delete node.hkCache["hk:temperature"];
delete node.hkCache["hk:humidity"];
delete node.hkCache["hk:light"];
delete node.hkCache["hk:battery"];
}
function buildBootstrapOutputs(baseMsg) {
clearSnapshotCache();
return [
buildMotionMsg(baseMsg),
buildOccupancyMsg(baseMsg),
buildTemperatureMsg(baseMsg),
buildHumidityMsg(baseMsg),
buildLightMsg(baseMsg),
buildBatteryMsg(baseMsg)
];
}
function emitTimerOccupancyClear() {
node.occupancyTimer = null;
if (!node.sensorState.occupancyKnown || !node.sensorState.occupancy) return;
node.sensorState.occupancy = false;
var baseMsg = cloneBaseMsg(node.lastMsgContext);
var occupancyMsg = buildOccupancyMsg(baseMsg);
if (occupancyMsg) {
node.send([null, occupancyMsg, null, null, null, null, null]);
}
setNodeStatus();
}
function applyMotionSample(rawDetected) {
var detected = !!rawDetected;
node.sensorState.motionKnown = true;
node.sensorState.motion = detected;
if (node.occupancyFadingTimeSec <= 0) {
clearOccupancyTimer();
node.sensorState.occupancyKnown = true;
node.sensorState.occupancy = detected;
return;
}
node.sensorState.occupancyKnown = true;
if (detected) {
node.sensorState.occupancy = true;
clearOccupancyTimer();
node.occupancyTimer = setTimeout(emitTimerOccupancyClear, Math.round(node.occupancyFadingTimeSec * 1000));
} else if (!node.sensorState.occupancy) {
clearOccupancyTimer();
}
}
function unsubscribeLast(reason, send) {
if (!node.subscriptionState.lastSubscribed) return null;
node.subscriptionState.lastSubscribed = false;
node.stats.controls += 1;
var controlMsg = buildUnsubscribeLastMsg(reason);
if (typeof send === "function") {
send([null, null, null, null, null, null, controlMsg]);
}
return controlMsg;
}
function markBootstrapSatisfied(capability) {
if (capability === "motion" && node.sensorState.motionKnown) {
node.bootstrapState.motion = true;
} else if (capability === "temperature" && node.sensorState.temperatureKnown) {
node.bootstrapState.temperature = true;
} else if (capability === "humidity" && node.sensorState.humidityKnown) {
node.bootstrapState.humidity = true;
} else if (capability === "illuminance" && node.sensorState.illuminanceKnown) {
node.bootstrapState.illuminance = true;
} else if ((capability === "battery" || capability === "battery_low") && (node.sensorState.batteryKnown || node.sensorState.batteryLowKnown)) {
node.bootstrapState.battery = true;
}
}
function isBootstrapComplete() {
return node.bootstrapState.motion
&& node.bootstrapState.temperature
&& node.bootstrapState.humidity
&& node.bootstrapState.illuminance
&& node.bootstrapState.battery;
}
function finalizeBootstrap(reason, send) {
if (node.bootstrapState.finalized) return false;
if (!node.subscriptionState.lastSubscribed) return false;
node.bootstrapState.finalized = true;
clearBootstrapTimer();
send = send || function(msgs) { node.send(msgs); };
var outputs = buildBootstrapOutputs(cloneBaseMsg(node.lastMsgContext));
var controlMsg = unsubscribeLast(reason);
send([
outputs[0],
outputs[1],
outputs[2],
outputs[3],
outputs[4],
outputs[5],
controlMsg
]);
setNodeStatus("live");
return true;
}
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 !== 6) return null;
if (tokens[1] !== "home") return null;
if (tokens[5] !== "value" && tokens[5] !== "last") return null;
if (node.site && tokens[0] !== node.site) return null;
if (node.location && tokens[2] !== node.location) return null;
if (node.accessory && tokens[4] !== node.accessory) return null;
return {
site: tokens[0],
location: tokens[2],
capability: tokens[3],
deviceId: tokens[4],
stream: tokens[5]
};
}
function extractValue(stream, payload) {
if (payload && typeof payload === "object" && !Array.isArray(payload) && Object.prototype.hasOwnProperty.call(payload, "value")) {
return payload.value;
}
return payload;
}
function updateBatteryLowFromThreshold() {
if (!node.sensorState.batteryKnown || node.sensorState.batteryLowKnown) return;
node.sensorState.batteryLow = Number(node.sensorState.battery) <= node.batteryLowThreshold;
}
function processCapability(baseMsg, parsed, value) {
var motionMsg = null;
var occupancyMsg = null;
var temperatureMsg = null;
var humidityMsg = null;
var lightMsg = null;
var batteryMsg = null;
node.sensorState.active = true;
node.sensorState.site = parsed.site;
node.sensorState.location = parsed.location;
node.sensorState.deviceId = parsed.deviceId;
node.lastMsgContext = cloneBaseMsg(baseMsg);
if (parsed.capability === "motion") {
var detected = asBool(value);
if (detected === null) return [null, null, null, null, null, null];
applyMotionSample(detected);
motionMsg = buildMotionMsg(baseMsg);
occupancyMsg = buildOccupancyMsg(baseMsg);
} else if (parsed.capability === "presence") {
return [null, null, null, null, null, null];
} else if (parsed.capability === "temperature") {
var temperature = asNumber(value);
if (temperature === null) return [null, null, null, null, null, null];
node.sensorState.temperatureKnown = true;
node.sensorState.temperature = temperature;
temperatureMsg = buildTemperatureMsg(baseMsg);
} else if (parsed.capability === "humidity") {
var humidity = asNumber(value);
if (humidity === null) return [null, null, null, null, null, null];
node.sensorState.humidityKnown = true;
node.sensorState.humidity = humidity;
humidityMsg = buildHumidityMsg(baseMsg);
} else if (parsed.capability === "illuminance") {
var illuminance = asNumber(value);
if (illuminance === null) return [null, null, null, null, null, null];
node.sensorState.illuminanceKnown = true;
node.sensorState.illuminance = illuminance;
lightMsg = buildLightMsg(baseMsg);
} else if (parsed.capability === "battery") {
var battery = asNumber(value);
if (battery === null) return [null, null, null, null, null, null];
node.sensorState.batteryKnown = true;
node.sensorState.battery = clamp(Math.round(battery), 0, 100);
updateBatteryLowFromThreshold();
batteryMsg = buildBatteryMsg(baseMsg);
motionMsg = buildMotionMsg(baseMsg);
occupancyMsg = buildOccupancyMsg(baseMsg);
} else if (parsed.capability === "battery_low") {
var batteryLow = asBool(value);
if (batteryLow === null) return [null, null, null, null, null, null];
node.sensorState.batteryLowKnown = true;
node.sensorState.batteryLow = batteryLow;
batteryMsg = buildBatteryMsg(baseMsg);
motionMsg = buildMotionMsg(baseMsg);
occupancyMsg = buildOccupancyMsg(baseMsg);
} else if (parsed.capability === "tamper") {
var tampered = asBool(value);
if (tampered === null) return [null, null, null, null, null, null];
node.sensorState.tamperedKnown = true;
node.sensorState.tampered = tampered;
motionMsg = buildMotionMsg(baseMsg);
occupancyMsg = buildOccupancyMsg(baseMsg);
batteryMsg = buildBatteryMsg(baseMsg);
} else {
return [null, null, null, null, null, null];
}
return [motionMsg, occupancyMsg, temperatureMsg, humidityMsg, lightMsg, batteryMsg];
}
function startSubscriptions() {
if (node.subscriptionState.started) return;
if (!node.site || !node.location || !node.accessory) {
noteError("missing site, location or accessory");
return;
}
node.subscriptionState.started = true;
node.subscriptionState.lastSubscribed = true;
node.subscriptionState.valueSubscribed = true;
clearBootstrapTimer();
node.bootstrapTimer = setTimeout(function() {
finalizeBootstrap("bootstrap-timeout");
}, node.bootstrapDeadlineMs);
node.stats.controls += 1;
node.send([null, null, null, null, null, null, buildSubscribeMsgs()]);
setNodeStatus("cold");
}
node.on("input", function(msg, send, done) {
send = send || function() { node.send.apply(node, arguments); };
try {
var parsed = parseTopic(msg && msg.topic);
if (!parsed) {
noteError("invalid topic");
if (done) done();
return;
}
var value = extractValue(parsed.stream, msg.payload);
var controlMsg = null;
if (parsed.stream === "last") node.stats.last_inputs += 1;
else node.stats.value_inputs += 1;
var outputs = processCapability(msg, parsed, value);
markBootstrapSatisfied(parsed.capability);
send([
outputs[0],
outputs[1],
outputs[2],
outputs[3],
outputs[4],
outputs[5],
controlMsg
]);
setNodeStatus();
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() {
clearBootstrapTimer();
clearOccupancyTimer();
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("zg-204zv-homekit-adapter", Z2MZG204ZVNode);
};