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
JavaScript
/* 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) { }
};
// *************************************************