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