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.
1,220 lines (1,019 loc) • 39.5 kB
JavaScript
const { expect } = require("chai");
const { ColorConverter } = require("../nodes/utils/colorManipulators/hueColorConverter");
const { dptlib } = require("knxultimate");
const debugLog = (...args) => {
if (process.env.DEBUG_KNX_HUE_TEST === "1") {
console.log("[knxHueTest]", ...args);
}
};
function instantiateNode(overrides = {}) {
const hueCommands = [];
const knxTelegrams = [];
const statuses = [];
const hueQueueDeletes = [];
const sentMessages = [];
let registeredConstructor = null;
const baseConfig = {
server: "knx",
serverHue: "hue",
hueDevice: "test-light#light",
GALightSwitch: "1/1/1",
dptLightSwitch: "1.001",
GADaylightSensor: "1/1/2",
dptDaylightSensor: "1.001",
specifySwitchOnBrightness: "no",
specifySwitchOnBrightnessNightTime: "no",
enableDayNightLighting: "no",
colorAtSwitchOnDayTime: '{ "kelvin":3000, "brightness":55 }',
colorAtSwitchOnNightTime: '{ "kelvin":2700, "brightness":25 }',
};
const config = { ...baseConfig, ...overrides };
const knxServer = {
sendKNXTelegramToKNXEngine: (frame) => {
knxTelegrams.push(frame);
debugLog("KNX telegram", frame);
},
addClient: () => {},
removeClient: () => {},
};
const hueServer = {
linkStatus: "connected",
hueManager: {
writeHueQueueAdd: (id, payload, command) => {
hueCommands.push({ id, payload, command });
debugLog("Hue command", { id, command, payload });
},
deleteHueQueue: (id) => {
hueQueueDeletes.push(id);
debugLog("Hue queue cleared", id);
},
},
addClient: () => {},
removeClient: () => {},
getAllLightsBelongingToTheGroup: async () => [],
getAverageColorsXYBrightnessAndTemperature: async (lights = []) => {
if (!Array.isArray(lights) || lights.length === 0) return {};
let sumX = 0;
let sumY = 0;
let sumMirek = 0;
let sumBrightness = 0;
let countXY = 0;
let countMirek = 0;
let countBrightness = 0;
lights.forEach((light) => {
if (light?.color?.xy) {
sumX += Number(light.color.xy.x);
sumY += Number(light.color.xy.y);
countXY += 1;
}
if (light?.color_temperature?.mirek !== undefined) {
sumMirek += Number(light.color_temperature.mirek);
countMirek += 1;
}
if (light?.dimming?.brightness !== undefined) {
sumBrightness += Number(light.dimming.brightness);
countBrightness += 1;
}
});
return {
x: countXY > 0 ? sumX / countXY : undefined,
y: countXY > 0 ? sumY / countXY : undefined,
mirek: countMirek > 0 ? sumMirek / countMirek : undefined,
brightness: countBrightness > 0 ? sumBrightness / countBrightness : undefined,
};
},
};
const RED = {
nodes: {
registerType: (_name, ctor) => { registeredConstructor = ctor; },
createNode: (node) => {
node.status = (status) => {
statuses.push(status);
debugLog("Status update", status);
};
node.on = (event, handler) => {
node._handlers = node._handlers || {};
node._handlers[event] = handler;
};
node.emit = (event, ...args) => {
if (node._handlers?.[event]) node._handlers[event](...args);
};
node.context = () => ({ get: () => undefined, set: () => {} });
node.send = (msg) => {
sentMessages.push(msg);
debugLog("Node send", msg);
};
},
getNode: (id) => {
if (id === config.server) return knxServer;
if (id === config.serverHue) return hueServer;
return undefined;
},
},
util: {
cloneMessage: (msg) => JSON.parse(JSON.stringify(msg)),
},
log: {
debug: () => {},
error: () => {},
},
};
delete require.cache[require.resolve("../nodes/knxUltimateHueLight.js")];
const nodeFactory = require("../nodes/knxUltimateHueLight.js");
nodeFactory(RED);
const node = new registeredConstructor(config);
return {
node,
config,
hueCommands,
knxTelegrams,
statuses,
hueServer,
knxServer,
hueQueueDeletes,
sentMessages,
};
}
function withFakeTimers(testFn) {
const originalSetInterval = global.setInterval;
const originalClearInterval = global.clearInterval;
const activeIntervals = [];
global.setInterval = (fn, delay) => {
const handle = { fn, delay };
activeIntervals.push(handle);
return fn;
};
global.clearInterval = (handle) => {
const index = activeIntervals.findIndex((entry) => entry.fn === handle);
if (index !== -1) activeIntervals.splice(index, 1);
};
const tick = (cycles = 1) => {
for (let i = 0; i < cycles; i += 1) {
activeIntervals.slice().forEach((entry) => {
entry.fn();
});
}
};
try {
return testFn({ tick, activeIntervals });
} finally {
global.setInterval = originalSetInterval;
global.clearInterval = originalClearInterval;
}
}
function createSwitchTelegram(destination, value = 1, event = "GroupValue_Write") {
const rawValue = Buffer.isBuffer(value) ? value : Buffer.from([value]);
return {
knx: {
event,
destination,
rawValue,
},
};
}
describe("knxUltimateHueLight KNX to HUE routing", () => {
it("restores stored daytime state when no switch-on profile is configured", () => {
const { node, config, hueCommands } = instantiateNode();
node.currentHUEDevice = { color_temperature: {}, dimming: {}, on: { on: false } };
node.DayTime = true;
const storedState = {
on: { on: true },
dimming: { brightness: 70 },
color: { xy: { x: 0.35, y: 0.35 } },
color_temperature: { mirek: 250 },
};
node.HUEDeviceWhileDaytime = storedState;
node.handleSend(createSwitchTelegram(config.GALightSwitch));
expect(hueCommands).to.have.lengthOf(2);
hueCommands.forEach((command) => {
expect(command.id).to.equal("test-light");
expect(command.command).to.equal("setLight");
expect(command.payload).to.deep.equal(storedState);
});
expect(node.HUEDeviceWhileDaytime).to.equal(null);
});
it("applies daytime temperature preset when configured", () => {
const { node, config, hueCommands } = instantiateNode({
specifySwitchOnBrightness: "temperature",
});
node.currentHUEDevice = {
color_temperature: {},
dimming: { brightness: 10 },
on: { on: false },
};
node.DayTime = true;
const brightnessUpdates = [];
node.updateKNXBrightnessState = (value) => { brightnessUpdates.push(value); };
node.handleSend(createSwitchTelegram(config.GALightSwitch));
expect(hueCommands).to.have.lengthOf(1);
const command = hueCommands[0];
expect(command.payload.on).to.deep.equal({ on: true });
expect(command.payload.dimming).to.deep.equal({ brightness: config.colorAtSwitchOnDayTime.brightness });
const expectedMirek = ColorConverter.kelvinToMirek(config.colorAtSwitchOnDayTime.kelvin);
expect(command.payload.color_temperature).to.deep.equal({ mirek: expectedMirek });
expect(brightnessUpdates).to.deep.equal([config.colorAtSwitchOnDayTime.brightness]);
expect(node.currentHUEDevice.color_temperature.mirek).to.equal(expectedMirek);
expect(node.currentHUEDevice.dimming.brightness).to.equal(config.colorAtSwitchOnDayTime.brightness);
});
it("stores a clone of the current device when entering night mode", () => {
const { node, config } = instantiateNode();
node.currentHUEDevice = {
on: { on: true },
dimming: { brightness: 80 },
color_temperature: { mirek: 300 },
color: { xy: { x: 0.4, y: 0.4 } },
};
node.DayTime = true;
node.handleSend(createSwitchTelegram(config.GADaylightSensor, 0));
expect(node.DayTime).to.equal(false);
expect(node.HUEDeviceWhileDaytime).to.deep.equal(node.currentHUEDevice);
expect(node.HUEDeviceWhileDaytime).to.not.equal(node.currentHUEDevice);
});
it("ignores KNX read requests", () => {
const { node, config, hueCommands } = instantiateNode();
node.currentHUEDevice = { color_temperature: {}, dimming: {}, on: { on: false } };
node.handleSend(createSwitchTelegram(config.GALightSwitch, 1, "GroupValue_Read"));
expect(hueCommands).to.have.lengthOf(0);
});
it("applies the night color preset when enabled", () => {
const { node, config, hueCommands } = instantiateNode({
enableDayNightLighting: "yes",
colorAtSwitchOnNightTime: JSON.stringify({ red: 10, green: 40, blue: 200 }),
});
node.currentHUEDevice = {
color: {
gamut: {
red: { x: 0.7, y: 0.298 },
green: { x: 0.17, y: 0.7 },
blue: { x: 0.15, y: 0.06 },
},
xy: { x: 0.1, y: 0.1 },
},
color_temperature: { mirek: 450 },
dimming: { brightness: 25 },
on: { on: false },
};
node.DayTime = false;
node.handleSend(createSwitchTelegram(config.GALightSwitch));
expect(hueCommands).to.have.lengthOf(1);
const command = hueCommands[0];
expect(command.command).to.equal("setLight");
expect(command.payload.on).to.deep.equal({ on: true });
expect(command.payload.color.xy).to.have.keys(["x", "y"]);
expect(command.payload.color_temperature).to.equal(undefined);
const expectedCommandBrightness = ColorConverter.getBrightnessFromRGBOrHex(10, 40, 200);
expect(command.payload.dimming.brightness).to.equal(expectedCommandBrightness);
expect(node.currentHUEDevice.dimming.brightness).to.equal(Math.round(expectedCommandBrightness));
});
it("restores each grouped light using the stored night snapshot", () => {
const { node, config, hueCommands } = instantiateNode({
hueDevice: "group-1#grouped_light",
});
node.currentHUEDevice = { on: { on: false }, dimming: {}, color: {} };
node.DayTime = true;
node.HUELightsBelongingToGroupWhileDaytime = [
{
light: [
{
id: "light-a",
on: { on: true },
dimming: { brightness: 60 },
color: { xy: { x: 0.3, y: 0.3 } },
color_temperature: { mirek: 300 },
},
],
},
{
light: [
{
id: "light-b",
on: { on: false },
dimming: { brightness: 45 },
color: { xy: { x: 0.4, y: 0.4 } },
color_temperature: { mirek: null },
},
],
},
];
node.handleSend(createSwitchTelegram(config.GALightSwitch));
expect(hueCommands).to.have.lengthOf(2);
const ids = hueCommands.map((entry) => entry.id);
expect(ids).to.have.members(["light-a", "light-b"]);
const groupedState = hueCommands.find((entry) => entry.id === "light-b");
expect(groupedState.payload.color_temperature).to.equal(undefined);
expect(groupedState.command).to.equal("setLight");
expect(node.HUELightsBelongingToGroupWhileDaytime).to.equal(null);
});
it("converts KNX RGB payloads into Hue XY commands", () => {
const { node, config, hueCommands } = instantiateNode({
GALightColor: "1/1/4",
dptLightColor: "232.600",
});
node.currentHUEDevice = {
on: { on: false },
dimming: { brightness: 10 },
color: {
gamut: {
red: { x: 0.7, y: 0.3 },
green: { x: 0.17, y: 0.7 },
blue: { x: 0.15, y: 0.06 },
},
},
};
const rgbBuffer = Buffer.from([10, 40, 200]);
node.handleSend(createSwitchTelegram(config.GALightColor, rgbBuffer));
expect(hueCommands).to.have.lengthOf(1);
const command = hueCommands[0];
expect(command.payload.on).to.deep.equal({ on: true });
expect(command.payload.color.xy).to.have.keys(["x", "y"]);
expect(command.payload.dimming.brightness).to.be.greaterThan(0);
});
it("turns Hue light off when KNX RGB payload carries zero brightness", () => {
const { node, config, hueCommands } = instantiateNode({
GALightColor: "1/1/4",
dptLightColor: "232.600",
});
node.currentHUEDevice = {
on: { on: true },
dimming: { brightness: 50 },
color: { gamut: null },
};
const rgbBuffer = Buffer.from([0, 0, 0]);
node.handleSend(createSwitchTelegram(config.GALightColor, rgbBuffer));
expect(hueCommands).to.have.lengthOf(1);
const command = hueCommands[0];
expect(command.payload.on).to.deep.equal({ on: false });
expect(command.payload.dimming.brightness).to.equal(0);
});
it("clamps tunable white writes to Hue-supported kelvin range", () => {
const { node, config, hueCommands } = instantiateNode({
GALightKelvin: "1/1/6",
dptLightKelvin: "7.600",
});
const kelvinDpt = dptlib.resolve("7.600");
node.currentHUEDevice = { color_temperature: { mirek: 350 } };
node.handleSend(createSwitchTelegram(
config.GALightKelvin,
kelvinDpt.formatAPDU(7000),
));
expect(hueCommands).to.have.lengthOf(1);
let command = hueCommands[0];
const maxKelvinMirek = ColorConverter.kelvinToMirek(6535);
expect(command.payload.color_temperature.mirek).to.equal(maxKelvinMirek);
node.handleSend(createSwitchTelegram(
config.GALightKelvin,
kelvinDpt.formatAPDU(1500),
));
expect(hueCommands).to.have.lengthOf(2);
command = hueCommands[1];
const minKelvinMirek = ColorConverter.kelvinToMirek(2000);
expect(command.payload.color_temperature.mirek).to.equal(minKelvinMirek);
});
it("maps tunable white percentage writes to Hue mirek range", () => {
const { node, config, hueCommands } = instantiateNode({
GALightKelvinPercentage: "1/1/7",
dptLightKelvinPercentage: "5.001",
nameLightKelvinDIM: "preset",
});
node.currentHUEDevice = { color_temperature: { mirek: 320 } };
node.handleSend(createSwitchTelegram(
config.GALightKelvinPercentage,
Buffer.from([0]),
));
expect(hueCommands).to.have.lengthOf(1);
let command = hueCommands[0];
expect(command.payload.color_temperature.mirek).to.equal(500);
node.handleSend(createSwitchTelegram(
config.GALightKelvinPercentage,
Buffer.from([255]),
));
expect(hueCommands).to.have.lengthOf(2);
command = hueCommands[1];
expect(command.payload.color_temperature.mirek).to.equal(153);
});
it("forces Hue dimming and turns light on for positive brightness telegrams", () => {
const { node, config, hueCommands } = instantiateNode({
GALightBrightness: "1/1/3",
dptLightBrightness: "5.001",
});
node.currentHUEDevice = { on: { on: false }, dimming: { brightness: 0 } };
const fiftyPercent = Buffer.from([128]);
node.handleSend(createSwitchTelegram(config.GALightBrightness, fiftyPercent));
expect(hueCommands).to.have.lengthOf(1);
const command = hueCommands[0];
expect(command.payload.dimming).to.deep.equal({ brightness: 50 });
expect(command.payload.on).to.deep.equal({ on: true });
expect(command.command).to.equal("setLight");
});
it("forces Hue dimming off when brightness telegram is zero", () => {
const { node, config, hueCommands } = instantiateNode({
GALightBrightness: "1/1/3",
dptLightBrightness: "5.001",
});
node.currentHUEDevice = { on: { on: true }, dimming: { brightness: 80 } };
node.handleSend(createSwitchTelegram(config.GALightBrightness, Buffer.from([0])));
expect(hueCommands).to.have.lengthOf(1);
const command = hueCommands[0];
expect(command.payload.dimming).to.deep.equal({ brightness: 0 });
expect(command.payload.on).to.deep.equal({ on: false });
expect(command.command).to.equal("setLight");
});
it("ignores malformed KNX brightness payloads without emitting Hue commands", () => {
const { node, config, hueCommands, statuses } = instantiateNode({
GALightBrightness: "1/1/3",
dptLightBrightness: "5.001",
});
node.currentHUEDevice = { on: { on: true }, dimming: { brightness: 40 } };
expect(() => node.handleSend(createSwitchTelegram(config.GALightBrightness, Buffer.alloc(0)))).to.not.throw();
expect(hueCommands).to.have.lengthOf(0);
expect(statuses.some((entry) => entry.fill === "red")).to.equal(true);
});
it("drops malformed KNX color payloads and keeps previous state untouched", () => {
const { node, config, hueCommands, statuses } = instantiateNode({
GALightColor: "1/1/4",
dptLightColor: "232.600",
});
node.currentHUEDevice = {
on: { on: true },
dimming: { brightness: 55 },
color: { gamut: null },
};
const originalError = console.error;
console.error = () => {};
try {
expect(() => node.handleSend(createSwitchTelegram(config.GALightColor, Buffer.from([1, 2])))).to.not.throw();
} finally {
console.error = originalError;
}
expect(hueCommands).to.have.lengthOf(0);
expect(statuses.some((entry) => entry.fill === "red")).to.equal(true);
});
});
describe("knxUltimateHueLight HUE to KNX status updates", () => {
it("publishes KNX brightness status frames", () => {
const overrides = {
GALightBrightnessState: "1/1/10",
dptLightBrightnessState: "5.001",
};
const { node, knxTelegrams } = instantiateNode(overrides);
node.updateKNXBrightnessState(42);
expect(knxTelegrams).to.have.lengthOf(1);
expect(knxTelegrams[0]).to.deep.equal({
grpaddr: overrides.GALightBrightnessState,
payload: 42,
dpt: overrides.dptLightBrightnessState,
outputtype: "write",
nodecallerid: node.id,
});
});
it("publishes KNX on/off status frames", () => {
const overrides = {
GALightState: "1/1/11",
dptLightState: "1.001",
};
const { node, knxTelegrams } = instantiateNode(overrides);
node.updateKNXLightState(true);
expect(knxTelegrams).to.have.lengthOf(1);
expect(knxTelegrams[0]).to.deep.equal({
grpaddr: overrides.GALightState,
payload: true,
dpt: overrides.dptLightState,
outputtype: "write",
nodecallerid: node.id,
});
});
it("publishes KNX color status frames with RGB conversion", () => {
const overrides = {
GALightColorState: "1/1/12",
dptLightColorState: "232.600",
};
const { node, knxTelegrams } = instantiateNode(overrides);
node.currentHUEDevice = { dimming: { brightness: 75 } };
const xyValue = { xy: { x: 0.25, y: 0.35 } };
node.updateKNXLightColorState(xyValue);
expect(knxTelegrams).to.have.lengthOf(1);
const frame = knxTelegrams[0];
expect(frame.grpaddr).to.equal(overrides.GALightColorState);
expect(frame.dpt).to.equal(overrides.dptLightColorState);
expect(frame.outputtype).to.equal("write");
expect(frame.nodecallerid).to.equal(node.id);
const expectedRgb = ColorConverter.xyBriToRgb(
xyValue.xy.x,
xyValue.xy.y,
100,
);
expect(frame.payload).to.deep.equal({
red: expectedRgb.r,
green: expectedRgb.g,
blue: expectedRgb.b,
});
});
});
describe("knxUltimateHueLight dimming helpers", () => {
it("respects configured minimum brightness limit while dimming down", () => {
withFakeTimers(({ tick }) => {
const { node, hueCommands } = instantiateNode({
minDimLevelLight: "30",
maxDimLevelLight: "80",
});
node.currentHUEDevice = {
on: { on: true },
dimming: { brightness: 35 },
color_temperature: { mirek: 350 },
};
const brightnessUpdates = [];
node.updateKNXBrightnessState = (value) => { brightnessUpdates.push(value); };
node.hueDimming(0, 1, 1000);
tick();
expect(node.brightnessStep).to.equal(30);
expect(hueCommands).to.have.lengthOf(1);
expect(hueCommands[0].payload.dimming.brightness).to.equal(30);
expect(brightnessUpdates[0]).to.equal(35);
});
});
it("does not exceed configured maximum brightness while dimming up", () => {
withFakeTimers(({ tick }) => {
const { node, hueCommands } = instantiateNode({
minDimLevelLight: "10",
maxDimLevelLight: "50",
});
node.currentHUEDevice = {
on: { on: true },
dimming: { brightness: 48 },
color_temperature: { mirek: 350 },
};
node.hueDimming(1, 1, 1000);
tick();
expect(node.brightnessStep).to.equal(50);
expect(hueCommands).to.have.lengthOf(1);
expect(hueCommands[0].payload.dimming.brightness).to.equal(50);
});
});
it("dims up from off state and schedules Hue updates", () => {
withFakeTimers(({ tick, activeIntervals }) => {
const { node, hueCommands } = instantiateNode();
node.currentHUEDevice = {
on: { on: false },
dimming: { brightness: 0 },
color_temperature: { mirek: 370 },
};
const brightnessUpdates = [];
node.updateKNXBrightnessState = (value) => { brightnessUpdates.push(value); };
node.hueDimming(1, 1, 1000);
expect(activeIntervals).to.have.lengthOf(1);
expect(node.brightnessStep).to.equal(0);
tick();
expect(brightnessUpdates).to.deep.equal([0]);
expect(hueCommands).to.have.lengthOf(1);
const command = hueCommands[0];
expect(command.payload.dimming.brightness).to.be.greaterThan(0);
expect(command.payload.on).to.deep.equal({ on: true });
expect(command.payload.color_temperature).to.deep.equal({ mirek: 370 });
expect(node.brightnessStep).to.be.greaterThan(0);
});
});
it("clears timers and queue when dimming stop telegram arrives", () => {
withFakeTimers(({ tick, activeIntervals }) => {
const { node, hueCommands, hueQueueDeletes } = instantiateNode();
node.currentHUEDevice = {
on: { on: true },
dimming: { brightness: 60 },
};
node.hueDimming(1, 1, 1000);
tick();
hueCommands.length = 0;
node.hueDimming(0, 0, 1000);
expect(hueQueueDeletes).to.deep.equal(["test-light"]);
expect(node.brightnessStep).to.equal(null);
expect(activeIntervals).to.have.lengthOf(0);
expect(hueCommands).to.have.lengthOf(0);
});
});
it("dims HSV hue upwards, updates color state and turns light on", () => {
withFakeTimers(({ tick, activeIntervals }) => {
const { node, hueCommands, hueQueueDeletes } = instantiateNode();
node.currentHUEDevice = {
on: { on: false },
dimming: { brightness: 30 },
color: {
xy: { x: 0.3, y: 0.3 },
gamut: {
red: { x: 0.7, y: 0.3 },
green: { x: 0.17, y: 0.7 },
blue: { x: 0.15, y: 0.06 },
},
},
};
const colorUpdates = [];
node.updateKNXLightColorState = (value) => { colorUpdates.push(value.xy); };
node.hueDimmingHSV_H(1, 1, 1000);
expect(activeIntervals).to.have.lengthOf(1);
tick();
expect(hueQueueDeletes).to.include("test-light");
expect(hueCommands).to.have.lengthOf(1);
const command = hueCommands[0];
expect(command.payload.color.xy).to.have.keys(["x", "y"]);
expect(command.payload.on).to.deep.equal({ on: true });
expect(colorUpdates).to.have.lengthOf(1);
expect(node.brightnessStepHSV_H).to.be.greaterThan(30);
});
});
it("stops HSV hue dimming when stop telegram arrives", () => {
withFakeTimers(({ tick, activeIntervals }) => {
const { node, hueCommands, hueQueueDeletes } = instantiateNode();
node.currentHUEDevice = {
on: { on: true },
dimming: { brightness: 40 },
color: {
xy: { x: 0.25, y: 0.32 },
gamut: {
red: { x: 0.7, y: 0.3 },
green: { x: 0.17, y: 0.7 },
blue: { x: 0.15, y: 0.06 },
},
},
};
node.hueDimmingHSV_H(1, 1, 1000);
tick();
hueCommands.length = 0;
node.hueDimmingHSV_H(0, 0, 1000);
expect(hueQueueDeletes[hueQueueDeletes.length - 1]).to.equal("test-light");
expect(activeIntervals).to.have.lengthOf(0);
expect(hueCommands).to.have.lengthOf(0);
});
});
it("dims HSV saturation down to minimum and updates color state", () => {
withFakeTimers(({ tick }) => {
const { node, hueCommands, hueQueueDeletes } = instantiateNode();
node.currentHUEDevice = {
on: { on: true },
dimming: { brightness: 10 },
color: {
xy: { x: 0.4, y: 0.35 },
gamut: {
red: { x: 0.7, y: 0.3 },
green: { x: 0.17, y: 0.7 },
blue: { x: 0.15, y: 0.06 },
},
},
};
const colorUpdates = [];
node.updateKNXLightColorState = (value) => { colorUpdates.push(value.xy); };
node.hueDimmingHSV_S(0, 1, 1000);
tick();
expect(hueQueueDeletes).to.include("test-light");
expect(hueCommands).to.have.lengthOf(1);
const command = hueCommands[0];
expect(command.payload.color.xy).to.have.keys(["x", "y"]);
expect(command.payload.on).to.equal(undefined);
expect(colorUpdates).to.have.lengthOf(1);
expect(node.brightnessStepHSV_S).to.equal(1);
});
});
it("dims tunable white upwards and turns light on when off", () => {
withFakeTimers(({ tick, activeIntervals }) => {
const { node, hueCommands, hueQueueDeletes } = instantiateNode();
node.currentHUEDevice = {
on: { on: false },
dimming: { brightness: 40 },
color_temperature: { mirek: 300 },
};
const kelvinUpdates = [];
node.updateKNXLightKelvinPercentageState = (value) => { kelvinUpdates.push(value); };
node.hueDimmingTunableWhite(1, 1, 1000);
expect(activeIntervals).to.have.lengthOf(1);
tick();
expect(kelvinUpdates).to.deep.equal([300]);
expect(hueCommands).to.have.lengthOf(1);
const command = hueCommands[0];
expect(command.payload.color_temperature.mirek).to.be.greaterThan(300);
expect(command.payload.on).to.deep.equal({ on: true });
expect(node.brightnessStepTunableWhite).to.be.greaterThan(300);
});
});
it("inverts tunable white direction when configured", () => {
withFakeTimers(({ tick }) => {
const { node, hueCommands } = instantiateNode({ invertDimTunableWhiteDirection: true });
node.currentHUEDevice = {
on: { on: true },
dimming: { brightness: 50 },
color_temperature: { mirek: 400 },
};
node.hueDimmingTunableWhite(1, 1, 1000);
tick();
expect(hueCommands).to.have.lengthOf(1);
const command = hueCommands[0];
expect(command.payload.color_temperature.mirek).to.be.lessThan(400);
expect(node.brightnessStepTunableWhite).to.be.lessThan(400);
expect(command.payload.on).to.equal(undefined);
});
});
it("clears queue and resets tunable white step on stop", () => {
withFakeTimers(({ tick, activeIntervals }) => {
const { node, hueCommands, hueQueueDeletes } = instantiateNode();
node.currentHUEDevice = {
on: { on: true },
dimming: { brightness: 60 },
color_temperature: { mirek: 350 },
};
node.hueDimmingTunableWhite(1, 1, 1000);
tick();
hueCommands.length = 0;
node.hueDimmingTunableWhite(0, 0, 1000);
expect(hueQueueDeletes).to.include("test-light");
expect(activeIntervals).to.have.lengthOf(0);
expect(node.brightnessStepTunableWhite).to.equal(null);
expect(hueCommands).to.have.lengthOf(0);
});
});
});
describe("knxUltimateHueLight timer-driven effects", () => {
it("toggles Hue commands while blink timer is active", () => {
withFakeTimers(({ tick, activeIntervals }) => {
const { node, config, hueCommands } = instantiateNode({
GALightBlink: "1/1/8",
dptLightBlink: "1.001",
});
node.currentHUEDevice = { on: { on: false } };
node.handleSend(createSwitchTelegram(config.GALightBlink, Buffer.from([1])));
expect(activeIntervals).to.have.lengthOf(1);
tick();
tick();
expect(hueCommands).to.have.lengthOf(2);
expect(hueCommands[0].payload.on).to.deep.equal({ on: false });
expect(hueCommands[1].payload.on).to.deep.equal({ on: true });
node.handleSend(createSwitchTelegram(config.GALightBlink, Buffer.from([0])));
expect(activeIntervals).to.have.lengthOf(0);
expect(hueCommands).to.have.lengthOf(3);
expect(hueCommands[2].payload.on).to.deep.equal({ on: false });
});
});
it("cycles random colors while color cycle is running", () => {
withFakeTimers(({ tick, activeIntervals }) => {
const { node, config, hueCommands } = instantiateNode({
GALightColorCycle: "1/1/9",
dptLightColorCycle: "1.001",
});
node.currentHUEDevice = {
on: { on: false },
color: {
gamut: {
red: { x: 0.7, y: 0.3 },
green: { x: 0.17, y: 0.7 },
blue: { x: 0.15, y: 0.06 },
},
},
};
const originalRandom = Math.random;
let seed = 0;
Math.random = () => {
seed = (seed + 1) % 10;
return seed / 10;
};
try {
node.handleSend(createSwitchTelegram(config.GALightColorCycle, Buffer.from([1])));
expect(activeIntervals).to.have.lengthOf(1);
expect(hueCommands).to.have.lengthOf(1);
tick();
expect(hueCommands).to.have.lengthOf(2);
const cycleCommand = hueCommands[1];
expect(cycleCommand.payload.color.xy).to.have.keys(["x", "y"]);
node.handleSend(createSwitchTelegram(config.GALightColorCycle, Buffer.from([0])));
expect(activeIntervals).to.have.lengthOf(0);
} finally {
Math.random = originalRandom;
}
});
});
});
describe("knxUltimateHueLight handleSendHUE events", () => {
it("propagates Hue light state to KNX telemetry", async () => {
const overrides = {
GALightState: "1/1/11",
dptLightState: "1.001",
GALightBrightnessState: "1/1/10",
dptLightBrightnessState: "5.001",
GALightColorState: "1/1/12",
dptLightColorState: "232.600",
GALightKelvinState: "1/1/14",
dptLightKelvinState: "7.600",
GALightKelvinPercentageState: "1/1/15",
dptLightKelvinPercentageState: "5.001",
};
const { node, knxTelegrams, sentMessages } = instantiateNode(overrides);
node.currentHUEDevice = {
on: { on: true },
dimming: { brightness: 80 },
color: { xy: { x: 0.3, y: 0.3 } },
color_temperature: { mirek: 250, mirel_valid: true },
};
const hueEvent = {
id: node.hueDevice,
type: "light",
on: { on: false },
dimming: { brightness: 0 },
color_temperature: { mirek: 330, mirel_valid: true },
color: { xy: { x: 0.2, y: 0.25 } },
};
await node.handleSendHUE(hueEvent);
expect(sentMessages).to.have.lengthOf(1);
expect(sentMessages[0].id).to.equal(node.hueDevice);
const framesByGa = Object.fromEntries(knxTelegrams.map((frame) => [frame.grpaddr, frame]));
expect(framesByGa).to.have.property(overrides.GALightState);
expect(framesByGa[overrides.GALightState]).to.deep.include({ payload: false, dpt: overrides.dptLightState });
expect(framesByGa).to.have.property(overrides.GALightBrightnessState);
expect(framesByGa[overrides.GALightBrightnessState]).to.deep.include({ payload: 0, dpt: overrides.dptLightBrightnessState });
expect(framesByGa).to.have.property(overrides.GALightKelvinState);
const kelvinFrame = framesByGa[overrides.GALightKelvinState];
const expectedKelvin = ColorConverter.mirekToKelvin(330);
expect(kelvinFrame).to.deep.include({ payload: expectedKelvin, dpt: overrides.dptLightKelvinState });
if (overrides.GALightKelvinPercentageState && framesByGa[overrides.GALightKelvinPercentageState] !== undefined) {
expect(framesByGa[overrides.GALightKelvinPercentageState]).to.deep.include({
payload: expectedKelvin,
dpt: overrides.dptLightKelvinPercentageState,
});
}
expect(framesByGa).to.have.property(overrides.GALightColorState);
const colorFrame = framesByGa[overrides.GALightColorState];
const expectedRgb = ColorConverter.xyBriToRgb(hueEvent.color.xy.x, hueEvent.color.xy.y, 100);
expect(colorFrame.payload).to.deep.equal({
red: expectedRgb.r,
green: expectedRgb.g,
blue: expectedRgb.b,
});
expect(node.currentHUEDevice.on.on).to.equal(false);
expect(node.currentHUEDevice.dimming.brightness).to.equal(80);
expect(node.currentHUEDevice.color_temperature.mirek).to.equal(330);
});
it("averages grouped light data and updates KNX Kelvin state", async () => {
const overrides = {
hueDevice: "group-1#grouped_light",
GALightKelvinState: "2/2/1",
dptLightKelvinState: "7.600",
GALightKelvinPercentageState: "2/2/2",
dptLightKelvinPercentageState: "5.001",
};
const { node, knxTelegrams, hueServer, sentMessages } = instantiateNode(overrides);
node.currentHUEDevice = {
on: { on: true },
dimming: { brightness: 70 },
color: { xy: { x: 0.3, y: 0.3 } },
color_temperature: { mirek: 300, mirel_valid: true },
};
const groupLights = [
{
id: "lamp-1",
metadata: { name: "Lamp 1" },
color_temperature: { mirek: 300, mirel_valid: true },
dimming: { brightness: 60 },
},
{
id: "lamp-2",
metadata: { name: "Lamp 2" },
color_temperature: { mirek: 320, mirel_valid: true },
dimming: { brightness: 40 },
},
];
hueServer.getAllLightsBelongingToTheGroup = async () => groupLights;
const averageMirek = 315;
hueServer.getAverageColorsXYBrightnessAndTemperature = async () => ({ mirek: averageMirek });
const hueEvent = {
id: "lamp-1",
type: "light",
color_temperature: { mirek: 310, mirel_valid: true },
};
await node.handleSendHUE(hueEvent);
expect(sentMessages).to.have.lengthOf(1);
expect(sentMessages[0].lightName).to.equal("Lamp 1");
const expectedKelvin = ColorConverter.mirekToKelvin(averageMirek);
const framesByGa = Object.fromEntries(knxTelegrams.map((frame) => [frame.grpaddr, frame]));
expect(framesByGa).to.have.property(overrides.GALightKelvinState);
expect(framesByGa[overrides.GALightKelvinState]).to.deep.include({ payload: expectedKelvin });
if (framesByGa[overrides.GALightKelvinPercentageState] !== undefined) {
expect(framesByGa[overrides.GALightKelvinPercentageState]).to.deep.include({ payload: expectedKelvin });
}
expect(node.currentHUEDevice.color_temperature.mirek).to.equal(averageMirek);
});
it("averages grouped light colors when mirek is unavailable", async () => {
const overrides = {
hueDevice: "group-2#grouped_light",
GALightColorState: "2/3/1",
dptLightColorState: "232.600",
};
const { node, knxTelegrams, hueServer, sentMessages } = instantiateNode(overrides);
node.currentHUEDevice = {
on: { on: true },
dimming: { brightness: 65 },
color: { xy: { x: 0.28, y: 0.32 } },
color_temperature: {},
};
const groupLights = [
{
id: "lamp-a",
metadata: { name: "Lamp A" },
color: { xy: { x: 0.4, y: 0.35 } },
dimming: { brightness: 70 },
},
{
id: "lamp-b",
metadata: { name: "Lamp B" },
color: { xy: { x: 0.2, y: 0.25 } },
dimming: { brightness: 55 },
},
];
hueServer.getAllLightsBelongingToTheGroup = async () => groupLights;
hueServer.getAverageColorsXYBrightnessAndTemperature = async () => ({
x: 0.3,
y: 0.3,
brightness: 62.5,
});
const hueEvent = {
id: "lamp-a",
type: "light",
color: { xy: { x: 0.41, y: 0.34 } },
};
await node.handleSendHUE(hueEvent);
expect(sentMessages).to.have.lengthOf(1);
expect(sentMessages[0].lightName).to.equal("Lamp A");
const framesByGa = Object.fromEntries(knxTelegrams.map((frame) => [frame.grpaddr, frame]));
expect(framesByGa).to.have.property(overrides.GALightColorState);
const colorFrame = framesByGa[overrides.GALightColorState];
const expectedRgb = ColorConverter.xyBriToRgb(0.3, 0.3, 100);
expect(colorFrame.payload).to.deep.equal({
red: expectedRgb.r,
green: expectedRgb.g,
blue: expectedRgb.b,
});
expect(node.currentHUEDevice.color.xy).to.deep.equal({ x: 0.3, y: 0.3 });
});
});
describe("knxUltimateHueLight KNX read responses", () => {
it("replies with the cached light state when a read request arrives", () => {
const { node, config, knxTelegrams } = instantiateNode({
GALightState: "1/1/11",
dptLightState: "1.001",
});
node.currentHUEDevice = { on: { on: true }, dimming: { brightness: 45 } };
const readMsg = createSwitchTelegram(config.GALightState, Buffer.from([0]), "GroupValue_Read");
node.handleSend(readMsg);
expect(knxTelegrams).to.have.lengthOf(1);
expect(knxTelegrams[0]).to.deep.include({
grpaddr: config.GALightState,
payload: true,
dpt: config.dptLightState,
outputtype: "response",
});
});
it("reports KNX brightness status after malformed KNX write attempt", () => {
const { node, config, knxTelegrams, statuses } = instantiateNode({
GALightBrightness: "1/1/3",
dptLightBrightness: "5.001",
GALightBrightnessState: "1/1/13",
dptLightBrightnessState: "5.001",
});
node.currentHUEDevice = { on: { on: true }, dimming: { brightness: 60 } };
const malformed = createSwitchTelegram(config.GALightBrightness, Buffer.alloc(0));
expect(() => node.handleSend(malformed)).to.not.throw();
expect(knxTelegrams).to.have.lengthOf(0);
const lastStatus = statuses[statuses.length - 1] || {};
expect(lastStatus.fill).to.equal("red");
});
it("forwards cached color state on KNX read even after failed color write", () => {
const { node, config, knxTelegrams, statuses } = instantiateNode({
GALightColor: "1/1/4",
dptLightColor: "232.600",
GALightColorState: "1/1/12",
dptLightColorState: "232.600",
});
node.currentHUEDevice = {
on: { on: true },
dimming: { brightness: 70 },
color: { gamut: null, xy: { x: 0.2, y: 0.3 } },
};
const originalError = console.error;
console.error = () => {};
try {
const malformed = createSwitchTelegram(config.GALightColor, Buffer.from([1, 2]));
expect(() => node.handleSend(malformed)).to.not.throw();
} finally {
console.error = originalError;
}
const readMsg = createSwitchTelegram(config.GALightColorState, Buffer.alloc(3), "GroupValue_Read");
node.handleSend(readMsg);
expect(knxTelegrams).to.have.lengthOf(1);
const frame = knxTelegrams[0];
expect(frame.grpaddr).to.equal(config.GALightColorState);
expect(frame.outputtype).to.equal("response");
const expectedRgb = ColorConverter.xyBriToRgb(
node.currentHUEDevice.color.xy.x,
node.currentHUEDevice.color.xy.y,
100,
);
expect(frame.payload).to.deep.equal({
red: expectedRgb.r,
green: expectedRgb.g,
blue: expectedRgb.b,
});
expect(statuses.some((entry) => entry.fill === "red")).to.equal(true);
});
});