UNPKG

node-red-contrib-huemagic

Version:

Philips Hue node to control bridges, lights, groups, scenes, rules, taps, switches, buttons, motion sensors, temperature sensors and Lux sensors using Node-RED.

873 lines (773 loc) 22.9 kB
module.exports = function(RED) { "use strict"; const API = require('./utils/api'); const merge = require('./utils/merge'); const events = require('events'); const dayjs = require('dayjs'); const diff = require("deep-object-diff").diff; const axios = require('axios'); const https = require('https'); const fastq = require('fastq'); // READABLE RESOURCE MESSAGES const { HueBridgeMessage, HueLightMessage, HueGroupMessage, HueMotionMessage, HueTemperatureMessage, HueBrightnessMessage, HueButtonsMessage, HueRulesMessage } = require('./utils/messages'); function HueBridge(config) { const scope = this; // STATES this.nodeActive = true; this.config = config; this.resources = {}; this.resourcesInGroups = {}; this.lastStates = {}; this.events = new events.EventEmitter(); this.patchQueue = null; this.timerWatchDog = null; // RESOURCE ID PATTERN this.validResourceID = /[a-zA-Z0-9]/gi; // FIRMWARE UPDATE TIMEOUT this.firmwareUpdateTimeout = null; // CREATE NODE RED.nodes.createNode(scope, config); // PERIODICALLY CHECK WETHER BRIDGE IS CONNECTED this.startWatchdog = function() { if (this.timerWatchDog !== null) clearTimeout(this.timerWatchDog); this.timerWatchDog = setTimeout(function() { try { API.request({ config: config, resource: "/config", version: 1 }) .then(function(bridgeInformation) { scope.startWatchdog(); }) .catch(function(error) { scope.log("Error requesting info from the bridge. Reconnect in some secs. " + error.message); scope.start(); }); } catch (error) { scope.log("Lost connection with the bridge. Reconnect in some secs. " + error.message); scope.start(); } }, 10000); } // INITIALIZE this.start = function() { scope.log("Initializing the bridge ("+config.bridge+")…"); API.init({ config: config }) .then(function(bridge) { scope.log("Connected to bridge"); return scope.getAllResources(); }) .then(function(allResources) { scope.log("Processing bridge resources…"); return API.processResources(allResources); }) .then(function(allResources) { // SAVE CURRENT RESOURCES scope.resources = allResources; // EMIT INITIAL STATES -> NODES scope.log("Initial emit of resource states…"); return scope.emitInitialStates(); }) .then(function(emitted) { // START REFRESHING STATES scope.keepUpdated(); // START LOOKING FOR FIRMWARE-UPDATES scope.autoUpdateFirmware(); // START WATCHDOG scope.startWatchdog(); return true; }) .catch(function(error) { // RETRY AFTER 30 SECONDS scope.log(error); if(scope.nodeActive == true) { setTimeout(function(){ scope.start(); }, 30000); } }); } // FETCH BRIDGE INFORMATION this.getBridgeInformation = function(replaceResources = false) { return new Promise(function(resolve, reject) { API.request({ config: config, resource: "/config", version: 1 }) .then(function(bridgeInformation) { // PREPARE TO MATCH V2 RESOURCES bridgeInformation.id = "bridge"; bridgeInformation.id_v1 = "/config"; bridgeInformation.updated = dayjs().format(); // ALSO REPLACE CURRENT RESOURCE? if(replaceResources === true) { scope.resources[bridgeInformation.id] = bridgeInformation; } // GIVE BACK resolve(bridgeInformation); }) .catch(function(error) { reject(error); }); }); } // GET ALL RESOURCES + RULES this.getAllResources = function() { return new Promise(function(resolve, reject) { var allResources = []; // GET BRIDGE INFORMATION scope.getBridgeInformation() .then(function(bridgeInformation) { // PUSH TO RESOURCES allResources.push(bridgeInformation); // CONTINUE WITH ALL RESOURCES return API.request({ config: config, resource: "all" }); }) .then(function(v2Resources) { // MERGE RESOURCES allResources = allResources.concat(v2Resources); // GET RULES (LEGACY API) return API.request({ config: config, resource: "/rules", version: 1 }); }) .then(function(rules) { for (var [id, rule] of Object.entries(rules)) { // "RENAME" OWNER rule["_owner"] = rule["owner"]; delete rule["owner"]; // ADD RULE ID(S) rule["id"] = "rule_" + id; rule["id_v1"] = "/rules/" + id; // ADD RULE TYPE rule["type"] = "rule"; // PUSH RULES allResources.push(rule); } resolve(allResources); }) .catch(function(error) { reject(error); }); }); } // EMIT INITIAL STATES -> NODES this.emitInitialStates = function(resources = false) { return new Promise(function(resolve, reject) { // PUSH STATES setTimeout(function() { // PUSH ALL STATES for (const [id, resource] of Object.entries(scope.resources)) { scope.pushUpdatedState(resource, resource.type, true); } resolve(true); }, 500); }); } // KEEEP STATES UP-TO-DATE this.keepUpdated = function() { if(!config.disableupdates) { scope.log("Keeping nodes up-to-date…"); // REFRESH STATES (SSE) this.refreshStatesSSE(); } } // GET UPDATED STATES (SSE) this.refreshStatesSSE = function() { scope.log("Subscribing to bridge events…"); API.subscribe(config, function(updates) { const currentDateTime = dayjs().format(); for(let resource of updates) { let id = resource.id; let type = resource.type; let previousState = false; // HAS OWNER? if(resource["owner"]) { let targetId = resource["owner"]["rid"]; if(scope.resources[targetId]) { // GET PREVIOUS STATE previousState = scope.resources[targetId]["services"][type][id]; // IS BUTTON? -> REMOVE PREVIOUS STATES if(type === "button") { for (const [oneButtonID, oneButton] of Object.entries(scope.resources[targetId]["services"]["button"])) { delete scope.resources[targetId]["services"]["button"][oneButtonID]["button"]; } } } } else if(scope.resources[id]) { // GET PREVIOUS STATE previousState = scope.resources[id]; } // NO PREVIOUS STATE? if(!previousState) { return false; } // CHECK DIFFERENCES const mergedState = merge.deep(previousState, resource); const updatedResources = diff(previousState, mergedState); if(Object.values(updatedResources).length > 0) { if(resource["owner"]) { let targetId = resource["owner"]["rid"]; scope.resources[targetId]["services"][type][id] = mergedState; scope.resources[targetId]["updated"] = currentDateTime; // PUSH STATE scope.pushUpdatedState(scope.resources[targetId], resource.type); } else { scope.resources[id] = mergedState; scope.resources[id]["updated"] = currentDateTime; // PUSH STATE scope.pushUpdatedState(scope.resources[id], resource.type); } } } }); } // PUSH UPDATED STATE this.pushUpdatedState = function(resource, updatedType, suppressMessage = false) { const msg = { id: resource.id, type: resource.type, updatedType: updatedType, services: resource["services"] ? Object.keys(resource["services"]) : [], suppressMessage: suppressMessage }; this.events.emit(config.id + "_" + resource.id, msg); this.events.emit(config.id + "_" + "globalResourceUpdates", msg); // RESOURCE CONTAINS SERVICES? -> SERVICE IN GROUP? -> EMIT CHANGES TO GROUPS ALSO if(this.resources["_groupsOf"][resource.id]) { for (var g = this.resources["_groupsOf"][resource.id].length - 1; g >= 0; g--) { const groupID = this.resources["_groupsOf"][resource.id][g]; const groupMessage = { id: groupID, type: "group", updatedType: updatedType, services: [], suppressMessage: suppressMessage }; this.events.emit(config.id + "_" + groupID, groupMessage); this.events.emit(config.id + "_" + "globalResourceUpdates", groupMessage); } } } // GET RESOURCE (FROM NODES) this.get = function(type, id = false, options = {}) { // GET SPECIFIC RESOURCE if(id) { // RESOURCE EXISTS? -> PROCEED if(scope.resources[id]) { // RESOLVE LINKS const targetResource = scope.resources[id]; const lastState = scope.lastStates[type+targetResource.id] ? Object.assign({}, scope.lastStates[type+targetResource.id]) : false; if(type == "bridge") { try { const message = new HueBridgeMessage(targetResource, options); // GET CURRENT STATE MESSAGE let currentState = message.msg; return currentState; } catch (error) { return false; } } else if(type == "light") { try { const message = new HueLightMessage(targetResource, options); // GET & SAVE LAST STATE AND DIFFERENCES let currentState = message.msg; scope.lastStates[type+targetResource.id] = Object.assign({}, currentState); currentState.updated = (lastState === false) ? {} : diff(lastState, currentState); currentState.lastState = lastState; return currentState; } catch (error) { return false; } } else if(type == "group") { try { // GET MESSAGE const message = new HueGroupMessage(targetResource, { resources: scope.resources, ...options}); // GET & SAVE LAST STATE AND DIFFERENCES let currentState = message.msg; scope.lastStates[type+targetResource.id] = Object.assign({}, currentState); currentState.updated = (lastState === false) ? {} : diff(lastState, currentState); currentState.lastState = lastState; return currentState; } catch (error) { return false; } } else if(type == "button") { try { const message = new HueButtonsMessage(targetResource, options); // GET & SAVE LAST STATE AND DIFFERENCES let currentState = message.msg; scope.lastStates[type+targetResource.id] = Object.assign({}, currentState); currentState.updated = (lastState === false) ? {} : diff(lastState, currentState); currentState.lastState = lastState; return currentState; } catch (error) { return false; } } else if(type == "motion") { try { const message = new HueMotionMessage(targetResource, options); // GET & SAVE LAST STATE AND DIFFERENCES let currentState = message.msg; scope.lastStates[type+targetResource.id] = Object.assign({}, currentState); currentState.updated = (lastState === false) ? {} : diff(lastState, currentState); currentState.lastState = lastState; return currentState; } catch (error) { return false; } } else if(type == "temperature") { try { const message = new HueTemperatureMessage(targetResource, options); // GET & SAVE LAST STATE AND DIFFERENCES let currentState = message.msg; scope.lastStates[type+targetResource.id] = Object.assign({}, currentState); currentState.updated = (lastState === false) ? {} : diff(lastState, currentState); currentState.lastState = lastState; return currentState; } catch (error) { return false; } } else if(type == "light_level") { try { const message = new HueBrightnessMessage(targetResource, options); // GET & SAVE LAST STATE AND DIFFERENCES let currentState = message.msg; scope.lastStates[type+targetResource.id] = Object.assign({}, currentState); currentState.updated = (lastState === false) ? {} : diff(lastState, currentState); currentState.lastState = lastState; return currentState; } catch (error) { return false; } } else if(type == "rule") { try { const message = new HueRulesMessage(targetResource, options); // GET & SAVE LAST STATE AND DIFFERENCES let currentState = message.msg; scope.lastStates[type+targetResource.id] = Object.assign({}, currentState); currentState.updated = (lastState === false) ? {} : diff(lastState, currentState); currentState.lastState = lastState; return currentState; } catch (error) { return false; } } else { return false; } } else { return false; } } else { // FILTER RESOURCES BY TYPE let allFilteredResources = {}; for (const [rootID, resource] of Object.entries(scope.resources)) { const isGroup = (resource["type"] == "room" || resource["type"] == "zone" || resource["type"] == "bridge_home"); // NORMAL DEVICES if(!isGroup && resource["services"] && resource["services"][type]) { for (const [serviceID, targetDevice] of Object.entries(resource["services"][type])) { allFilteredResources[rootID] = scope.get(type, rootID); } } // GROUPED RESOURCES else if(isGroup && type === "group") { allFilteredResources[rootID] = scope.get(type, rootID); } } return Object.values(allFilteredResources); } } // PATCH RESOURCE (FROM NODES) this.patch = function(type, id, patch, version = 2) { return new Promise(function(resolve, reject) { if(!scope.patchQueue) { return false; } scope.patchQueue.push({ type: type, id: id, patch: patch, version: version }, function (error, response) { if(error) { reject(error); } else { resolve(response); } }); }); } // PATCH RESOURCE (WORKER) / 7 PROCESSES IN PARALLEL this.patchQueue = fastq(function({ type, id, patch, version }, callback) { // GET SERVICE ID if(version !== 1 && scope.resources[id] && scope.resources[id]["services"] && scope.resources[id]["services"][type]) { const targetResource = Object.values(scope.resources[id]["services"][type])[0]; id = targetResource.id; } // ACTION! API.request({ config: config, method: "PUT", resource: (version === 2) ? (type+"/"+id) : id, data: patch, version: version }) .then(function(response) { callback(null, response); }) .catch(function(error) { callback(error, null); }); }, config.worker ? parseInt(config.worker) : 10); // RE-FETCH RULE (RECEIVES NO UPDATES VIA SSE) this.refetchRule = function(id) { return new Promise(function(resolve, reject) { API.request({ config: config, resource: "/rules/" + id, version: 1 }) .then(function(rule) { // "RENAME" OWNER rule["_owner"] = rule["owner"]; delete rule["owner"]; // ADD RULE ID(S) rule["id"] = "rule_" + id; rule["id_v1"] = "/rules/" + id; // ADD RULE TYPE rule["type"] = "rule"; // UPDATED TIME rule["updated"] = dayjs().format(); // ADD BACK TO RESOURCES scope.resources[rule["id"]] = rule; // PUSH UPDATED STATE scope.pushUpdatedState(rule, "rule"); resolve(resolve); }) .catch(function(error) { reject(error); }); }); } // SUBSCRIBE (FROM NODES) this.subscribe = function(type, id = null, callback = null) { // IS RULE? if(type == "rule" && !!id) { id = "rule_" + id; } // PUSH WHITELIST const messageWhitelist = { "light": ["light", "zigbee_connectivity", "zgp_connectivity", "device"], "motion": ["motion", "zigbee_connectivity", "zgp_connectivity", "device_power", "device"], "temperature": ["temperature", "zigbee_connectivity", "zgp_connectivity", "device_power", "device"], "light_level": ["light_level", "zigbee_connectivity", "zgp_connectivity", "device_power", "device"], "button": ["button", "zigbee_connectivity", "zgp_connectivity", "device_power", "device"], "group": ["group", "light", "grouped_light"], "rule": ["rule"] }; if(!id) { // UNIVERSAL MODE this.events.on(config.id + "_" + "globalResourceUpdates", function(info) { if(type === "bridge") { callback(info); } else if(info.services.includes(type) && messageWhitelist[type].includes(info.updatedType)) { callback(info); } else if(type == "rule" && messageWhitelist[type].includes(info.updatedType)) { callback(info); } }); } else { // SPECIFIC RESOURCE MODE this.events.on(config.id + "_" + id, function(info) { if(type === "bridge" || messageWhitelist[type].includes(info.updatedType)) { callback(info); } }); } } // AUTO UPDATES? this.autoUpdateFirmware = function() { if((config.autoupdates && config.autoupdates == true) || typeof config.autoupdates == 'undefined') { if(scope.firmwareUpdateTimeout !== null) { clearTimeout(scope.firmwareUpdateTimeout); }; API.request({ config: config, method: "PUT", resource: "/config", version: 1, data: { swupdate2: { checkforupdate: true, install: true } } }) .then(function(status) { if(scope.nodeActive == true) { scope.firmwareUpdateTimeout = setTimeout(function(){ scope.autoUpdateFirmware(); }, 60000 * 720); } }) .catch(function(error) { // NO UPDATES AVAILABLE // TRY AGAIN IN 12H if(scope.nodeActive == true) { scope.firmwareUpdateTimeout = setTimeout(function(){ scope.autoUpdateFirmware(); }, 60000 * 720); } }); } } // // START THE MAGIC this.start(); // // CLOSE NODE / REMOVE EVENT LISTENER this.on('close', function() { scope.nodeActive = false; // UNSUBSCRIBE FROM BRIDGE EVENTS scope.log("Unsubscribing from bridge events…"); API.unsubscribe(config); // UNSUBSCRIBE FROM "READY" EVENTS scope.events.removeAllListeners(); // REMOVE FIRMWARE UPDATE TIMEOUT if(scope.firmwareUpdateTimeout !== null) { clearTimeout(scope.firmwareUpdateTimeout); } // KILL QUEUE scope.patchQueue.kill(); }); } RED.nodes.registerType("hue-bridge", HueBridge); // // DISCOVER HUE BRIDGES ON LOCAL NETWORK RED.httpAdmin.get('/hue/bridges', async function(req, res, next) { axios({ "method": "GET", "url": "https://discovery.meethue.com", "headers": { "Content-Type": "application/json; charset=utf-8" }, }) .then(function(response) { // PREPARE BRIDGES OUTPUT var bridges = {}; for (var i = response.data.length - 1; i >= 0; i--) { var ipAddress = response.data[i].internalipaddress; bridges[ipAddress] = { ip: ipAddress, name: ipAddress }; } res.end(JSON.stringify(Object.values(bridges))); }) .catch(function(error) { res.send(error); }); }); // // GET BRIDGE NAME RED.httpAdmin.get('/hue/name', function(req, res, next) { if(!req.query.ip) { return res.status(500).send(RED._("hue-bridge-config.config.missing-ip")); } else { API.init({ config: { bridge: req.query.ip, key: "huemagic" } }) .then(function(bridge) { res.end(bridge.name); }) .catch(function(error) { res.send(error); }); } }); // // REGISTER A HUE BRIDGE RED.httpAdmin.get('/hue/register', function(req, rescope, next) { if(!req.query.ip) { return rescope.status(500).send(RED._("hue-bridge-config.config.missing-ip")); } else { axios({ "method": "POST", "url": "http://"+req.query.ip+"/api", "httpsAgent": new https.Agent({ rejectUnauthorized: false }), "headers": { "Content-Type": "application/json; charset=utf-8" }, "data": { "devicetype": "HueMagic for Node-RED (" + Math.floor((Math.random() * 100) + 1) + ")" } }) .then(function(response) { var bridge = response.data; if(bridge[0].error) { rescope.end("error"); } else { rescope.end(JSON.stringify(bridge)); } }) .catch(function(error) { rescope.status(500).send(error); }); } }); // // DISCOVER RESOURCES RED.httpAdmin.get('/hue/resources', function(req, res, next) { const targetType = req.query.type; // GET ALL RULES if(targetType == "rule") { API.request({ config: { bridge: req.query.bridge, key: req.query.key }, resource: "/rules", version: 1 }) .then(function(rules) { let targetRules = {}; for (var [id, rule] of Object.entries(rules)) { var oneDevice = {}; oneDevice.id = id; oneDevice.name = rule.name; oneDevice.model = false; targetRules[id] = oneDevice; } // CONVERT TO ARRAY targetRules = Object.values(targetRules); // GIVE BACK res.end(JSON.stringify(targetRules)); }) .catch(function(error) { res.status(500).send(JSON.stringify(error)); }); } // GET ALL OTHER RESOURCES else { API.request({ config: { bridge: req.query.bridge, key: req.query.key }, resource: "all" }) .then(function(allResources) { return API.processResources(allResources); }) .then(function(processedResources) { let targetDevices = {}; for (const [id, resource] of Object.entries(processedResources)) { const isGroup = (resource["type"] == "room" || resource["type"] == "zone" || resource["type"] == "bridge_home"); // NORMAL DEVICES if(!isGroup && resource["services"] && resource["services"][targetType]) { for (const [deviceID, targetDevice] of Object.entries(resource["services"][targetType])) { var oneDevice = {}; oneDevice.id = id; oneDevice.name = resource.metadata ? resource.metadata.name : false; oneDevice.model = resource.product_data ? resource.product_data.product_name : false; targetDevices[id] = oneDevice; } } // GROUPED (LIGHT) RESOURCES else if(isGroup && targetType === "group") { if(resource["services"] && resource["services"]["grouped_light"]) { var oneDevice = {}; oneDevice.id = id; oneDevice.name = resource.metadata ? resource.metadata.name : false; oneDevice.model = resource["type"]; targetDevices[id] = oneDevice; } } // SCENES else if(targetType === "scene" && resource["type"] == "scene") { var oneDevice = {}; oneDevice.id = id; oneDevice.name = resource.metadata ? resource.metadata.name : false; oneDevice.group = processedResources[resource["group"]["rid"]].metadata.name; targetDevices[id] = oneDevice; } } // CONVERT TO ARRAY targetDevices = Object.values(targetDevices); // GIVE BACK res.end(JSON.stringify(targetDevices)); }) .catch(function(error) { res.status(500).send(JSON.stringify(error)); }); } }); };