module.exports = function(RED) { function SmartSocketHomeKitNode(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 = normalizeToken(config.accessory || config.mqttSensor || ""); node.bootstrapDeadlineMs = 10000; node.hkCache = Object.create(null); node.startTimer = null; node.bootstrapTimer = null; node.lastMsgContext = null; node.stats = { controls: 0, last_inputs: 0, meta_inputs: 0, value_inputs: 0, availability_inputs: 0, command_outputs: 0, hk_updates: 0, errors: 0 }; node.subscriptionState = { started: false, lastSubscribed: false, metaSubscribed: false, valueSubscribed: false, availabilitySubscribed: false }; node.bootstrapState = { finalized: false, power: false }; node.outletState = { active: false, site: node.site || "", location: node.location || "", deviceId: node.accessory || "", sourceTopic: "", sourceRef: "", powerKnown: false, power: false }; function normalizeToken(value) { if (value === undefined || value === null) return ""; return String(value).trim(); } 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 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 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 makeSetMsg(baseMsg, payload) { var out = RED.util.cloneMessage(baseMsg || {}); out.topic = buildSetTopic(); if (node.outletState.sourceTopic || node.outletState.sourceRef) { out.payload = { value: payload }; if (node.outletState.sourceTopic) out.payload.source_topic = node.outletState.sourceTopic; if (node.outletState.sourceRef) out.payload.source_ref = node.outletState.sourceRef; } else { out.payload = payload; } out.qos = 1; out.retain = false; return out; } function buildSubscriptionTopic(stream) { return [node.site, "home", node.location, "power", node.accessory, stream].join("/"); } function buildSetTopic() { return [node.site, "home", node.location, "power", node.accessory, "set"].join("/"); } function buildSubscribeMsgs() { return [ { action: "subscribe", topic: buildSubscriptionTopic("last"), qos: 2, rh: 0, rap: true }, { action: "subscribe", topic: buildSubscriptionTopic("meta"), qos: 2, rh: 0, rap: true }, { action: "subscribe", topic: buildSubscriptionTopic("value"), qos: 2, rh: 0, rap: true }, { action: "subscribe", topic: buildSubscriptionTopic("availability"), 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.outletState.deviceId || node.accessory || "?"; return [ prefix || state, device, "l:" + node.stats.last_inputs, "m:" + node.stats.meta_inputs, "v:" + node.stats.value_inputs, "a:" + node.stats.availability_inputs, "set:" + node.stats.command_outputs, "hk:" + node.stats.hk_updates ].join(" "); } function setNodeStatus(prefix, fill, shape) { node.status({ fill: fill || (node.stats.errors ? "red" : (node.subscriptionState.lastSubscribed ? "yellow" : (node.outletState.active ? "green" : "yellow"))), 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 buildOutletMsg(baseMsg) { if (!node.outletState.powerKnown) return null; var payload = { On: node.outletState.power ? 1 : 0, OutletInUse: (node.outletState.active && node.outletState.power) ? 1 : 0 }; if (!shouldPublish("hk:outlet", payload)) return null; node.stats.hk_updates += 1; return makeHomeKitMsg(baseMsg, payload); } function clearSnapshotCache() { delete node.hkCache["hk:outlet"]; } function buildBootstrapOutputs(baseMsg) { clearSnapshotCache(); return [buildOutletMsg(baseMsg)]; } function unsubscribeLast(reason) { if (!node.subscriptionState.lastSubscribed) return null; node.subscriptionState.lastSubscribed = false; node.stats.controls += 1; return buildUnsubscribeLastMsg(reason); } 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[3] !== "power") return { ignored: true }; if (tokens[5] !== "value" && tokens[5] !== "last" && tokens[5] !== "meta" && tokens[5] !== "availability") return { ignored: true }; if ((node.site && tokens[0] !== node.site) || (node.location && tokens[2] !== node.location) || (node.accessory && tokens[4] !== node.accessory)) return { ignored: true }; return { site: tokens[0], location: tokens[2], capability: tokens[3], deviceId: tokens[4], stream: tokens[5] }; } function extractValue(stream, payload) { if (stream === "last" && payload && typeof payload === "object" && !Array.isArray(payload) && Object.prototype.hasOwnProperty.call(payload, "value")) { return payload.value; } return payload; } function processAvailability(baseMsg, value) { var active = asBool(value); if (active === null) return null; node.outletState.active = active; node.lastMsgContext = cloneBaseMsg(baseMsg); return buildOutletMsg(baseMsg); } function processMeta(baseMsg, parsed, value) { if (!value || typeof value !== "object" || Array.isArray(value)) return null; node.outletState.site = parsed.site; node.outletState.location = parsed.location; node.outletState.deviceId = parsed.deviceId; if (typeof value.source_topic === "string" && value.source_topic.trim()) { node.outletState.sourceTopic = value.source_topic.trim(); } if (typeof value.source_ref === "string" && value.source_ref.trim()) { node.outletState.sourceRef = value.source_ref.trim(); } node.lastMsgContext = cloneBaseMsg(baseMsg); return null; } function processPower(baseMsg, parsed, value) { var power = asBool(value); if (power === null) return null; node.outletState.active = true; node.outletState.site = parsed.site; node.outletState.location = parsed.location; node.outletState.deviceId = parsed.deviceId; node.outletState.powerKnown = true; node.outletState.power = power; node.lastMsgContext = cloneBaseMsg(baseMsg); return buildOutletMsg(baseMsg); } 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], null, controlMsg]); setNodeStatus("live"); return true; } 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.metaSubscribed = true; node.subscriptionState.valueSubscribed = true; node.subscriptionState.availabilitySubscribed = true; clearBootstrapTimer(); node.bootstrapTimer = setTimeout(function() { finalizeBootstrap("bootstrap-timeout"); }, node.bootstrapDeadlineMs); node.stats.controls += 1; node.send([null, null, buildSubscribeMsgs()]); setNodeStatus("cold"); } function translateHomeKitCommand(msg) { var payload = msg && msg.payload; if (!payload || typeof payload !== "object" || Array.isArray(payload)) return null; if (!Object.prototype.hasOwnProperty.call(payload, "On")) return null; var desired = asBool(payload.On); if (desired === null) return null; node.stats.command_outputs += 1; return makeSetMsg(cloneBaseMsg(msg), desired); } node.on("input", function(msg, send, done) { send = send || function() { node.send.apply(node, arguments); }; try { var commandMsg = translateHomeKitCommand(msg); if (commandMsg) { send([null, commandMsg, null]); setNodeStatus("set"); if (done) done(); return; } var parsed = parseTopic(msg && msg.topic); if (!parsed) { noteError("invalid topic", msg); if (done) done(); return; } if (parsed.ignored) { if (done) done(); return; } var value = extractValue(parsed.stream, msg.payload); var output = null; if (parsed.stream === "last") { node.stats.last_inputs += 1; output = processPower(msg, parsed, value); node.bootstrapState.power = node.outletState.powerKnown; } else if (parsed.stream === "meta") { node.stats.meta_inputs += 1; output = processMeta(msg, parsed, value); } else if (parsed.stream === "value") { node.stats.value_inputs += 1; output = processPower(msg, parsed, value); node.bootstrapState.power = node.outletState.powerKnown; } else { node.stats.availability_inputs += 1; output = processAvailability(msg, value); } if (node.subscriptionState.lastSubscribed) { setNodeStatus("cold"); } else { send([output, null, null]); setNodeStatus(); } if (done) done(); } catch (err) { noteError(err.message, msg); if (done) done(err); else node.error(err, msg); } }); node.on("close", function() { clearBootstrapTimer(); 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("smart-socket-homekit-adapter", SmartSocketHomeKitNode); };