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