UNPKG

iobroker.nanoleaf-lightpanels

Version:
1,490 lines (1,374 loc) 52.9 kB
/* jshint -W097 */// jshint strict:false /*jslint node: true */ "use strict"; // you have to require the utils module and call adapter function let utils = require("@iobroker/adapter-core"); // Get common adapter utils let adapter; // constants const SSDPUPNP = require(__dirname + "/lib/node-upnp-ssdp"); const DNS = require("dns"); const NET = require("net"); const AURORA_API = require(__dirname + "/lib/nanoleaf-aurora-api"); const STATE_EXCLUDES = ["brightness_duration"]; // Exclude list for states const MIN_POLLING_INTERVAL = 500; // minimum polling interval in milliseconds const MIN_RECONNECT_INTERVAL = 10; // minimum interval for reconnect attempts in seconds const DEFAULT_TIMEOUT = 10000; const KEEPALIVE_INTERVAL = 75000; // interval in ms when device is not alive anymore const MIN_KEEPALIVE_POLLING_INTERVAL = 10; // minimum interval in seconds for polling keep alive detection const SSDP_MSEARCH_TIMEOUT = 5000; // time to wait for getting SSDP answers for a MSEARCH const MSEARCH_ST = "ssdp:all"; // Service type for MESEARCH -> all to develop all kind of nanoleaf devices // nanoleaf device definitions const NANOLEAF_DEVICES = { lightpanels: { model: "NL22", deviceName: "LightPanels", name: "Light Panels", SSDP_NT_ST: "nanoleaf_aurora:light", SSEFirmware: "3.1.0", hasTouch: false }, canvas: { model: "NL29", deviceName: "Canvas", name: "Canvas", SSDP_NT_ST: "nanoleaf:nl29", SSEFirmware: "1.1.0", hasTouch: true }, shapes: { model: "NL42", deviceName: "Shapes", name: "Shapes", SSDP_NT_ST: "nanoleaf:nl42", SSEFirmware: "4.0.2", hasTouch: true }, elements: { model: "NL52", deviceName: "Elements", name: "Elements", SSDP_NT_ST: "nanoleaf:nl52", SSEFirmware: "1.0.0", hasTouch: true }, lines: { model: "NL59", deviceName: "Lines", name: "Lines", SSDP_NT_ST: "nanoleaf:nl59", SSEFirmware: "1.0.0", hasTouch: false }, skylight: { model: "NL64", deviceName: "Skylight", name: "Skylight", SSDP_NT_ST: "nanoleaf:nl64", SSEFirmware: "1.0.0", hasTouch: false } }; // variables let auroraAPI; // Instance of auroraAPI-Client let isConnected; // indicates connection to device (same value as info.connection state) let lastError; // keeps the last error occurred let commandQueue = []; // Array for all state changes (commands) to process (Queue) let commandQueueProcessing = false; // flag to show that command queue processing is in progress let NLdevice; // holds the nanoleaf device type which will be processed let NL_UUID; // UUID of NL device received via SSDP let SSDP_devices = []; // list of devices found via SSDP MSEARCH let SSEenabled; // indicates if SSE is enabled var SSDP; // SSDP object // Timers let pollingTimer; let connectTimer; let keepAliveTimer; let SSDP_mSearchTimer; let pollingInterval; let reconnectInterval; let keepAlivePollingInterval; function startAdapter(options) { options = options || {}; Object.assign(options, { name: "nanoleaf-lightpanels" }); adapter = new utils.Adapter(options); adapter.on("ready", function () { adapter.log.info("Nanoleaf adapter '" + adapter.namespace + "' started."); main(); }); // is called when adapter shuts down - callback has to be called under any circumstances! adapter.on("unload", function (callback) { try { adapter.log.info("Shutting down Nanoleaf adapter '" + adapter.namespace + "'..."); StopPollingTimer(); StopConnectTimer(); SSDP.close(); auroraAPI.stopSSE(); callback(); } catch (e) { callback(); } }); // Some message was sent to adapter instance adapter.on("message", function (obj) { adapter.log.debug("Incoming adapter message: " + obj.command); switch (obj.command) { case "getAuthToken": adapter.log.info("Try to obtain authorization token from '" + obj.message.host + ":" + obj.message.port + "' (device has to be in pairing mode!)"); let messageObj = {}; AURORA_API.getAuthToken(obj.message.host, obj.message.port) .then(function(authToken) { messageObj.message = "SuccessGetAuthToken"; messageObj.authToken = authToken; adapter.log.info("Got new Authentication Token: '" + authToken + "'"); }) .catch(function(error) { messageObj.message = error.errorCode; adapter.log.error(error.message); if (error.messageDetail) adapter.log.debug(error.messageDetail); }) .finally(function() { if (obj.callback) adapter.sendTo(obj.from, obj.command, messageObj, obj.callback); }); break; case "searchDevice": SSDP_mSearch(function (devices) { adapter.log.debug("MSEARCH: " + devices.length + " devices found!"); SSDP_mSearchTimer = null; if (obj.callback) adapter.sendTo(obj.from, obj.command, devices, obj.callback); }); break; default: adapter.log.debug("Invalid adapter message send: " + obj); } }); // is called if a subscribed state changes adapter.on("stateChange", function (id, state) { if (state) adapter.log.debug("State change " + ((state.ack) ? "status" : "command") + ": id: " + id + ": " + JSON.stringify(state)); // acknowledge false for command if (state && !state.ack) { let stateID = id.split("."); // get state name let stateName = stateID.pop(); // get device name let DeviceName = stateID.pop(); if (DeviceName == NLdevice.deviceName || DeviceName == "Rhythm") { commandQueue.push({stateName, state, id}); adapter.log.debug("Command '" + stateName + "' with value '" + state.val + "' added to queue! Queue length: " + commandQueue.length); // start processing commands when not in progress if (!commandQueueProcessing) { adapter.log.debug("Start processing commands..."); processCommandQueue(); } } } }); return adapter; } // process command queue function processCommandQueue() { let nextCommand = commandQueue.shift(); if (!nextCommand) { commandQueueProcessing = false; adapter.log.debug("No further commands in queue. Processing finished."); return; } let stateName = nextCommand.stateName; let state = nextCommand.state; let id = nextCommand.id; commandQueueProcessing = true; adapter.log.debug("Process new command '" + stateName + "' with value '" + state.val + "' from queue. Commands remaining: " + commandQueue.length); switch (stateName) { // Power On/Off case "state": if (state.val) auroraAPI.turnOn() .then(function() { adapter.log.debug("OpenAPI: Device turned on"); adapter.setState(id, {ack: true}); }) .catch(function(err) { logApiError("OpenAPI: Error turning on light panels", err); }) .then(function() { processCommandQueue(); }); else auroraAPI.turnOff() .then(function() { adapter.log.debug("OpenAPI: Device turned off"); adapter.setState(id, {ack: true}); }) .catch(function(err) { logApiError("OpenAPI: Error turning off light panels", err); }) .then(function() { processCommandQueue(); }); break; // Brightness case "brightness": adapter.getState(NLdevice.deviceName + ".brightness_duration", function(err, obj) { let duration = 0; if (err) adapter.log.error("Error while reading 'brightness_duration' object: " + err); else if (Number.isInteger(obj.val)) duration = obj.val; auroraAPI.setBrightness(parseInt(state.val), duration) // parseInt to fix vis colorPicker .then(function() { adapter.log.debug("OpenAPI: Brightness set to " + state.val + " with duration of " + duration + " seconds"); adapter.setState(id, {ack: true}); }) .catch(function(err) { logApiError("OpenAPI: Error while setting brightness value " + state.val + " with duration of " + duration + " seconds", err); }) .then(function() { processCommandQueue(); }); }); break; // Hue case "hue": auroraAPI.setHue(parseInt(state.val)) // parseInt to fix vis colorPicker .then(function() { adapter.log.debug("OpenAPI: Hue set to " + state.val); adapter.setState(id, {ack: true}); }) .catch(function(err) { logApiError("OpenAPI: Error while setting hue value " + state.val, err); }) .then(function() { processCommandQueue(); }); break; // Saturation case "saturation": auroraAPI.setSat(parseInt(state.val)) // parseInt to fix vis colorPicker .then(function() { adapter.log.debug("OpenAPI: Saturation set to " + state.val); adapter.setState(id, {ack: true}); }) .catch(function(err) { logApiError("OpenAPI: Error while setting saturation value " + state.val, err); }) .then(function() { processCommandQueue(); }); break; // Color Temperature case "colorTemp": auroraAPI.setColourTemperature(state.val) .then(function() { adapter.log.debug("OpenAPI: Color temperature set to " + state.val); adapter.setState(id, {ack: true}); }) .catch(function(err) { logApiError("OpenAPI: Error while setting color temperature " + state.val, err); }) .then(function() { processCommandQueue(); }); break; // RGB Color case "colorRGB": let rgb = RGBHEXtoRGBDEC(state.val); if (rgb) { auroraAPI.setRGB(rgb.R, rgb.G, rgb.B) .then(function() { adapter.log.debug("OpenAPI: RGB color set to " + state.val + " (" + rgb.R + "," + rgb.G + "," + rgb.B + ")"); adapter.setState(id, {ack: true}); }) .catch(function(err) { logApiError("OpenAPI: Error while setting RGB color R=" + rgb.R + ", G=" + rgb.G + ", B=" + rgb.B, err); }) .then(function() { processCommandQueue(); }); } else { adapter.log.error("OpenAPI: set RGB color: Supplied RGB hex string '" + state.val + "' is invalid!"); processCommandQueue(); } break; // Current effect case "effect": auroraAPI.setEffect(state.val) .then(function() { adapter.log.debug("OpenAPI: Effect set to '" + state.val + "'"); adapter.setState(id, {ack: true}); }) .catch(function(err) { logApiError("OpenAPI: Error while setting effect '" + state.val + "'", err); }) .then(function() { processCommandQueue(); }); break; // write effect case "effectWrite": try { var effectObj = JSON.parse(state.val); auroraAPI.writeEffect(effectObj) .then(function(data) { adapter.log.debug("OpenAPI: Write Effect '" + state.val + "'"); adapter.setState(id, {ack: true}); //write response adapter.setState(id + "Response", {val: data, ack: true}); }) .catch(function(err) { logApiError("OpenAPI: Error while writing effect '" + state.val + "'", err); }) .then(function() { processCommandQueue(); }); } catch (err) { adapter.log.error("The supplied value for 'effectWrite' is no valid JSON! Error: " + err.message); } break; // Identify case "identify": auroraAPI.identify() .then(function() { adapter.log.debug("OpenAPI: Identify panels enabled!"); adapter.setState(id, {ack: true}); }) .catch(function(err) { logApiError("OpenAPI: Error while triggering identification", err); }) .then(function() { processCommandQueue(); }); break; // Rhythm Mode case "rhythmMode": auroraAPI.setRhythmMode((state.val)) .then(function() { adapter.log.debug("OpenAPI: Rhythm mode set to '" + state.val + "'"); adapter.setState(id, {ack: true}); }) .catch(function(err) { logApiError("OpenAPI: Error while setting rhythm mode '" + state.val + "'", err); }) .then(function() { processCommandQueue(); }); break; // no valid command -> skip and warn if not in exclude list default: if (!STATE_EXCLUDES.includes(stateName)) adapter.log.warn("Command for state '" + stateName + "\ invalid, skipping..."); processCommandQueue(); } } // clear command queue function clearCommandQueue() { commandQueue.length = 0; adapter.log.debug("Command queue cleared!"); } // convert HSV color to RGB color /** * @return {string} */ function HSVtoRGB(hue, saturation, value) { let h, i, f, s, v, p, q, t, r, g, b; s = saturation / 100; v = value / 100; if (s == 0) // achromatic (Gray) r = g = b = v; else { h = hue / 60; i = Math.floor(h); f = h - i; p = v * (1 - s); q = v * (1 - s * f); t = v * (1 - s * (1 - f)); switch (i) { case 0: r = v; g = t; b = p; break; case 1: r = q; g = v; b = p; break; case 2: r = p; g = v; b = t; break; case 3: r = p; g = q; b = v; break; case 4: r = t; g = p; b = v; break; default: r = v; g = p; b = q; break; } } // convert to hex return "#" + ("0" + (Math.round(r * 255)).toString(16)).slice(-2) + ("0" + (Math.round(g * 255)).toString(16)).slice(-2) + ("0" + (Math.round(b * 255)).toString(16)).slice(-2) } // convert RGB hex string to decimal RGB components object function RGBHEXtoRGBDEC(RGBHEX) { let pattern = new RegExp("^#([0-9a-f]{2})([0-9a-f]{2})([0-9a-f]{2})$", "i"); let RGBDEC = {}; let res; if (res = pattern.exec(RGBHEX.trim())) { RGBDEC.R = parseInt(res[1], 16); RGBDEC.G = parseInt(res[2], 16); RGBDEC.B = parseInt(res[3], 16); return RGBDEC; } else return null; } function getDevice(url, ip, name) { let devInfo; const pattern = new RegExp("http:\/\/([0-9a-zA-Z\.]+)?:([0-9]{1,5})", "gi"); let res = pattern.exec(url); if (res && res.length == 3) { devInfo = {}; // check if host in url is available (workaround for nanoleaf firmware bug) if (res[1]) devInfo.host = res[1]; // host is missing, use IP address from packet header else { adapter.log.debug("Host in location string '" + url + "' seems to be missing or invalid, use address '" + ip + "' from packet header instead!"); devInfo.host = ip; } devInfo.port = res[2]; devInfo.name = name; } return devInfo; } function StartPollingTimer() { let interval; // if SSE with keep alive polling enables, use keep alive polling interval instead of update polling interval if (SSEenabled && adapter.config.keepAlivePolling) interval = keepAlivePollingInterval * 1000; else interval = pollingInterval; pollingTimer = setTimeout(statusUpdate, interval, true); adapter.log.debug("Polling timer started with " + interval + " ms"); } function StopPollingTimer() { clearTimeout(pollingTimer); pollingTimer = null; adapter.log.debug("Polling timer stopped!"); } function StartConnectTimer(isReconnect) { connectTimer = setTimeout(connect, reconnectInterval * 1000, isReconnect); adapter.log.debug("Connect timer started with " + reconnectInterval * 1000 + " ms"); } function StopConnectTimer() { clearTimeout(connectTimer); connectTimer = null; adapter.log.debug("Connect timer stopped!"); } function resetKeepAliveTimer() { clearTimeout(keepAliveTimer); keepAliveTimer = setTimeout(function() { reconnect("No ssdp:alive detected"); }, KEEPALIVE_INTERVAL); } // subscribe states changes on nanoleaf device using server sent events function startSSE() { auroraAPI.startSSE(function (data, error) { if (error) adapter.log.error(error); else { adapter.log.debug("SSE data '" + JSON.stringify(data) + "' received!"); statusUpdate(false, data); } }) .then(function () { adapter.log.debug("SSE subscription started, listening..."); }) .catch(function (error) { throw error; }); } // close SSE connection function stopSSE() { auroraAPI.stopSSE(); adapter.log.debug("SSE connection closed!"); } function formatError(err) { if (!err) return "Error: unknown"; let message = err; if (Number.isInteger(err)) { message = "HTTP status " + err; switch (err) { case 200: message += " (OK)"; break; case 204: message += " (No Content)"; break; case 400: message += " (Bad Request)"; break; case 401: message += " (Unauthorized)"; break; case 403: message += " (Forbidden)"; break; case 404: message += " (Not Found)"; break; case 422: message += " (Unprocessable Entity)"; break; } } else { if (/ECONNRESET/i.test(err.code)) message += " (Timeout)"; if (!/error/i.test(err)) message = "Error: " + message } return message; } // logging of actions via API. Only bad requests or unprocessable entity (invalid data supplied) logs error, all other only in debug function logApiError(msg, err) { let errormsg = msg + ", " + formatError(err); if (Number.isInteger(err)) { switch (err) { case 400: adapter.log.error(errormsg); break; case 404: adapter.log.warn(errormsg); break; case 422: adapter.log.error(errormsg); break; default: adapter.log.debug(errormsg); } } else adapter.log.debug(errormsg); } // Update states via polling function statusUpdate(polling, data) { // polling mode if (polling) auroraAPI.getInfo() .then(function(info) { StartPollingTimer(); // restart polling timer for next update // update states only when no SSE is used (skip updates when keep alive polling is active) if (!SSEenabled) { adapter.log.debug("Updating changed states..."); writeStates(JSON.parse(info)); } }) .catch(function(err) { adapter.log.debug("Updating states failed: " + formatError(err)); reconnect(err); }); // SSE mode else { try { var updateColorRGB = false; for (var event of data.events) { switch(data.eventID) { case AURORA_API.Events.state: switch (event.attr) { case AURORA_API.StateAttributes.on: writeState("state", event.value); break; case AURORA_API.StateAttributes.brightness: writeState("brightness", event.value); updateColorRGB = true; break; case AURORA_API.StateAttributes.hue: writeState("hue", event.value); updateColorRGB = true; break; case AURORA_API.StateAttributes.saturation: writeState("saturation", event.value); updateColorRGB = true; break; case AURORA_API.StateAttributes.cct: writeState("colorTemp", event.value); break; case AURORA_API.StateAttributes.colorMode: writeState("colorMode", event.value); break; default: adapter.log.warn("Attribute '" + event.attribute + "' for event ID '" + data.eventID + "' is not implemented. Please report that to the developer!"); } break; case AURORA_API.Events.effects: switch (event.attr) { case AURORA_API.EventAttributes.event: writeState("effect", event.value); break; case AURORA_API.EventAttributes.eventList: updateEventList(event.value); break; default: adapter.log.warn("Attribute '" + event.attribute + "' for event ID '" + data.eventID + "' is not implemented. Please report that to the developer!"); } break; case AURORA_API.Events.touch: writeState("touch.gesture", event.gesture, true); writeState("touch.panelID", event.panelId, true); break; default: adapter.log.warn("Invalid eventID '" + data.eventID + "' received from device. Please report that to the developer!"); } } // update colorRGB if colorMode = hs if necessary if (updateColorRGB) { // read old state adapter.getStates(NLdevice.deviceName + ".*", function (err, states) { if (err) adapter.log.error("Error reading States from '" + NLdevice.deviceName + "' " + err + ". No Update of 'colorRGB'!"); else { if (states[adapter.namespace + "." + NLdevice.deviceName + ".colorMode"].val == "hs") writeState("colorRGB", HSVtoRGB(states[adapter.namespace + "." + NLdevice.deviceName + ".hue"].val, states[adapter.namespace + "." + NLdevice.deviceName + ".saturation"].val, states[adapter.namespace + "." + NLdevice.deviceName + ".brightness"].val)); } }); } } catch (e) { adapter.log.error("Invalid data received from nanoleaf controller, cannot process!"); adapter.log.debug("Invalid data: " + JSON.stringify(data)); } } } // write single State function writeState(stateName, newState, forceUpdate) { // read old state adapter.getState(NLdevice.deviceName + "." + stateName, function (err, oldState) { if (err) adapter.log.error("Error reading state '" + NLdevice.deviceName + "." + stateName + "': " + err + ". State will not be updated!"); else setChangedState(NLdevice.deviceName + "." + stateName, oldState, newState, forceUpdate); }); } // write States function writeStates(newStates) { // read all old states adapter.getStates("*", function (err, oldStates) { if (err) throw "Error reading states: " + err + "!"; else { setChangedState(NLdevice.deviceName + ".state", oldStates[adapter.namespace + "." + NLdevice.deviceName + ".state"], newStates.state.on.value); setChangedState(NLdevice.deviceName + ".brightness", oldStates[adapter.namespace + "." + NLdevice.deviceName + ".brightness"], newStates.state.brightness.value); setChangedState(NLdevice.deviceName + ".hue", oldStates[adapter.namespace + "." + NLdevice.deviceName + ".hue"], newStates.state.hue.value); setChangedState(NLdevice.deviceName + ".saturation", oldStates[adapter.namespace + "." + NLdevice.deviceName + ".saturation"], newStates.state.sat.value); setChangedState(NLdevice.deviceName + ".colorTemp", oldStates[adapter.namespace + "." + NLdevice.deviceName + ".colorTemp"], newStates.state.ct.value); // write RGB color only when colorMode is 'hs' if (newStates.state.colorMode === "hs") setChangedState(NLdevice.deviceName + ".colorRGB", oldStates[adapter.namespace + "." + NLdevice.deviceName + ".colorRGB"], HSVtoRGB(newStates.state.hue.value, newStates.state.sat.value, newStates.state.brightness.value)); setChangedState(NLdevice.deviceName + ".colorMode", oldStates[adapter.namespace + "." + NLdevice.deviceName + ".colorMode"], newStates.state.colorMode); setChangedState(NLdevice.deviceName + ".effect", oldStates[adapter.namespace + "." + NLdevice.deviceName + ".effect"], newStates.effects.select); updateEventList(newStates.effects.effectsList); setChangedState(NLdevice.deviceName + ".info.name", oldStates[adapter.namespace + "." + NLdevice.deviceName + ".info.name"], newStates.name); setChangedState(NLdevice.deviceName + ".info.serialNo", oldStates[adapter.namespace + "." + NLdevice.deviceName + ".info.serialNo"], newStates.serialNo); setChangedState(NLdevice.deviceName + ".info.firmwareVersion", oldStates[adapter.namespace + "." + NLdevice.deviceName + ".info.firmwareVersion"],newStates.firmwareVersion); setChangedState(NLdevice.deviceName + ".info.model", oldStates[adapter.namespace + "." + NLdevice.deviceName + ".info.model"], newStates.model); // Rhythm module only available with nanoleaf Light-Panels, others have built in module and here we get no info about Rhythm if (typeof newStates.rhythm === "object") { let oldConnectedState = oldStates[adapter.namespace + ".Rhythm.info.connected"]; let newConnectedState = newStates.rhythm.rhythmConnected; setChangedState("Rhythm.info.connected", oldConnectedState, newConnectedState); // current connected state is true if (newConnectedState) { // last connection state was false -> create states if (!oldConnectedState || (oldConnectedState && !oldConnectedState.val)) { adapter.log.info("Rhythm module attached!"); CreateRhythmModuleStates(); } // Update states setChangedState("Rhythm.info.active", oldStates[adapter.namespace + ".Rhythm.info.active"], newStates.rhythm.rhythmActive); setChangedState("Rhythm.info.hardwareVersion", oldStates[adapter.namespace + ".Rhythm.info.hardwareVersion"], newStates.rhythm.hardwareVersion); setChangedState("Rhythm.info.firmwareVersion", oldStates[adapter.namespace + ".Rhythm.info.firmwareVersion"], newStates.rhythm.firmwareVersion); setChangedState("Rhythm.info.auxAvailable", oldStates[adapter.namespace + ".Rhythm.info.auxAvailable"], newStates.rhythm.auxAvailable); setChangedState("Rhythm.rhythmMode", oldStates[adapter.namespace + ".Rhythm.rhythmMode"], newStates.rhythm.rhythmMode); } // module is not connected anymore else { // last connection state was true -> delete states if (oldConnectedState && oldConnectedState.val) { adapter.log.info("Rhythm module detached!"); DeleteRhythmModuleStates(); } } } } }); } // set changed state value function setChangedState(stateID, oldState, newStateValue, forceUpdate = false) { // check oldStates try { // set state only when value changed or value is not acknowledged or state is null (never had a value) if (oldState == null || (oldState.val != newStateValue || forceUpdate) || !oldState.ack) { if (forceUpdate) adapter.log.debug("Update from OpenAPI: value for state '" + stateID + "' updated >>>> force update value: " + newStateValue); else adapter.log.debug("Update from OpenAPI: value for state '" + stateID + "' changed >>>> set new value: " + newStateValue); adapter.setState(stateID, newStateValue, true); } } catch (err) { let mes = "State '" + stateID + "' does not exist and will be ignored!"; adapter.log.warn(mes); adapter.log.debug(mes + " " + err); } } // write changes event list state and states of effect state function updateEventList(effectsArray) { let effectsList; let effectsStates = {}; adapter.getState(NLdevice.deviceName + ".effectsList", function (err, oldState) { if (err) adapter.log.error("Error reading state '" + NLdevice.deviceName + "." + stateName + "': " + err + ". State will not be updated!"); else { // loop through effectsList and write it as semicolon separated string and new states object for (let i = 0; i < effectsArray.length; i++) { if (effectsList) effectsList += ";" + effectsArray[i]; else effectsList = effectsArray[i]; effectsStates[effectsArray[i]] = effectsArray[i]; } setChangedState(NLdevice.deviceName + ".effectsList", oldState, effectsList); // updating states of effect if changed adapter.getObject(NLdevice.deviceName + ".effect", function (err, obj) { if (err) adapter.log.debug("Error getting '" + effectObject + "': " + err + ". States will not be updated!"); else { // only if list has changed if (JSON.stringify(effectsStates) !== JSON.stringify(obj.common.states)) { adapter.log.debug("Update from OpenAPI: possible states for state 'effect' changed >>>> set new states: " + JSON.stringify(effectsArray)); obj.common.states = effectsStates; adapter.setObject(NLdevice.deviceName + ".effect", obj, function (err) { if (err) adapter.log.debug("Error getting '" + NLdevice.deviceName + ".effect" + "': " + err) }); } } }); } }); } // sends a SSDP mSearch to discover nanoleaf devices function SSDP_mSearch(callback) { // clear device list SSDP_devices = []; SSDP.mSearch(MSEARCH_ST); // start timer for collecting SSDP responses SSDP_mSearchTimer = setTimeout(callback, SSDP_MSEARCH_TIMEOUT, SSDP_devices); } // Create nanoleaf device function createNanoleafDevice(deviceInfo, callback) { let model; let dev; let rhythmAvailable; let rhythmConnected; try { model = deviceInfo.model; rhythmAvailable = typeof deviceInfo.rhythm === "object"; rhythmConnected = rhythmAvailable && deviceInfo.rhythm.rhythmConnected; // loop through known nanoleaf devices and check model for (dev in NANOLEAF_DEVICES) { if (NANOLEAF_DEVICES[dev].model == model) { NLdevice = NANOLEAF_DEVICES[dev]; break; } } // if no model found, Canvas are fallback if (!NLdevice) { NLdevice = NANOLEAF_DEVICES.canvas; adapter.log.warn("nanoleaf device '" + model + "' unknown! Using Canvas device as fallback. Please report this to the developer!"); } // enable SSE instead of polling for firmwares higher then in specification given and disable SSE when selected in admin if (deviceInfo.firmwareVersion > NLdevice.SSEFirmware && !adapter.config.disableSSE) SSEenabled = true; else SSEenabled = false; } catch (e) { adapter.log.error("Invalid device info received from nanoleaf controller. Cannot detect device. Please check device!"); adapter.log.debug("Invalid device info: " + JSON.stringify(deviceInfo)); adapter.stop(); // stop adapter, cannot proceed here! } deleteNanoleafDevices(model); // delete all other nanoleaf device models if existing // if Rhythm module available -> create Rhythm device, else delete it if (rhythmAvailable) CreateRhythmDevice(rhythmConnected); else DeleteRhythmDevice(); adapter.log.debug("nanoleaf Device '" + NLdevice.name + "' (" + model + ") detected!"); // create the device if not exists adapter.getObject(NLdevice.deviceName, function (err, obj) { if (err) throw err; if (obj == null) { adapter.log.info("New nanoleaf device '" + NLdevice.name + "' (" + model + ") detected!"); // Create nanoleaf Device adapter.createDevice(NLdevice.deviceName, { "name": NLdevice.name + " Device", "icon": "/icons/" + NLdevice.deviceName.toLocaleLowerCase() + ".png" }, {} ); } // create info Channel adapter.setObjectNotExists(NLdevice.deviceName + ".info", { "type": "channel", "common": { "name": NLdevice.name + " Device Information", "icon": "/icons/" + NLdevice.deviceName.toLocaleLowerCase() + ".png" }, "native": {} } ); // create info "firmwareVersion" state adapter.setObjectNotExists(NLdevice.deviceName + ".info.firmwareVersion", { "type": "state", "common": { "name": "Firmware Version of nanoleaf device", "type": "string", "read": true, "write": false, "role": "info.version" }, "native": {} } ); // create info "model" state adapter.setObjectNotExists(NLdevice.deviceName + ".info.model", { "type": "state", "common": { "name": "Model of nanoleaf device", "type": "string", "read": true, "write": false, "role": "info.model" }, "native": {} } ); // create info "name" state adapter.setObjectNotExists(NLdevice.deviceName + ".info.name", { "type": "state", "common": { "name": "Name of nanoleaf device", "type": "string", "read": true, "write": false, "role": "info.name" }, "native": {} } ); // create info "serialNo" state adapter.setObjectNotExists(NLdevice.deviceName + ".info.serialNo", { "type": "state", "common": { "name": "Serial No. of nanoleaf device", "type": "string", "read": true, "write": false, "role": "info.serial" }, "native": {} } ); // create "state" state adapter.setObjectNotExists(NLdevice.deviceName + ".state", { "type": "state", "common": { "name": "Power State", "type": "boolean", "def": false, "read": true, "write": true, "role": "switch.light", "desc": "Switch on/off" }, "native": {} } ); // create "brightness" state adapter.setObjectNotExists(NLdevice.deviceName + ".brightness", { "type": "state", "common": { "name": "Brightness level", "type": "number", "unit": "%", "def": 100, "min": 0, "max": 100, "read": true, "write": true, "role": "level.dimmer", "desc": "Brightness level in %" }, "native": {} } ); // remove duration from brightness object (upgrade from older versions) adapter.getObject(NLdevice.deviceName + ".brightness", function (err, obj) { if (err) throw err; if (obj != null && typeof obj.native.duration !== "undefined") { delete obj.native.duration; adapter.setObject(NLdevice.deviceName + ".brightness", obj, function (err) { if (err) throw err; }); } }); // create "brightness duration" state adapter.setObjectNotExists(NLdevice.deviceName + ".brightness_duration", { "type": "state", "common": { "name": "Brightness duration", "type": "number", "unit": "sec", "def": 0, "min": 0, "max": 60, "read": true, "write": true, "role": "level.dimmer", "desc": "Brightness transition duration in seconds" }, "native": {} } ); // create "hue" state adapter.setObjectNotExists(NLdevice.deviceName + ".hue", { "type": "state", "common": { "name": "Hue value", "type": "number", "unit": "°", "def": 100, "min": 0, "max": 360, "read": true, "write": true, "role": "level.color.hue", "desc": "Hue value" }, "native": {} } ); // create "saturation" state adapter.setObjectNotExists(NLdevice.deviceName + ".saturation", { "type": "state", "common": { "name": "Saturation value", "type": "number", "unit": "%", "def": 100, "min": 0, "max": 100, "read": true, "write": true, "role": "level.color.saturation", "desc": "Saturation value" }, "native": {} } ); // create "colorMode" state adapter.setObjectNotExists(NLdevice.deviceName + ".colorMode", { "type": "state", "common": { "name": "Color Mode", "type": "string", "read": true, "write": false, "role": "value.color.mode", "desc": "Color Mode" }, "native": {} } ); // create "colorRGB" state adapter.setObjectNotExists(NLdevice.deviceName + ".colorRGB", { "type": "state", "common": { "name": "RGB Color", "type": "string", "read": true, "write": true, "role": "level.color.rgb", "desc": "Color in RGB hex format (#000000 to #FFFFFF)" }, "native": {} } ); // create "colorTemp" state adapter.setObjectNotExists(NLdevice.deviceName + ".colorTemp", { "type": "state", "common": { "name": "Color Temperature", "type": "number", "unit": "K", "def": 4000, "min": 1200, "max": 6500, "read": true, "write": true, "role": "level.color.temperature", "desc": "Color Temperature" }, "native": {} } ); // create "effect" state adapter.setObjectNotExists(NLdevice.deviceName + ".effect", { "type": "state", "common": { "name": "Current effect", "type": "string", "read": true, "write": true, "role": "text", "states": [], "desc": "Current effect" }, "native": {} } ); // create "effectsList" state adapter.setObjectNotExists(NLdevice.deviceName + ".effectsList", { "type": "state", "common": { "name": "Effects list", "type": "string", "read": true, "write": false, "role": "text", "desc": "List of available effects" }, "native": {} } ); // create "effectWrite" state adapter.setObjectNotExists(NLdevice.deviceName + ".effectWrite", { "type": "state", "common": { "name": "Write effect", "type": "string", "read": true, "write": true, "role": "text", "desc": "Writes a user defined effect to the device" }, "native": {} } ); // create "effectWriteResponse" state adapter.setObjectNotExists(NLdevice.deviceName + ".effectWriteResponse", { "type": "state", "common": { "name": "Response of write effect request", "type": "string", "read": true, "write": false, "role": "text", "desc": "Response of the write request for user defined effect" }, "native": {} } ); // touch event only for supported devices if (NLdevice.hasTouch) { adapter.log.debug("Touch capability available, create 'touch' event channel"); // create "touch" Channel adapter.setObjectNotExists(NLdevice.deviceName + ".touch", { "type": "channel", "common": { "name": NLdevice.name + " Touch event" }, "native": {} } ); // create touch "gesture" state adapter.setObjectNotExists(NLdevice.deviceName + ".touch.gesture", { "type": "state", "common": { "name": "Gesture of touch event", "type": "number", "read": true, "write": false, "role": "value", "states" : { 0 : "Single Tap", 1 : "Double Tap", 2 : "Swipe Up", 3 : "Swipe Down", 4 : "Swipe Left", 5 : "Swipe Right" } }, "native": {} } ); // create touch "panelID" state adapter.setObjectNotExists(NLdevice.deviceName + ".touch.panelID", { "type": "state", "common": { "name": "Panel ID of touch event", "type": "number", "read": true, "write": false, "role": "value" }, "native": {} } ); } else { adapter.getObject(NLdevice.deviceName + ".touch", function (err, obj) { if (err) throw err; // if existent, delete it if (obj != null) { adapter.log.debug("No touch capability, delete 'touch' event channel"); adapter.deleteChannel(NLdevice.deviceName, "touch"); } }); } // create "identify" state adapter.setObjectNotExists(NLdevice.deviceName + ".identify", { "type": "state", "common": { "name": "Identify panels", "type": "boolean", "read": false, "write": true, "role": "button", "desc": "Causes the panels to flash in unison" }, "native": {} }, // last state to be created, after this start adapter processing with callback function function() { callback(deviceInfo); } ); }); } // delete all nanoleaf devices // Parameter 'ignoreModel': ignore deletion of this device model function deleteNanoleafDevices(ignoreModel) { let device; // loop through all known devices and delete if not ignored for (device in NANOLEAF_DEVICES) { // delete device if not ignored if (ignoreModel !== NANOLEAF_DEVICES[device].model) { // check if device is still existing adapter.getObject(NANOLEAF_DEVICES[device].deviceName, function (err, obj) { if (err) throw err; // delete it if (obj != null) { var devicename = obj._id.substring(obj._id.lastIndexOf(".") + 1); adapter.log.debug("Delete '" + devicename + "' device..."); // first, delete all states adapter.getStates(devicename + ".*", function (err, states) { for (let id in states) adapter.delObject(id); }); // second, delete all channels adapter.getChannelsOf(devicename, function (err, channels) { for (let i in channels) adapter.deleteChannel(devicename, channels[i]._id) }); // last, delete device adapter.deleteDevice(devicename); } }); } } } // Creates Rhythm Device function CreateRhythmDevice(connected) { // check if Rhythm Device already exists adapter.getObject("Rhythm", function (err, obj) { if (err) throw err; // if not existent, create it if (obj == null) { adapter.log.debug("Rhythm module information available. Creating Rhythm device..."); // create Rhythm Device adapter.createDevice("Rhythm", { "name": "Light Panels Rhythm Module Device", "icon": "/icons/rhythm.png" }, {} ); } // create Rhythm Channel adapter.setObjectNotExists("Rhythm.info", { "type": "channel", "common": { "name": "Light Panels Rhythm Module Device Information", "icon": "/icons/rhythm.png" }, "native": {} } ); // create "connected" state adapter.setObjectNotExists("Rhythm.info.connected", { "type": "state", "common": { "name": "Rhythm module connected to nanoleaf light panels", "type": "boolean", "read": true, "write": false, "role": "indicator.connected" }, "native": {} } ); adapter.deleteState("Rhythm", "info" , "rhythmMode"); // state moved outside info channel (clean up for older versions) // create rhythm module states if connected, else delete these if (connected) CreateRhythmModuleStates(); }); } // Deletes Rhythm device function DeleteRhythmDevice() { // check if Rhythm Device exists adapter.getObject("Rhythm", function (err, obj) { if (err) throw err; // if existent, delete it if (obj != null) { adapter.log.debug("Rhythm module is not available. Delete Rhythm device..."); adapter.deleteDevice("Rhythm"); } }); } // Creates Rhythm module states function CreateRhythmModuleStates() { // create "active" state adapter.setObjectNotExists("Rhythm.info.active", { "type": "state", "common": { "name": "Rhythm module active", "type": "boolean", "read": true, "write": false, "role": "indicator" }, "native": {} } ); // create "auxAvailable" state adapter.setObjectNotExists("Rhythm.info.auxAvailable", { "type": "state", "common": { "name": "AUX of rhythm module available", "type": "boolean", "read": true, "write": false, "role": "indicator" }, "native": {} } ); // create "firmwareVersion" state adapter.setObjectNotExists("Rhythm.info.firmwareVersion", { "type": "state", "common": { "name": "Firmware Version of rhythm module", "type": "string", "read": true, "write": false, "role": "info.version" }, "native": {} } ); // create "hardwareVersion" state adapter.setObjectNotExists("Rhythm.info.hardwareVersion", { "type": "state", "common": { "name": "Hardware Version of rhythm module", "type": "string", "read": true, "write": false, "role": "info.version.hw" }, "native": {} } ); // create "rhythmMode" state adapter.setObjectNotExists("Rhythm.rhythmMode", { "type": "state", "common": { "name": "Mode of rhythm module", "type": "number", "read": true, "write": true, "role": "state", "states": { "0": "Microphone", "1": "Aux Cable" } }, "native": {} } ); } // Deletes Rhythm module states function DeleteRhythmModuleStates() { adapter.log.debug("Delete Rhythm module states..."); adapter.deleteState("Rhythm", "info", "active"); adapter.deleteState("Rhythm", "info", "auxAvailable"); adapter.deleteState("Rhythm", "info", "firmwareVersion"); adapter.deleteState("Rhythm", "info", "hardwareVersion"); adapter.deleteState("Rhythm", "" , "rhythmMode"); } function startAdapterProcessing(deviceInfo) { // first states updates writeStates(deviceInfo); // subscribe state changes of nanoleaf device adapter.subscribeStates(NLdevice.deviceName + ".*"); // subscribe rhythmMode when Light Panels device if (deviceInfo.model == NANOLEAF_DEVICES.lightpanels.model) adapter.subscribeStates("Rhythm.rhythmMode"); // if SSE enabled start SSE and bind SSDP notify events for keep alive if (SSEenabled) { startSSE(); // Keep alive with polling when enabled if (adapter.config.keepAlivePolling) { StartPollingTimer(); // start polling timer for updates } // keep alive with SSDP notify else { // handle SSDP Notify messages SSDP.on("DeviceAvailable:" + NLdevice.SSDP_NT_ST, SSDP_notify); // handle device becomes unavailable SSDP.on("DeviceUnavailable:" + NLdevice.SSDP_NT_ST, SSDP_goodbye); adapter.log.debug("SSDP notify events initialized!"); resetKeepAliveTimer(); } } // else use polling else StartPollingTimer(); // start polling timer for updates } function stopAdapterProcessing() { // stop processing commands by clearing queue clearCommandQueue(); // if SSE enabled stop SSE and unbind SSDP events if (SSEenabled) { stopSSE(); // stop keep alive polling when enabled if (adapter.config.keepAlivePolling) { StopPollingTimer(); } // remove SSDP events only if no polling is active else { // remove SSDP notify events SSDP.removeAllListeners("DeviceAvailable:" + NLdevice.SSDP_NT_ST, SSDP_notify); SSDP.removeAllListeners("DeviceUnavailable:" + NLdevice.SSDP_NT_ST, SSDP_goodbye); adapter.log.debug("SSDP notify events disabled!"); } } // else stop polling else StopPollingTimer(); adapter.unsubscribeStates(NLdevice.deviceName + ".*"); // unsubscribe state changes adapter.unsubscribeStates("Rhythm.rhythmMode"); } // connection loss detected, stop adapter processing and start reconnect function reconnect(err) { adapter.log.warn("Connection to '" + auroraAPI.host + ":" + auroraAPI.port + "' lost, " + formatError(err) + ". Try to reconnect..."); stopAdapterProcessing(); setConnectedState(false); // set disconnect state lastError = err; StartConnectTimer(true); // start connect timer } function setConnectedState(connected) { adapter.setState("info.connection", connected, true); isConnected = connected; } function connect(isReconnect) { // establish connection through sending info-request auroraAPI.getInfo() .then(function(info) { StopConnectTimer(); setConnectedState(true); // set connection state to true adapter.log.info(((isReconnect) ? "Reconnected" : "Connected") + " to '" + auroraAPI.host + ":" + auroraAPI.port + "'"); let deviceInfo = JSON.parse(info); // create nanoleaf device and start adapter processing with callback createNanoleafDevice(deviceInfo, startAdapterProcessing); }) .catch(function(err) { let message = "Connection to '" + auroraAPI.host + ":" + auroraAPI.port + "' failed with " + formatError(err) + ". "; let messageDetail = "Please check hostname/IP, device and connection!"; // is HTTP error if (Number.isInteger(err)) { // special message for 401 and 403 if (err == 401 || err == 403) { messageDetail = "Permission denied, please check authorization token!"; adapter.log.error(message + messageDetail); } else { adapter.log.error(message + messageDetail + " Stopping..."); adapter.stop(); } } // other error, try to reconnect else { // log only if error changed or not timeout for reconnect attempt if ( (lastError === undefined || (err.code != lastError.code)) && !(isReconnect && err.code == "ETIMEDOUT") ) { adapter.log.error(message + messageDetail + "Retry in " + adapter.config.reconnectInterval + "s intervals..."); lastError = err; } else adapter.log.debug(message); StartConnectTimer(isReconnect); // start reconnect timer } }); } function SSDP_notify(data) { adapter.log.debug("ssdp:alive NOTIFY received: " + JSON.stringify(data)); // only if connected if (isConnected) { // check UUID if set if (NL_UUID) { if (NL_UUID == data.usn) { adapter.log.debug(data.usn + " matched nanoleaf device UUID! Keep alive..."); resetKeepAliveTimer(); // if match, keep alive } } // if not set check device and set UUID else { let dev = getDevice(data.location, data.address); // check device if (dev) { // if adapter host is IP, directly check if match if (NET.isIPv4(adapter.config.host)) { if (dev.host == adapter.config.host) { NL_UUID = data.usn; adapter.log.debug("nanoleaf " + NL_UUID + " from device '" + dev.host + "' set!"); resetKeepAliveTimer(); } } // resolve hostname and then match else { DNS.lookup(adapter.config.host, function(err, address) { if (err) adapter.log.error("Error while looking up DNS '" + adapter.config.host + "'. Error: " + err.code + " (" + err.message + ")."); else if (dev.host == address) { NL_UUID = data.usn; adapter.log.debug("nanoleaf " + NL_UUID + " from device '" + adapter.config.host + "' (" + dev.host + ") set!"); resetKeepAliveTimer(); } }); } } else { adapter.log.debug("Invalid location '" + data.location + "' received from d