UNPKG

node-red-contrib-knx-ultimate

Version:

Control your KNX and KNX Secure intallation via Node-Red! A bunch of KNX nodes, with integrated Philips HUE control and ETS group address importer. Easy to use and highly configurable.

974 lines (933 loc) 93 kB
/* eslint-disable no-param-reassign */ /* eslint-disable camelcase */ /* eslint-disable max-len */ /* eslint-disable no-lonely-if */ const cloneDeep = require("lodash/cloneDeep"); const dptlib = require('knxultimate').dptlib; const hueColorConverter = require("./utils/colorManipulators/hueColorConverter"); const DEFAULT_DAY_SWITCH_STATE = { kelvin: 3000, brightness: 100 }; const DEFAULT_NIGHT_SWITCH_STATE = { kelvin: 2700, brightness: 20 }; function safeJSONParse(value, fallback) { if (value === undefined || value === null || value === "") return cloneDeep(fallback); try { return JSON.parse(value); } catch (error) { return cloneDeep(fallback); } } function hydrateSwitchColor(value, fallback) { if (value === undefined || value === null || value === "") return cloneDeep(fallback); if (typeof value !== "string") return cloneDeep(value); const cleaned = value.replace(/geen/gi, "green"); if (cleaned.indexOf("#") !== -1) { try { return hueColorConverter.ColorConverter.hexRgb(cleaned.replace("#", "")); } catch (error) { return cloneDeep(fallback); } } return safeJSONParse(cleaned, fallback); } function normalizeRuleKey(rawValue, dpt) { if (rawValue === undefined || rawValue === null) return ""; const value = typeof rawValue === "string" ? rawValue.trim() : String(rawValue); if (value === "") return ""; if (typeof dpt !== "string" || dpt === "") return value; const prefix = dpt.split(".")[0]; switch (prefix) { case "1": case "2": if (value === "1" || value.toLowerCase() === "true") return "true"; if (value === "0" || value.toLowerCase() === "false") return "false"; return value.toLowerCase(); case "5": case "6": case "7": case "8": case "9": case "12": case "13": case "14": case "20": { const num = Number(value); return Number.isNaN(num) ? value : String(num); } case "16": return value; default: return value; } } function normalizeIncomingValue(value, dpt) { if (value === undefined || value === null) return ""; if (typeof value === "boolean") return value ? "true" : "false"; if (typeof value === "number") return Number.isNaN(value) ? "" : String(value); if (typeof value === "string") return value.trim(); if (typeof value === "object") { if (Object.prototype.hasOwnProperty.call(value, "value")) return normalizeIncomingValue(value.value, dpt); if (Object.prototype.hasOwnProperty.call(value, "scene_number")) return normalizeIncomingValue(value.scene_number, dpt); if (Object.prototype.hasOwnProperty.call(value, "scenenumber")) return normalizeIncomingValue(value.scenenumber, dpt); if (Object.prototype.hasOwnProperty.call(value, "text")) return normalizeIncomingValue(value.text, dpt); try { return JSON.stringify(value); } catch (error) { return ""; } } return ""; } function convertRuleValueForStatus(rawValue, dpt) { if (rawValue === undefined || rawValue === null) return undefined; if (typeof dpt !== "string" || dpt === "") { return typeof rawValue === "string" ? rawValue.trim() : rawValue; } const valueStr = typeof rawValue === "string" ? rawValue.trim() : String(rawValue); const prefix = dpt.split(".")[0]; switch (prefix) { case "1": case "2": if (valueStr === "1" || valueStr.toLowerCase() === "true") return true; if (valueStr === "0" || valueStr.toLowerCase() === "false") return false; return undefined; case "5": case "6": case "7": case "8": case "9": case "12": case "13": case "14": case "20": { const num = Number(valueStr); return Number.isNaN(num) ? undefined : num; } case "16": return valueStr; default: return valueStr; } } function buildEffectLookups(rules, commandDpt, statusDpt) { const byKnxValue = new Map(); const byEffect = new Map(); if (!Array.isArray(rules)) return { byKnxValue, byEffect }; rules.forEach((entry) => { if (!entry || typeof entry.hueEffect !== "string") return; const effectName = entry.hueEffect.trim(); if (effectName === "") return; const normalizedKey = normalizeRuleKey(entry.knxValue, commandDpt); if (normalizedKey !== "" && !byKnxValue.has(normalizedKey)) { byKnxValue.set(normalizedKey, effectName); } if (!byEffect.has(effectName)) { const statusValue = convertRuleValueForStatus(entry.knxValue, statusDpt); byEffect.set(effectName, { knxValue: entry.knxValue, statusValue, }); } }); return { byKnxValue, byEffect }; } function prepareHueLightConfig(rawConfig) { const cfg = { ...rawConfig }; if (cfg.nameLightKelvinDIM === undefined) { cfg.nameLightKelvinDIM = cfg.nameLightHSV; cfg.GALightKelvinDIM = cfg.GALightHSV; cfg.dptLightKelvinDIM = cfg.dptLightHSV; cfg.nameLightKelvinPercentage = cfg.nameLightHSVPercentage; cfg.GALightKelvinPercentage = cfg.GALightHSVPercentage; cfg.dptLightKelvinPercentage = cfg.dptLightHSVPercentage; cfg.nameLightKelvinPercentageState = cfg.nameLightHSVState; cfg.GALightKelvinPercentageState = cfg.GALightHSVState; cfg.dptLightKelvinPercentageState = cfg.dptLightHSVState; } cfg.initializingAtStart = cfg.readStatusAtStartup === undefined || cfg.readStatusAtStartup === "yes"; cfg.specifySwitchOnBrightness = (cfg.specifySwitchOnBrightness === undefined || cfg.specifySwitchOnBrightness === "") ? "temperature" : cfg.specifySwitchOnBrightness; cfg.specifySwitchOnBrightnessNightTime = (cfg.specifySwitchOnBrightnessNightTime === undefined || cfg.specifySwitchOnBrightnessNightTime === "") ? "no" : cfg.specifySwitchOnBrightnessNightTime; cfg.colorAtSwitchOnDayTime = hydrateSwitchColor(cfg.colorAtSwitchOnDayTime, DEFAULT_DAY_SWITCH_STATE); cfg.colorAtSwitchOnNightTime = hydrateSwitchColor(cfg.colorAtSwitchOnNightTime, DEFAULT_NIGHT_SWITCH_STATE); cfg.dimSpeed = (cfg.dimSpeed === undefined || cfg.dimSpeed === "") ? 5000 : Number(cfg.dimSpeed); cfg.HSVDimSpeed = (cfg.HSVDimSpeed === undefined || cfg.HSVDimSpeed === "") ? 5000 : Number(cfg.HSVDimSpeed); cfg.invertDimTunableWhiteDirection = cfg.invertDimTunableWhiteDirection !== undefined && cfg.invertDimTunableWhiteDirection !== false; cfg.restoreDayMode = cfg.restoreDayMode === undefined ? "no" : cfg.restoreDayMode; cfg.invertDayNight = cfg.invertDayNight === undefined ? false : Boolean(cfg.invertDayNight); if (!Array.isArray(cfg.effectRules)) { cfg.effectRules = safeJSONParse(cfg.effectRules, []); } if (!Array.isArray(cfg.effectRules)) cfg.effectRules = []; cfg.effectRules = cfg.effectRules .filter((rule) => rule && typeof rule.hueEffect === "string") .map((rule) => ({ knxValue: rule.knxValue === undefined || rule.knxValue === null ? "" : String(rule.knxValue), hueEffect: rule.hueEffect, })); return cfg; } module.exports = function (RED) { function knxUltimateHueLight(config) { RED.nodes.createNode(this, config); const node = this; node.serverKNX = RED.nodes.getNode(config.server) || undefined; node.serverHue = RED.nodes.getNode(config.serverHue) || undefined; // Normalize legacy fields and defaults before using the configuration config = prepareHueLightConfig(config); node.effectRules = Array.isArray(config.effectRules) ? cloneDeep(config.effectRules) : []; node.effectRuleLookup = buildEffectLookups(node.effectRules, config.dptLightEffect, config.dptLightEffectStatus); node.availableEffects = new Set(); if (config.hueDeviceObject && config.hueDeviceObject.effects && Array.isArray(config.hueDeviceObject.effects.status_values)) { config.hueDeviceObject.effects.status_values.forEach((effect) => { if (effect !== undefined && effect !== null && effect !== "") node.availableEffects.add(effect); }); } node.availableEffects.add('no_effect'); node.currentEffectStatus = undefined; node.topic = node.name; node.name = config.name === undefined ? "Hue" : config.name; node.outputtopic = node.name; node.dpt = ""; node.notifyreadrequest = true; node.notifyreadrequestalsorespondtobus = "false"; node.notifyreadrequestalsorespondtobusdefaultvalueifnotinitialized = ""; node.notifyresponse = false; node.notifywrite = true; node.initialread = true; node.listenallga = true; // Don't remove node.outputtype = "write"; node.outputRBE = 'false'; // Apply or not RBE to the output (Messages coming from flow) node.inputRBE = 'false'; // Apply or not RBE to the input (Messages coming from BUS) node.currentPayload = ""; // Current value for the RBE input and for the .previouspayload msg node.passthrough = "no"; node.formatmultiplyvalue = 1; node.formatnegativevalue = "leave"; node.formatdecimalsvalue = 2; node.currentHUEDevice = undefined; // At start, this value is filled by a call to HUE api. It stores a value representing the current light status. node.lastKnownBrightness = undefined; // Stores the latest non-zero brightness to honour "keep brightness" behaviour when toggling via KNX. node.HUEDeviceWhileDaytime = null;// This retains the HUE device status while daytime, to be restored after nighttime elapsed. node.HUELightsBelongingToGroupWhileDaytime = null; // Array contains all light belonging to the grouped_light (if grouped_light is selected) node.DayTime = true; node.isGrouped_light = config.hueDevice.split("#")[1] === "grouped_light"; node.hueDevice = config.hueDevice.split("#")[0]; node.initializingAtStart = config.initializingAtStart; node.timerCheckForFastLightSwitch = null; node.HSVObject = null; //{ h, s, v };// Store the current light calculated HSV const pushStatus = (status) => { if (!status) return; const provider = node.serverKNX; if (provider && typeof provider.applyStatusUpdate === 'function') { provider.applyStatusUpdate(node, status); } else { node.status(status); } }; const updateStatus = (status) => { if (!status) return; pushStatus(status); }; const safeSendToKNX = (telegram, context = 'write') => { try { if (!node.serverKNX || typeof node.serverKNX.sendKNXTelegramToKNXEngine !== 'function') { const now = new Date(); updateStatus({ fill: 'red', shape: 'dot', text: `KNX server missing (${context}) (${now.getDate()}, ${now.toLocaleTimeString()})` }); return; } node.serverKNX.sendKNXTelegramToKNXEngine({ ...telegram, nodecallerid: node.id }); } catch (error) { updateStatus({ fill: 'red', shape: 'dot', text: `KNX send error ${error.message}` }); } }; // Used to call the status update from the config node. node.setNodeStatus = ({ fill, shape, text, payload, }) => { try { if (node.currentHUEDevice?.on?.on === true) { fill = "blue"; shape = "dot" } else { fill = "blue"; shape = "ring" }; if (payload === undefined) payload = ''; const dDate = new Date(); payload = typeof payload === "object" ? JSON.stringify(payload) : payload.toString(); node.sKNXNodeStatusText = `|KNX: ${text} ${payload} (${dDate.getDate()}, ${dDate.toLocaleTimeString()})`; updateStatus({ fill, shape, text: (node.sHUENodeStatusText || '') + ' ' + (node.sKNXNodeStatusText || '') }); } catch (error) { } }; // Used to call the status update from the HUE config node. node.setNodeStatusHue = ({ fill, shape, text, payload }) => { try { if (node.currentHUEDevice?.on?.on === true) { fill = "blue"; shape = "dot" } else { fill = "blue"; shape = "ring" }; if (payload === undefined) payload = ''; const dDate = new Date(); payload = typeof payload === "object" ? JSON.stringify(payload) : payload.toString(); node.sHUENodeStatusText = `|HUE: ${text} ${payload} (${dDate.getDate()}, ${dDate.toLocaleTimeString()})`; updateStatus({ fill, shape, text: node.sHUENodeStatusText + ' ' + (node.sKNXNodeStatusText || '') }); } catch (error) { } }; const updateAvailableEffects = (effectsArray) => { if (!Array.isArray(effectsArray)) return; const newSet = new Set(); effectsArray.forEach((effect) => { if (effect !== undefined && effect !== null && effect !== "") newSet.add(effect); }); newSet.add('no_effect'); node.availableEffects = newSet; }; const hueQueueTarget = () => (node.isGrouped_light === false ? "setLight" : "setGroupedLight"); const queueHueCommand = (payload) => node.serverHue.hueManager.writeHueQueueAdd(node.hueDevice, payload, hueQueueTarget()); const reportHueStatus = (payload, text = "KNX->HUE", fill = "green") => { node.setNodeStatusHue({ fill, shape: "dot", text, payload }); }; const handleLightSwitch = (msg) => { let state = {}; msg.payload = dptlib.fromBuffer(msg.knx.rawValue, dptlib.resolve(config.dptLightSwitch)); if (node.lastKnownBrightness === undefined && node.currentHUEDevice?.dimming?.brightness > 0) { node.lastKnownBrightness = node.currentHUEDevice.dimming.brightness; } if (config.restoreDayMode === "setDayByFastSwitchLightSingle" || config.restoreDayMode === "setDayByFastSwitchLightALL") { if (node.DayTime === false) { if (msg.payload === true) { if (node.timerCheckForFastLightSwitch === null) { node.timerCheckForFastLightSwitch = setTimeout(() => { node.DayTime = false; RED.log.debug("knxUltimateHueLight: node.timerCheckForFastLightSwitch: set daytime to false after node.timerCheckForFastLightSwitch elapsed"); node.timerCheckForFastLightSwitch = null; }, 10000); } else { if (config.restoreDayMode === "setDayByFastSwitchLightALL") { if (config.GADaylightSensor !== undefined && config.GADaylightSensor !== "") { if (node.timerCheckForFastLightSwitch !== null) { clearTimeout(node.timerCheckForFastLightSwitch); node.timerCheckForFastLightSwitch = null; } RED.log.debug(`knxUltimateHueLight: node.timerCheckForFastLightSwitch: set daytime the group address ${config.GADaylightSensor}`); safeSendToKNX({ grpaddr: config.GADaylightSensor, payload: config.invertDayNight === false, dpt: config.dptDaylightSensor, outputtype: "write", }, 'write'); } } node.DayTime = true; RED.log.debug("knxUltimateHueLight: node.timerCheckForFastLightSwitch: set daytime to true"); } } } } if (msg.payload === true) { if ((node.DayTime === true && config.specifySwitchOnBrightness === "no") && ((node.isGrouped_light === false && node.HUEDeviceWhileDaytime !== null) || (node.isGrouped_light === true && node.HUELightsBelongingToGroupWhileDaytime !== null))) { if (node.isGrouped_light === false && node.HUEDeviceWhileDaytime !== null) { state = { on: { on: true }, dimming: node.HUEDeviceWhileDaytime.dimming, color: node.HUEDeviceWhileDaytime.color, color_temperature: node.HUEDeviceWhileDaytime.color_temperature }; if (node.HUEDeviceWhileDaytime.color_temperature !== undefined && node.HUEDeviceWhileDaytime.color_temperature.mirek === null) delete state.color_temperature; queueHueCommand(state); if (state.dimming?.brightness > 0) node.lastKnownBrightness = state.dimming.brightness; reportHueStatus("Restore light status"); node.HUEDeviceWhileDaytime = null; } else if (node.isGrouped_light === true && node.HUELightsBelongingToGroupWhileDaytime !== null) { let bAtLeastOneIsOn = false; for (let index = 0; index < node.HUELightsBelongingToGroupWhileDaytime.length; index++) { const element = node.HUELightsBelongingToGroupWhileDaytime[index].light[0]; if (element.on.on === true) { bAtLeastOneIsOn = true; break; } } for (let index = 0; index < node.HUELightsBelongingToGroupWhileDaytime.length; index++) { const element = node.HUELightsBelongingToGroupWhileDaytime[index].light[0]; if (bAtLeastOneIsOn === true) { state = { on: element.on, dimming: element.dimming, color: element.color, color_temperature: element.color_temperature }; } else { state = { on: { on: true }, dimming: element.dimming, color: element.color, color_temperature: element.color_temperature }; } if (element.color_temperature !== undefined && element.color_temperature.mirek === null) delete state.color_temperature; node.serverHue.hueManager.writeHueQueueAdd(element.id, state, "setLight"); if (state.dimming?.brightness > 0) node.lastKnownBrightness = state.dimming.brightness; } reportHueStatus("Resuming all group's light"); node.HUELightsBelongingToGroupWhileDaytime = null; return; } } else { let colorChoosen; let temperatureChoosen; let brightnessChoosen; if (node.currentHUEDevice.color_temperature !== undefined) { if (node.DayTime === true && config.specifySwitchOnBrightness === "temperature") { temperatureChoosen = config.colorAtSwitchOnDayTime.kelvin; } else if (node.DayTime === false && config.enableDayNightLighting === "temperature") { temperatureChoosen = config.colorAtSwitchOnNightTime.kelvin; } } if (node.currentHUEDevice.dimming !== undefined) { if (node.DayTime === true && config.specifySwitchOnBrightness === "temperature") { brightnessChoosen = config.colorAtSwitchOnDayTime.brightness; } else if (node.DayTime === false && config.enableDayNightLighting === "temperature") { brightnessChoosen = config.colorAtSwitchOnNightTime.brightness; } } if (node.currentHUEDevice.color !== undefined) { if (node.DayTime === true && config.specifySwitchOnBrightness === "yes") { colorChoosen = config.colorAtSwitchOnDayTime; } else if (node.DayTime === false && config.enableDayNightLighting === "yes") { colorChoosen = config.colorAtSwitchOnNightTime; } } if (colorChoosen !== undefined) { let gamut = null; if (node.currentHUEDevice.color.gamut !== undefined) { gamut = node.currentHUEDevice.color.gamut; } const dretXY = hueColorConverter.ColorConverter.calculateXYFromRGB(colorChoosen.red, colorChoosen.green, colorChoosen.blue, gamut); const dbright = hueColorConverter.ColorConverter.getBrightnessFromRGBOrHex(colorChoosen.red, colorChoosen.green, colorChoosen.blue); state = { color_temperature: { mirek: null }, color: { xy: dretXY }, dimming: { brightness: dbright }, on: { on: true }, }; if (node.currentHUEDevice.color_temperature === undefined) { delete state.color_temperature; } queueHueCommand(state); if (typeof dbright === "number" && dbright > 0) node.lastKnownBrightness = dbright; reportHueStatus(JSON.stringify(msg.payload)); return; } if (temperatureChoosen !== undefined) { let bBright; if (config.specifySwitchOnBrightness === "temperature") { bBright = brightnessChoosen; } else { bBright = node.currentHUEDevice.dimming?.brightness; } state = { color_temperature: { mirek: hueColorConverter.ColorConverter.kelvinToMirek(temperatureChoosen) }, dimming: { brightness: bBright }, on: { on: true }, }; if (node.currentHUEDevice.color_temperature === undefined) { delete state.color_temperature; } queueHueCommand(state); if (typeof bBright === "number" && bBright > 0) node.lastKnownBrightness = bBright; reportHueStatus(JSON.stringify(msg.payload)); return; } if (node.currentHUEDevice.dimming !== undefined) { let targetBrightness; if (brightnessChoosen !== undefined && brightnessChoosen !== null && brightnessChoosen !== "") { const parsed = Number(brightnessChoosen); if (!Number.isNaN(parsed)) targetBrightness = parsed; } if (targetBrightness === undefined) { if (typeof node.lastKnownBrightness === "number" && node.lastKnownBrightness > 0) { targetBrightness = node.lastKnownBrightness; } else if (typeof node.currentHUEDevice.dimming.brightness === "number" && node.currentHUEDevice.dimming.brightness > 0) { targetBrightness = node.currentHUEDevice.dimming.brightness; } } if (targetBrightness === undefined || targetBrightness <= 0) { targetBrightness = 100; } state = { dimming: { brightness: targetBrightness }, on: { on: true } }; if (targetBrightness > 0) node.lastKnownBrightness = targetBrightness; queueHueCommand(state); if (typeof targetBrightness === "number" && targetBrightness > 0) node.lastKnownBrightness = targetBrightness; reportHueStatus(JSON.stringify(msg.payload)); return; } state = { on: { on: true } }; queueHueCommand(state); reportHueStatus(JSON.stringify(msg.payload)); } } else { if (node.currentHUEDevice?.dimming?.brightness > 0) { node.lastKnownBrightness = node.currentHUEDevice.dimming.brightness; } state = { on: { on: false } }; queueHueCommand(state); reportHueStatus(JSON.stringify(msg.payload)); } }; function getRandomIntInclusive(min, max) { min = Math.ceil(min); max = Math.floor(max); return Math.floor(Math.random() * (max - min + 1) + min); // The maximum is inclusive and the minimum is inclusive } // This function is called by the hue-config.js node.handleSend = (msg) => { if (node.currentHUEDevice === undefined && node.serverHue.linkStatus === "connected") { node.setNodeStatusHue({ fill: "yellow", shape: "ring", text: "Initializing. Please wait.", payload: "", }); return; } if (msg.knx.event !== "GroupValue_Read" && node.currentHUEDevice !== undefined) { let state = {}; try { switch (msg.knx.destination) { case config.GALightSwitch: handleLightSwitch(msg); break; case config.GALightDIM: // { decr_incr: 1, data: 1 } : Start increasing until { decr_incr: 0, data: 0 } is received. // { decr_incr: 0, data: 1 } : Start decreasing until { decr_incr: 0, data: 0 } is received. msg.payload = dptlib.fromBuffer(msg.knx.rawValue, dptlib.resolve(config.dptLightDIM)); node.hueDimming(msg.payload.decr_incr, msg.payload.data, config.dimSpeed); node.setNodeStatusHue({ fill: "green", shape: "dot", text: "KNX->HUE", payload: JSON.stringify(msg.payload), }); break; case config.GALightHSV_H_DIM: // { decr_incr: 1, data: 1 } : Start increasing until { decr_incr: 0, data: 0 } is received. // { decr_incr: 0, data: 1 } : Start decreasing until { decr_incr: 0, data: 0 } is received. msg.payload = dptlib.fromBuffer(msg.knx.rawValue, dptlib.resolve(config.dptLightDIM)); node.hueDimmingHSV_H(msg.payload.decr_incr, msg.payload.data, config.HSVDimSpeed); node.setNodeStatusHue({ fill: "green", shape: "dot", text: "KNX->HUE", payload: JSON.stringify(msg.payload), }); break; case config.GALightHSV_S_DIM: // { decr_incr: 1, data: 1 } : Start increasing until { decr_incr: 0, data: 0 } is received. // { decr_incr: 0, data: 1 } : Start decreasing until { decr_incr: 0, data: 0 } is received. msg.payload = dptlib.fromBuffer(msg.knx.rawValue, dptlib.resolve(config.dptLightDIM)); node.hueDimmingHSV_S(msg.payload.decr_incr, msg.payload.data, config.HSVDimSpeed); node.setNodeStatusHue({ fill: "green", shape: "dot", text: "KNX->HUE", payload: JSON.stringify(msg.payload), }); break; case config.GALightKelvin: let retMirek; let kelvinValue = 0; msg.payload = dptlib.fromBuffer(msg.knx.rawValue, dptlib.resolve(config.dptLightKelvin)); if (config.dptLightKelvin === "7.600") { if (msg.payload > 6535) msg.payload = 6535; if (msg.payload < 2000) msg.payload = 2000; kelvinValue = msg.payload;//hueColorConverter.ColorConverter.scale(msg.payload, [0, 65535], [2000, 6535]); retMirek = hueColorConverter.ColorConverter.kelvinToMirek(kelvinValue); } else if (config.dptLightKelvin === "9.002") { // Relative temperature in Kelvin. Use HUE scale. if (msg.payload > 6535) msg.payload = 6535; if (msg.payload < 2000) msg.payload = 2000; retMirek = hueColorConverter.ColorConverter.kelvinToMirek(msg.payload); } state = { color_temperature: { mirek: retMirek } }; node.serverHue.hueManager.writeHueQueueAdd(node.hueDevice, state, node.isGrouped_light === false ? "setLight" : "setGroupedLight"); node.setNodeStatusHue({ fill: "green", shape: "dot", text: "KNX->HUE", payload: kelvinValue, }); break; case config.GADaylightSensor: node.DayTime = Boolean(dptlib.fromBuffer(msg.knx.rawValue, dptlib.resolve(config.dptDaylightSensor))); if (config.invertDayNight === true) node.DayTime = !node.DayTime; if (config.specifySwitchOnBrightness === "no") { // This retains the HUE device status while daytime, to be restored after nighttime elapsed. https://github.com/Supergiovane/node-red-contrib-knx-ultimate/issues/298 if (node.DayTime === false) { if (node.isGrouped_light === false) node.HUEDeviceWhileDaytime = cloneDeep(node.currentHUEDevice); // DayTime has switched to false: save the currentHUEDevice into the HUEDeviceWhileDaytime if (node.isGrouped_light === true) { (async () => { try { const retLights = await node.serverHue.getAllLightsBelongingToTheGroup(node.hueDevice, false); node.HUELightsBelongingToGroupWhileDaytime = cloneDeep(retLights); // DayTime has switched to false: save the lights belonging to the group into the HUELightsBelongingToGroupWhileDaytime array } catch (error) { /* empty */ } })(); } } } else { node.HUEDeviceWhileDaytime = null; node.HUELightsBelongingToGroupWhileDaytime = null; } node.setNodeStatusHue({ fill: "green", shape: "dot", text: "KNX->HUE Daytime", payload: node.DayTime, }); break; case config.GALightKelvinDIM: if (config.dptLightKelvinDIM === "3.007") { // MDT smartbutton will dim the color temperature // { decr_incr: 1, data: 1 } : Start increasing until { decr_incr: 0, data: 0 } is received. // { decr_incr: 0, data: 1 } : Start decreasing until { decr_incr: 0, data: 0 } is received. msg.payload = dptlib.fromBuffer(msg.knx.rawValue, dptlib.resolve(config.dptLightKelvinDIM)); node.hueDimmingTunableWhite(msg.payload.decr_incr, msg.payload.data, 5000); node.setNodeStatusHue({ fill: "green", shape: "dot", text: "KNX->HUE", payload: JSON.stringify(msg.payload), }); } break; case config.GALightKelvinPercentage: if (config.dptLightKelvinPercentage === "5.001") { // 0-100% tunable white msg.payload = 100 - dptlib.fromBuffer(msg.knx.rawValue, dptlib.resolve(config.dptLightKelvinPercentage)); // msg.payload = msg.payload <= 0 ? 1 : msg.payload const retMirek = hueColorConverter.ColorConverter.scale(msg.payload, [0, 100], [153, 500]); msg.payload = retMirek; state = { color_temperature: { mirek: msg.payload } }; node.serverHue.hueManager.writeHueQueueAdd(node.hueDevice, state, node.isGrouped_light === false ? "setLight" : "setGroupedLight"); node.setNodeStatusHue({ fill: "green", shape: "dot", text: "KNX->HUE", payload: state, }); } break; case config.GALightBrightness: msg.payload = dptlib.fromBuffer(msg.knx.rawValue, dptlib.resolve(config.dptLightBrightness)); state = { dimming: { brightness: msg.payload } }; if (msg.payload > 0) node.lastKnownBrightness = msg.payload; if (node.currentHUEDevice === undefined) { // Grouped light state.on = { on: msg.payload > 0 }; } else { // Light if (node.currentHUEDevice.on.on === false && msg.payload > 0) state.on = { on: true }; if (node.currentHUEDevice.on.on === true && msg.payload === 0) state.on = { on: false }; } node.serverHue.hueManager.writeHueQueueAdd(node.hueDevice, state, node.isGrouped_light === false ? "setLight" : "setGroupedLight"); node.setNodeStatusHue({ fill: "green", shape: "dot", text: "KNX->HUE", payload: state, }); break; case config.GALightColor: msg.payload = dptlib.fromBuffer(msg.knx.rawValue, dptlib.resolve(config.dptLightColor)); let gamut = null; if ( node.currentHUEDevice !== undefined && node.currentHUEDevice.color !== undefined && node.currentHUEDevice.color.gamut !== undefined ) { gamut = node.currentHUEDevice.color.gamut; } const retXY = hueColorConverter.ColorConverter.calculateXYFromRGB(msg.payload.red, msg.payload.green, msg.payload.blue, gamut); const bright = hueColorConverter.ColorConverter.getBrightnessFromRGBOrHex(msg.payload.red, msg.payload.green, msg.payload.blue); state = { dimming: { brightness: bright }, color: { xy: retXY } }; if (node.currentHUEDevice === undefined) { // Grouped light state.on = { on: bright > 0 }; } else { // Light if (node.currentHUEDevice.on.on === false && bright > 0) state.on = { on: true }; if (node.currentHUEDevice.on.on === true && bright === 0) state = { on: { on: false }, dimming: { brightness: bright } }; } node.serverHue.hueManager.writeHueQueueAdd(node.hueDevice, state, node.isGrouped_light === false ? "setLight" : "setGroupedLight"); node.setNodeStatusHue({ fill: "green", shape: "dot", text: "KNX->HUE", payload: state, }); break; case config.GALightBlink: const gaVal = dptlib.fromBuffer(msg.knx.rawValue, dptlib.resolve(config.dptLightBlink)); if (gaVal) { node.timerBlink = setInterval(() => { if (node.blinkValue === undefined) node.blinkValue = true; node.blinkValue = !node.blinkValue; msg.payload = node.blinkValue; // state = msg.payload === true ? { on: { on: true } } : { on: { on: false } } state = msg.payload === true ? { on: { on: true }, dimming: { brightness: 100 }, dynamics: { duration: 0 } } : { on: { on: false }, dimming: { brightness: 0 }, dynamics: { duration: 0 } }; node.serverHue.hueManager.writeHueQueueAdd(node.hueDevice, state, node.isGrouped_light === false ? "setLight" : "setGroupedLight"); }, 1500); } else { if (node.timerBlink !== undefined) clearInterval(node.timerBlink); node.serverHue.hueManager.writeHueQueueAdd( node.hueDevice, { on: { on: false } }, node.isGrouped_light === false ? "setLight" : "setGroupedLight", ); } node.setNodeStatusHue({ fill: "green", shape: "dot", text: "KNX->HUE", payload: gaVal, }); break; case config.GALightColorCycle: { if (node.timerColorCycle !== undefined) clearInterval(node.timerColorCycle); const gaValColorCycle = dptlib.fromBuffer(msg.knx.rawValue, dptlib.resolve(config.dptLightColorCycle)); if (gaValColorCycle === true) { node.serverHue.hueManager.writeHueQueueAdd(node.hueDevice, { on: { on: true } }, node.isGrouped_light === false ? "setLight" : "setGroupedLight"); node.timerColorCycle = setInterval(() => { try { const red = getRandomIntInclusive(0, 255); const green = getRandomIntInclusive(0, 255); const blue = getRandomIntInclusive(0, 255); let gamut = null; if ( node.currentHUEDevice !== undefined && node.currentHUEDevice.color !== undefined && node.currentHUEDevice.color.gamut !== undefined ) { gamut = node.currentHUEDevice.color.gamut; } const retXY = hueColorConverter.ColorConverter.calculateXYFromRGB(red, green, blue, gamut); const bright = hueColorConverter.ColorConverter.getBrightnessFromRGBOrHex(red, green, blue); state = bright > 0 ? { on: { on: true }, dimming: { brightness: bright }, color: { xy: retXY } } : { on: { on: false } }; node.serverHue.hueManager.writeHueQueueAdd(node.hueDevice, state, node.isGrouped_light === false ? "setLight" : "setGroupedLight"); } catch (error) { } }, 10000); } else { if (node.timerColorCycle !== undefined) clearInterval(node.timerColorCycle); node.serverHue.hueManager.writeHueQueueAdd( node.hueDevice, { on: { on: false } }, node.isGrouped_light === false ? "setLight" : "setGroupedLight", ); } node.setNodeStatusHue({ fill: "green", shape: "dot", text: "KNX->HUE", payload: gaValColorCycle, }); } break; case config.GALightEffect: { if (!config.dptLightEffect || config.dptLightEffect === "") { node.setNodeStatusHue({ fill: "red", shape: "ring", text: "KNX->HUE Effect", payload: "Missing DPT", }); break; } let payloadEffect; try { payloadEffect = dptlib.fromBuffer(msg.knx.rawValue, dptlib.resolve(config.dptLightEffect)); } catch (error) { node.setNodeStatusHue({ fill: "red", shape: "ring", text: "KNX->HUE Effect", payload: error.message, }); break; } const normalizedEffectValue = normalizeIncomingValue(payloadEffect, config.dptLightEffect); let effectToApply = node.effectRuleLookup.byKnxValue.get(normalizedEffectValue); if (!effectToApply && typeof payloadEffect === "string") { const trimmed = payloadEffect.trim(); if (trimmed !== "") effectToApply = trimmed; } if (!effectToApply && config.dptLightEffect && config.dptLightEffect.startsWith("16.") && normalizedEffectValue !== "") { effectToApply = normalizedEffectValue; } if (!effectToApply) { node.setNodeStatusHue({ fill: "yellow", shape: "ring", text: "KNX->HUE Effect", payload: normalizedEffectValue, }); break; } const hueState = { effects: { status: effectToApply } }; if (!(node.availableEffects instanceof Set)) node.availableEffects = new Set(); node.availableEffects.add(effectToApply); queueHueCommand(hueState); if (node.currentHUEDevice !== undefined) { if (!node.currentHUEDevice.effects) node.currentHUEDevice.effects = {}; node.currentHUEDevice.effects.status = effectToApply; } node.currentEffectStatus = effectToApply; node.setNodeStatusHue({ fill: "green", shape: "dot", text: "KNX->HUE Effect", payload: effectToApply, }); } break; default: break; } } catch (error) { updateStatus({ fill: "red", shape: "dot", text: `KNX->HUE errorRead ${error.message} (${new Date().getDate()}, ${new Date().toLocaleTimeString()})`, }); RED.log.error(`knxUltimateHueLight: node.handleSend: if (msg.knx.event !== "GroupValue_Read"): ${error.message} : ${error.stack || ""} `); } } // I must respond to query requests (read request) sent from the KNX BUS try { if (msg.knx.event === "GroupValue_Read" && node.currentHUEDevice !== undefined) { let ret; switch (msg.knx.destination) { case config.GALightState: ret = node.currentHUEDevice.on.on; if (ret !== undefined) node.updateKNXLightState(ret, "response"); break; case config.GALightColorState: ret = node.currentHUEDevice.color.xy; if (ret !== undefined) node.updateKNXLightColorState(node.currentHUEDevice.color, "response"); break; case config.GALightKelvinPercentageState: // The kelvin level belongs to the group defice, so i don't need to get the first light in the collection (if the device is a grouped_light) ret = node.currentHUEDevice.color_temperature.mirek; if (ret !== undefined) node.updateKNXLightKelvinPercentageState(ret, "response"); // if (node.isGrouped_light === false) { // ret = node.currentHUEDevice.color_temperature.mirek; // if (ret !== undefined) node.updateKNXLightKelvinPercentageState(ret, "response"); // } else { // (async () => { // try { // // Find the first light in the collection, having the color_temperature capabilities // const devices = await node.serverHue.getAllLightsBelongingToTheGroup(node.hueDevice, false); // for (let index = 0; index < devices.length; index++) { // const element = devices[index]; // if (element.light[0].color_temperature !== undefined) { // ret = element.light[0].color_temperature.mirek; // break; // } // } // if (ret !== undefined) node.updateKNXLightKelvinPercentageState(ret, "response"); // } catch (error) { /* empty */ } // })(); // } break; case config.GALightBrightnessState: ret = node.currentHUEDevice.dimming.brightness; if (ret !== undefined) node.updateKNXBrightnessState(ret, "response"); break; case config.GALightKelvinState: // The kelvin level belongs to the group defice, so i don't need to get the first light in the collection (if the device is a grouped_light) ret = node.currentHUEDevice.color_temperature.mirek; if (ret !== undefined) node.updateKNXLightKelvinState(ret, "response"); // if (node.isGrouped_light === false) { // ret = node.currentHUEDevice.color_temperature.mirek; // if (ret !== undefined) node.updateKNXLightKelvinState(ret, "response"); // } else { // (async () => { // try { // // Find the first light in the collection, having the color_temperature capabilities // const devices = await node.serverHue.getAllLightsBelongingToTheGroup(node.hueDevice, false); // for (let index = 0; index < devices.length; index++) { // const element = devices[index]; // if (element.light[0].color_temperature !== undefined) { // ret = element.light[0].color_temperature.mirek; // break; // } // } // if (ret !== undefined) node.updateKNXLightKelvinState(ret, "response"); // } catch (error) { /* empty */ } // })(); // } break; case config.GALightEffectStatus: if (node.currentHUEDevice && node.currentHUEDevice.effects) { const effectStatus = node.currentHUEDevice.effects.status !== undefined ? node.currentHUEDevice.effects.status : "no_effect"; node.updateKNXLightEffectState(effectStatus, "response"); } break; default: break; } } } catch (error) { updateStatus({ fill: "red", shape: "dot", text: `KNX->HUE error :-( ${error.message} (${new Date().getDate()}, ${new Date().toLocaleTimeString()})`, }); RED.log.error(`knxUltimateHueLight: node.handleSend: if (msg.knx.event === "GroupValue_Read" && node.currentHUEDevice !== undefined): ${error.message} : ${error.stack || ""} `); } }; // Start dimming // *********************************************************** node.hueDimming = function hueDimming(_KNXaction, _KNXbrightness_Direction, _dimSpeedInMillisecs = undefined) { // 31/10/2023 after many attempts to use dimming_delta function of the HueApeV2, loosing days of my life, without a decent success, will use the standard dimming calculations // i decide to go to the "step brightness" way. try { let hueTelegram = {}; let numStep = 10; // Steps from 0 to 100 by 10 const extendedConf = {}; if (_KNXbrightness_Direction === 0 && _KNXaction === 0) { // STOP DIM if (node.timerStepDim !== undefined) clearInterval(node.timerStepDim); node.brightnessStep = null; node.serverHue.hueManager.deleteHueQueue(node.hueDevice); // Clear dimming queue. return; } // 26/03/2024 set the extended configuration as well, because the light was off (so i've been unable to set it up elsewhere) if (node.currentHUEDevice.on !== undefined && node.currentHUEDevice.on.on === false) { // if (node.currentHUEDevice.color !== undefined) extendedConf.color = { xy: node.currentHUEDevice.color.xy }; // DO NOT ADD THE COLOR, BECAUSE THE COLOR HAS ALSO THE BRIGHTNESS, SO ALL THE DATA NEEDED TO BE SWITCHED ON CORRECLY if (node.currentHUEDevice.color_temperature !== undefined) extendedConf.color_temperature = { mirek: node.currentHUEDevice.color_temperature.mirek }; } // If i'm dimming up while the light is off, start the dim with the initial brightness set to zero if (_KNXbrightness_Direction > 0 && _KNXaction === 1 && node.currentHUEDevice.on !== undefined && node.currentHUEDevice.on.on === false) { node.brightnessStep = null; node.currentHUEDevice.dimming.brightness = 0; } // Set the actual brightness to start with if (node.brightnessStep === null || node.brightnessStep === undefined) node.brightnessStep = node.currentHUEDevice.dimming.brightness !== undefined ? node.currentHUEDevice.dimming.brightness : 50; node.brightnessStep = Math.ceil(Number(node.brightnessStep)); // We have also minDimLevelLight and maxDimLevelLight to take care of. let minDimLevelLight; if (config.minDimLevelLight === undefined) { minDimLevelLight = 10; } else if (config.minDimLevelLight === "useHueLightLevel") { minDimLevelLight = node.currentHUEDevice.dimming.min_dim_level === undefined ? 10 : node.currentHUEDevice.dimming.min_dim_level; } else { minDimLevelLight = Number(config.minDimLevelLight); } const maxDimLevelLight = config.maxDimLevelLight === undefined ? 100 : Number(config.maxDimLevelLight); // Set the speed _dimSpeedInMillisecs /= numStep; numStep = Math.round((maxDimLevelLight - minDimLevelLight) / numStep, 0); if (_KNXbrightness_Direction > 0 && _KNXaction === 1) { // DIM UP if (node.timerStepDim !== undefined) clearInterval(node.timerStepDim); node.timerStepDim = setInterval(() => { node.updateKNXBrightnessState(node.brightnessStep); // Unnecessary, but necessary to set the KNX Status in real time. node.brightnessStep += numStep; if (node.brightnessStep > maxDimLevelLight) node.brightnessStep = maxDimLevelLight; hueTelegram = { dimming: { brightness: node.brightnessStep }, dynamics: { duration: _dimSpeedInMillisecs + 500 } }; // + is to avoid ladder effect // Switch on the light if off if (node.currentHUEDevice.on !== undefined && node.currentHUEDevice.on.on === false) { hueTelegram.on = { on: true }; Object.assign(hueTelegram, extendedConf); // 26/03/2024 add extended conf } //console.log(hueTelegram) node.serverHue.hueManager.writeHueQueueAdd(node.hueDevice, hueTelegram, node.isGrouped_light === false ? "setLight" : "setGroupedLight"); if (node.brightnessStep >= maxDimLevelLight) clearInterval(node.timerStepDim); }, _dimSpeedInMillisecs); } if (_KNXbrightness_Direction > 0 && _KNXaction === 0) { if (node.currentHUEDevice.on.on === false) return; // Don't dim down, if the light is already off. // DIM DOWN if (node.timerStepDim !== undefined) clearInterval(node.timerStepDim); node.timerStepDim = setInterval(() => { node.updateKNXBrightnessState(node.brightnessStep); // Unnecessary, but necessary to set the KNX Status in real time. node.brightnessStep -= numStep; if (node.brightnessStep < minDimLevelLight) node.brightnessStep = minDimLevelLight; hueTelegram = { dimming: { brightness: node.brightnessStep }, dynamics: { duration: _dimSpeedInMillisecs + 500 } };// + 100 is to avoid ladder effect // Switch off the light if on if (node.currentHUEDevice.on !== undefined && node.currentHUEDevice.on.on === true && node.brightnessStep === 0) { hueTelegram.on = { on: false }; } node.serverHue.hueManager.writeHueQueueAdd(node.hueDevice, hueTelegram, node.isGrouped_light === false ? "setLight" : "setGroupedLight"); if (node.brightnessStep <= minDimLevelLight) clearInterval(node.timerStepDim); }, _dimSpeedInMillisecs); } } catch (error) { } }; // *************************************************