From b478dfb91e93404540036b2c9bc73f4583b57254 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 16 Aug 2026 02:25:53 +0000 Subject: [PATCH 1/2] feat(matter): support ColorTemperature and XY modes of the ColorControl cluster Gladys' Matter integration only handled the Hue/Saturation mode of the ColorControl cluster. Bulbs that report their color through the XY (CIE 1931) mode and/or expose a color temperature (for example the IKEA KAJPLATS bulb paired through the IKEA DIRIGERA hub) only exposed ON/OFF and brightness in Gladys, while the same bulb exposes color and color temperature over Zigbee2mqtt. - Expose a light/color feature when the cluster advertises either the hueSaturation or the xy feature in its featureMap - Expose a light/temperature feature (in mireds, the unit already used by Gladys for Zigbee2mqtt and Philips Hue) when the cluster advertises the colorTemperature feature, using the physical mireds range reported by the bulb with a fallback to 153-500 - Read the initial state and subscribe to CurrentX/CurrentY and ColorTemperatureMireds attribute changes - Send MoveToColor and MoveToColorTemperature commands - Add intToXy() in server/utils/colors.js, the reverse conversion of the existing xyToInt() Bulbs that only support Hue/Saturation are unchanged. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01BRdJPgpjHkz9LKu39n8fm8 --- server/services/matter/README.md | 2 +- .../matter/lib/matter.listenToStateChange.js | 34 ++++ .../lib/matter.readInitialDeviceStates.js | 33 +++- server/services/matter/lib/matter.setValue.js | 59 +++++-- .../matter/utils/colorControlMatterMapping.js | 80 +++++++++ .../matter/utils/convertToGladysDevice.js | 40 ++++- .../matter/lib/convertToGladysDevice.test.js | 109 +++++++++++++ .../matter/lib/listenToStateChange.test.js | 81 ++++++++++ .../matter.readInitialDeviceStates.test.js | 84 ++++++++++ .../matter/lib/matter.setValue.test.js | 153 ++++++++++++++++++ .../utils/colorControlMatterMapping.test.js | 111 +++++++++++++ server/test/utils/colors.test.js | 31 ++++ server/utils/colors.js | 45 ++++++ 13 files changed, 842 insertions(+), 20 deletions(-) create mode 100644 server/services/matter/utils/colorControlMatterMapping.js create mode 100644 server/test/services/matter/utils/colorControlMatterMapping.test.js diff --git a/server/services/matter/README.md b/server/services/matter/README.md index ba20fbd61c..ee9c623b56 100644 --- a/server/services/matter/README.md +++ b/server/services/matter/README.md @@ -36,7 +36,7 @@ The **Gladys feature** column lists matching `category/type` pairs from `server/ | `Chime` | This cluster provides facilities to configure and play Chime sounds, such as those used in a doorbell. | No | siren/binary (easy) | Not explicitly handled in `server/services/matter`. | | `ClosureControl` | This cluster provides an interface for controlling a Closure. | No | shutter/position, shutter/state (easy) | Not explicitly handled in `server/services/matter`. | | `ClosureDimension` | This cluster provides an interface for controlling a single degree of freedom (also referred to as a "dimension" or an "axis" below) of a composed closure. | No | shutter/position (easy) | Not explicitly handled in `server/services/matter`. | -| `ColorControl` | Provides attributes and commands for controlling color, hue, saturation, and related lighting properties. | Yes | light/color | Color control via hue/saturation. | +| `ColorControl` | Provides attributes and commands for controlling color, hue, saturation, and related lighting properties. | Yes | light/color, light/temperature | Color control via hue/saturation or XY (CIE 1931), and color temperature in mireds. Only the modes advertised by the cluster `featureMap` are exposed. | | `CommissionerControl` | The Commissioner Control Cluster supports the ability for clients to request the commissioning of themselves or other nodes onto a fabric which the cluster server can commission onto. | No | — | Not explicitly handled in `server/services/matter`. | | `CommodityMetering` | The Commodity Metering Cluster provides the mechanism for communicating commodity consumption information within a premises. | No | energy-sensor/index (easy) | Not explicitly handled in `server/services/matter`. | | `CommodityPrice` | The Commodity Price Cluster provides the mechanism for communicating Gas, Energy, or Water pricing information within the premises. | No | — | Not explicitly handled in `server/services/matter`. | diff --git a/server/services/matter/lib/matter.listenToStateChange.js b/server/services/matter/lib/matter.listenToStateChange.js index 22b28d60c6..9d8dfe2235 100644 --- a/server/services/matter/lib/matter.listenToStateChange.js +++ b/server/services/matter/lib/matter.listenToStateChange.js @@ -30,6 +30,7 @@ const { const logger = require('../../../utils/logger'); const { matterFanModeToGladys, matterAttributeToNumber } = require('../utils/fanMatterMapping'); const { matterSystemModeToGladysAcMode } = require('../utils/thermostatMatterMapping'); +const { matterXyToInt } = require('../utils/colorControlMatterMapping'); const { hsbToRgb, rgbToInt } = require('../../../utils/colors'); const { EVENTS, STATE, BUTTON_STATUS } = require('../../../utils/constants'); const { @@ -223,6 +224,18 @@ async function listenToStateChange(nodeId, devicePath, device) { }); }; + // Function to convert the XY (CIE 1931) color to integer and emit state change + const emitXyColorState = async () => { + logger.debug(`Matter: Emitting XY color state`); + const currentX = await colorControl.getCurrentXAttribute(); + const currentY = await colorControl.getCurrentYAttribute(); + + this.gladys.event.emit(EVENTS.DEVICE.NEW_STATE, { + device_feature_external_id: `matter:${nodeId}:${devicePath}:${ColorControl.Complete.id}:color`, + state: matterXyToInt(currentX, currentY), + }); + }; + if (colorControl.supportedFeatures.hueSaturation) { // Listen for hue changes colorControl.addCurrentHueAttributeListener(() => { @@ -233,6 +246,27 @@ async function listenToStateChange(nodeId, devicePath, device) { colorControl.addCurrentSaturationAttributeListener(() => { emitColorState(); }); + } else if (colorControl.supportedFeatures.xy) { + // Listen for X changes + colorControl.addCurrentXAttributeListener(() => { + emitXyColorState(); + }); + + // Listen for Y changes + colorControl.addCurrentYAttributeListener(() => { + emitXyColorState(); + }); + } + + if (colorControl.supportedFeatures.colorTemperature) { + // Gladys stores the color temperature in mireds, which is the unit used by Matter + colorControl.addColorTemperatureMiredsAttributeListener((value) => { + logger.debug(`Matter: ColorControl colorTemperatureMireds attribute changed to ${value}`); + this.gladys.event.emit(EVENTS.DEVICE.NEW_STATE, { + device_feature_external_id: `matter:${nodeId}:${devicePath}:${ColorControl.Complete.id}:temperature`, + state: value, + }); + }); } } diff --git a/server/services/matter/lib/matter.readInitialDeviceStates.js b/server/services/matter/lib/matter.readInitialDeviceStates.js index fdfc5a00cb..4c6eb8af27 100644 --- a/server/services/matter/lib/matter.readInitialDeviceStates.js +++ b/server/services/matter/lib/matter.readInitialDeviceStates.js @@ -29,6 +29,7 @@ const { const logger = require('../../../utils/logger'); const { matterFanModeToGladys, matterAttributeToNumber } = require('../utils/fanMatterMapping'); const { matterSystemModeToGladysAcMode } = require('../utils/thermostatMatterMapping'); +const { matterXyToInt } = require('../utils/colorControlMatterMapping'); const { hsbToRgb, rgbToInt } = require('../../../utils/colors'); const { EVENTS, STATE } = require('../../../utils/constants'); const { @@ -132,14 +133,30 @@ async function readInitialDeviceStates(nodeId, devicePath, device) { } const colorControl = device.getClusterClientById(ColorControl.Complete.id); - if (colorControl && colorControl.supportedFeatures.hueSaturation) { - const currentHue = await safeReadAttribute(() => colorControl.getCurrentHueAttribute()); - const currentSaturation = await safeReadAttribute(() => colorControl.getCurrentSaturationAttribute()); - if (currentHue !== undefined && currentSaturation !== undefined) { - const hue = Math.round((currentHue / 254) * 360); - const saturation = Math.round((currentSaturation / 254) * 100); - const rgb = hsbToRgb([hue, saturation, 100]); - emitState(`matter:${nodeId}:${devicePath}:${ColorControl.Complete.id}:color`, rgbToInt(rgb)); + if (colorControl) { + if (colorControl.supportedFeatures.hueSaturation) { + const currentHue = await safeReadAttribute(() => colorControl.getCurrentHueAttribute()); + const currentSaturation = await safeReadAttribute(() => colorControl.getCurrentSaturationAttribute()); + if (currentHue !== undefined && currentSaturation !== undefined) { + const hue = Math.round((currentHue / 254) * 360); + const saturation = Math.round((currentSaturation / 254) * 100); + const rgb = hsbToRgb([hue, saturation, 100]); + emitState(`matter:${nodeId}:${devicePath}:${ColorControl.Complete.id}:color`, rgbToInt(rgb)); + } + } else if (colorControl.supportedFeatures.xy) { + const currentX = await safeReadAttribute(() => colorControl.getCurrentXAttribute()); + const currentY = await safeReadAttribute(() => colorControl.getCurrentYAttribute()); + if (currentX !== undefined && currentY !== undefined) { + emitState( + `matter:${nodeId}:${devicePath}:${ColorControl.Complete.id}:color`, + matterXyToInt(currentX, currentY), + ); + } + } + if (colorControl.supportedFeatures.colorTemperature) { + const colorTemperatureMireds = await safeReadAttribute(() => colorControl.getColorTemperatureMiredsAttribute()); + // Gladys stores the color temperature in mireds, which is the unit used by Matter + emitState(`matter:${nodeId}:${devicePath}:${ColorControl.Complete.id}:temperature`, colorTemperatureMireds); } } diff --git a/server/services/matter/lib/matter.setValue.js b/server/services/matter/lib/matter.setValue.js index cb11a9eaa3..4516863511 100644 --- a/server/services/matter/lib/matter.setValue.js +++ b/server/services/matter/lib/matter.setValue.js @@ -20,6 +20,7 @@ const { } = require('../utils/fanMatterMapping'); const { convertGladysRunModeToMatter, convertGladysCleanModeToMatter } = require('../utils/vacuumCleanerStateMapping'); const { gladysAcModeToMatterSystemMode } = require('../utils/thermostatMatterMapping'); +const { intToMatterXy } = require('../utils/colorControlMatterMapping'); /** * @description Find a device recursively through child endpoints. @@ -167,22 +168,60 @@ async function setValue(gladysDevice, gladysFeature, value) { ) { const colorControl = targetDevice.getClusterClientById(ColorControl.Complete.id); const onOff = targetDevice.getClusterClientById(OnOff.Complete.id); - const [hue, saturation] = intToHsb(value); + const colorControlFeatures = colorControl.supportedFeatures || {}; - // Convert from standard HSB ranges to Matter ranges - // Matter uses hue in range 0-254, saturation in range 0-254 - // Our HSB values are in ranges: hue (0-360), saturation (0-100), brightness (0-100) - const matterHue = Math.round((hue / 360) * 254); - const matterSaturation = Math.round((saturation / 100) * 254); + if (colorControlFeatures.hueSaturation) { + const [hue, saturation] = intToHsb(value); - await colorControl.moveToHueAndSaturation({ - hue: matterHue, - saturation: matterSaturation, + // Convert from standard HSB ranges to Matter ranges + // Matter uses hue in range 0-254, saturation in range 0-254 + // Our HSB values are in ranges: hue (0-360), saturation (0-100), brightness (0-100) + const matterHue = Math.round((hue / 360) * 254); + const matterSaturation = Math.round((saturation / 100) * 254); + + await colorControl.moveToHueAndSaturation({ + hue: matterHue, + saturation: matterSaturation, + transitionTime: 0, + optionsMask: 1, // bitmap: bit 0 = executeIfOff + optionsOverride: 1, // bitmap: bit 0 = executeIfOff + }); + } else if (colorControlFeatures.xy) { + // Bulbs that don't support Hue/Saturation report and accept their color + // through the XY (CIE 1931) mode of the ColorControl cluster + const { colorX, colorY } = intToMatterXy(value); + await colorControl.moveToColor({ + colorX, + colorY, + transitionTime: 0, + optionsMask: 1, // bitmap: bit 0 = executeIfOff + optionsOverride: 1, // bitmap: bit 0 = executeIfOff + }); + } else { + throw new Error('Device does not support any ColorControl color mode'); + } + // If the user changes the color, we needs to turn on the light + await onOff.on(); + } + + // Handle light color temperature + if ( + gladysFeature.category === DEVICE_FEATURE_CATEGORIES.LIGHT && + gladysFeature.type === DEVICE_FEATURE_TYPES.LIGHT.TEMPERATURE + ) { + const colorControl = targetDevice.getClusterClientById(ColorControl.Complete.id); + if (!colorControl) { + throw new Error('Device does not support ColorControl cluster'); + } + const onOff = targetDevice.getClusterClientById(OnOff.Complete.id); + // Gladys stores the color temperature in mireds, which is the unit used by Matter + await colorControl.moveToColorTemperature({ + colorTemperatureMireds: Math.round(value), transitionTime: 0, optionsMask: 1, // bitmap: bit 0 = executeIfOff optionsOverride: 1, // bitmap: bit 0 = executeIfOff }); - // If the user changes the color, we needs to turn on the light + // If the user changes the color temperature, we need to turn on the light await onOff.on(); } diff --git a/server/services/matter/utils/colorControlMatterMapping.js b/server/services/matter/utils/colorControlMatterMapping.js new file mode 100644 index 0000000000..f4cb14c6f1 --- /dev/null +++ b/server/services/matter/utils/colorControlMatterMapping.js @@ -0,0 +1,80 @@ +const { xyToInt, intToXy } = require('../../../utils/colors'); + +// In the Matter ColorControl cluster, CurrentX/CurrentY are uint16 attributes +// holding the CIE xyY chromaticity multiplied by 65536. +const MATTER_XY_FACTOR = 65536; +// The maximum value allowed by the Matter specification for CurrentX/CurrentY +// and for the color temperature expressed in mireds. +const MATTER_MAX_UINT16_VALUE = 65279; + +// Gladys stores the light color temperature in mireds, like Zigbee2mqtt and Philips Hue. +// Those defaults (6535K - 2000K) are used when the bulb does not advertise its physical range. +const DEFAULT_MIN_MIREDS = 153; +const DEFAULT_MAX_MIREDS = 500; + +/** + * @description Convert the Matter CurrentX/CurrentY attributes to the Gladys int color. + * @param {number} currentX - The Matter CurrentX attribute (0 - 65279). + * @param {number} currentY - The Matter CurrentY attribute (0 - 65279). + * @returns {number} The Gladys int color. + * @example + * const intColor = matterXyToInt(45914, 19615); + */ +function matterXyToInt(currentX, currentY) { + return xyToInt(currentX / MATTER_XY_FACTOR, currentY / MATTER_XY_FACTOR); +} + +/** + * @description Convert a Gladys int color to the Matter ColorX/ColorY command fields. + * @param {number} intColor - The Gladys int color (0 - 16777215). + * @returns {object} An object with the colorX and colorY Matter values. + * @example + * const { colorX, colorY } = intToMatterXy(16711680); + */ +function intToMatterXy(intColor) { + const { x, y } = intToXy(intColor); + return { + colorX: Math.min(Math.round(x * MATTER_XY_FACTOR), MATTER_MAX_UINT16_VALUE), + colorY: Math.min(Math.round(y * MATTER_XY_FACTOR), MATTER_MAX_UINT16_VALUE), + }; +} + +/** + * @description Tell if a value read from a Matter device is a usable mireds value. + * @param {any} value - The value read on the Matter device. + * @returns {boolean} True if the value can be used as a mireds bound. + * @example + * const valid = isValidMireds(153); + */ +function isValidMireds(value) { + return typeof value === 'number' && Number.isFinite(value) && value > 0 && value <= MATTER_MAX_UINT16_VALUE; +} + +/** + * @description Build the Gladys min/max mireds range from the physical range advertised by the bulb. + * @param {any} physicalMinMireds - The ColorTempPhysicalMinMireds attribute. + * @param {any} physicalMaxMireds - The ColorTempPhysicalMaxMireds attribute. + * @returns {object} An object with the min and max mireds values. + * @example + * const { min, max } = getColorTemperatureMiredsRange(153, 500); + */ +function getColorTemperatureMiredsRange(physicalMinMireds, physicalMaxMireds) { + const min = isValidMireds(physicalMinMireds) ? physicalMinMireds : DEFAULT_MIN_MIREDS; + const max = isValidMireds(physicalMaxMireds) ? physicalMaxMireds : DEFAULT_MAX_MIREDS; + // Some devices advertise an inconsistent range, in that case we fallback to the Gladys defaults + if (max <= min) { + return { min: DEFAULT_MIN_MIREDS, max: DEFAULT_MAX_MIREDS }; + } + return { min, max }; +} + +module.exports = { + matterXyToInt, + intToMatterXy, + isValidMireds, + getColorTemperatureMiredsRange, + MATTER_XY_FACTOR, + MATTER_MAX_UINT16_VALUE, + DEFAULT_MIN_MIREDS, + DEFAULT_MAX_MIREDS, +}; diff --git a/server/services/matter/utils/convertToGladysDevice.js b/server/services/matter/utils/convertToGladysDevice.js index d83a3100c0..d1dd560bf7 100644 --- a/server/services/matter/utils/convertToGladysDevice.js +++ b/server/services/matter/utils/convertToGladysDevice.js @@ -40,6 +40,22 @@ const { const { slugify } = require('../../../utils/slugify'); const { matterAttributeToNumber } = require('./fanMatterMapping'); const { getAcModeSupportedOptions } = require('./thermostatMatterMapping'); +const { getColorTemperatureMiredsRange } = require('./colorControlMatterMapping'); + +/** + * @description Read an attribute and ignore errors when the device does not expose it. + * @param {Function} readAttribute - Async function that reads the attribute. + * @returns {Promise} Attribute value or undefined. + * @example + * const value = await safeReadAttribute(() => colorControl.getColorTempPhysicalMinMiredsAttribute()); + */ +async function safeReadAttribute(readAttribute) { + try { + return await readAttribute(); + } catch (error) { + return undefined; + } +} /** * @description Build a stable Gladys selector from a Matter external_id. @@ -244,7 +260,10 @@ async function convertToGladysDevice(serviceId, nodeId, device, nodeDetailDevice max: maxLevel, }); } else if (clusterIndex === ColorControl.Complete.id) { - if (clusterClient.supportedFeatures.hueSaturation) { + const colorControlFeatures = clusterClient.supportedFeatures || {}; + // The bulb can report its color either through the Hue/Saturation mode + // or through the XY (CIE 1931) mode of the ColorControl cluster + if (colorControlFeatures.hueSaturation || colorControlFeatures.xy) { gladysDevice.features.push({ name: `${clusterClient.name} - ${clusterClient.endpointId} (Color)`, category: DEVICE_FEATURE_CATEGORIES.LIGHT, @@ -256,6 +275,25 @@ async function convertToGladysDevice(serviceId, nodeId, device, nodeDetailDevice max: 6579300, }); } + if (colorControlFeatures.colorTemperature) { + const physicalMinMireds = await safeReadAttribute(() => + clusterClient.getColorTempPhysicalMinMiredsAttribute(), + ); + const physicalMaxMireds = await safeReadAttribute(() => + clusterClient.getColorTempPhysicalMaxMiredsAttribute(), + ); + const { min, max } = getColorTemperatureMiredsRange(physicalMinMireds, physicalMaxMireds); + gladysDevice.features.push({ + name: `${clusterClient.name} - ${clusterClient.endpointId} (Color temperature)`, + category: DEVICE_FEATURE_CATEGORIES.LIGHT, + type: DEVICE_FEATURE_TYPES.LIGHT.TEMPERATURE, + read_only: false, + has_feedback: true, + external_id: `matter:${nodeId}:${devicePath}:${clusterIndex}:temperature`, + min, + max, + }); + } } else if (clusterIndex === RelativeHumidityMeasurement.Complete.id) { gladysDevice.features.push({ ...commonNewFeature, diff --git a/server/test/services/matter/lib/convertToGladysDevice.test.js b/server/test/services/matter/lib/convertToGladysDevice.test.js index 8099679864..67aaf471bf 100644 --- a/server/test/services/matter/lib/convertToGladysDevice.test.js +++ b/server/test/services/matter/lib/convertToGladysDevice.test.js @@ -9,6 +9,7 @@ const { RvcCleanMode, PowerSource, Thermostat, + ColorControl, CarbonDioxideConcentrationMeasurement, // eslint-disable-next-line import/no-unresolved } = require('@matter/main/clusters'); @@ -546,4 +547,112 @@ describe('Matter.convertToGladysDevice', () => { const modeFeatures = gladysDevice.features.filter((feature) => feature.type === 'mode'); expect(modeFeatures).to.have.lengthOf(0); }); + describe('ColorControl cluster', () => { + const buildDevice = (clusterClient) => ({ + name: 'Bulb', + number: 1, + getAllClusterClients: () => [clusterClient], + getChildEndpoints: () => [], + }); + + it('should create a color feature when the bulb supports the hue/saturation mode', async () => { + const clusterClient = { + id: ColorControl.Complete.id, + name: 'ColorControl', + endpointId: 1, + supportedFeatures: { hueSaturation: true }, + }; + + const gladysDevice = await convertToGladysDevice(serviceId, nodeId, buildDevice(clusterClient), null, '1'); + + expect(gladysDevice.features).to.have.lengthOf(1); + expect(gladysDevice.features[0]).to.deep.equal({ + name: 'ColorControl - 1 (Color)', + selector: matterExternalIdToSelector('matter:12345:1:768:color'), + category: 'light', + type: 'color', + read_only: false, + has_feedback: true, + external_id: 'matter:12345:1:768:color', + min: 0, + max: 6579300, + }); + }); + + it('should create a color feature when the bulb only supports the XY mode', async () => { + const clusterClient = { + id: ColorControl.Complete.id, + name: 'ColorControl', + endpointId: 1, + supportedFeatures: { xy: true }, + }; + + const gladysDevice = await convertToGladysDevice(serviceId, nodeId, buildDevice(clusterClient), null, '1'); + + expect(gladysDevice.features).to.have.lengthOf(1); + expect(gladysDevice.features[0]).to.deep.include({ + type: 'color', + external_id: 'matter:12345:1:768:color', + }); + }); + + it('should create a color temperature feature with the physical range of the bulb', async () => { + const clusterClient = { + id: ColorControl.Complete.id, + name: 'ColorControl', + endpointId: 1, + supportedFeatures: { xy: true, colorTemperature: true }, + getColorTempPhysicalMinMiredsAttribute: () => Promise.resolve(200), + getColorTempPhysicalMaxMiredsAttribute: () => Promise.resolve(454), + }; + + const gladysDevice = await convertToGladysDevice(serviceId, nodeId, buildDevice(clusterClient), null, '1'); + + expect(gladysDevice.features).to.have.lengthOf(2); + expect(gladysDevice.features[1]).to.deep.equal({ + name: 'ColorControl - 1 (Color temperature)', + selector: matterExternalIdToSelector('matter:12345:1:768:temperature'), + category: 'light', + type: 'temperature', + read_only: false, + has_feedback: true, + external_id: 'matter:12345:1:768:temperature', + min: 200, + max: 454, + }); + }); + + it('should fallback to the default mireds range when the bulb does not expose the physical range', async () => { + const clusterClient = { + id: ColorControl.Complete.id, + name: 'ColorControl', + endpointId: 1, + supportedFeatures: { colorTemperature: true }, + getColorTempPhysicalMinMiredsAttribute: () => Promise.reject(new Error('Attribute not supported')), + getColorTempPhysicalMaxMiredsAttribute: () => Promise.reject(new Error('Attribute not supported')), + }; + + const gladysDevice = await convertToGladysDevice(serviceId, nodeId, buildDevice(clusterClient), null, '1'); + + expect(gladysDevice.features).to.have.lengthOf(1); + expect(gladysDevice.features[0]).to.deep.include({ + type: 'temperature', + min: 153, + max: 500, + }); + }); + + it('should not create any feature when the bulb supports no color mode', async () => { + const clusterClient = { + id: ColorControl.Complete.id, + name: 'ColorControl', + endpointId: 1, + supportedFeatures: undefined, + }; + + const gladysDevice = await convertToGladysDevice(serviceId, nodeId, buildDevice(clusterClient), null, '1'); + + expect(gladysDevice.features).to.have.lengthOf(0); + }); + }); }); diff --git a/server/test/services/matter/lib/listenToStateChange.test.js b/server/test/services/matter/lib/listenToStateChange.test.js index f9d459b343..9cfe59a661 100644 --- a/server/test/services/matter/lib/listenToStateChange.test.js +++ b/server/test/services/matter/lib/listenToStateChange.test.js @@ -1002,4 +1002,85 @@ describe('Matter.listenToStateChange', () => { state: 75, }); }); + it('should listen to state change (ColorControl XY)', async () => { + let clusterClient; + const promise = new Promise((resolve) => { + let callCount = 0; + const checkThatEveryThingWasCalled = () => { + callCount += 1; + if (callCount === 4) { + resolve(); + } + }; + clusterClient = { + id: ColorControl.Complete.id, + supportedFeatures: { + xy: true, + }, + addCurrentHueAttributeListener: () => { + throw new Error('Should not be called'); + }, + addCurrentXAttributeListener: (callback) => { + callback(45915); + checkThatEveryThingWasCalled(); + }, + addCurrentYAttributeListener: (callback) => { + callback(19615); + checkThatEveryThingWasCalled(); + }, + getCurrentXAttribute: () => { + checkThatEveryThingWasCalled(); + return 45915; + }, + getCurrentYAttribute: () => { + checkThatEveryThingWasCalled(); + return 19615; + }, + }; + }); + const device = { + number: 1, + getClusterClientById: (id) => (id === clusterClient.id ? clusterClient : null), + }; + await matterHandler.listenToStateChange(1234n, '1', device); + // We need to make sure that we called all 4 functions before checking the events + await promise; + assert.calledWith(gladys.event.emit, EVENTS.DEVICE.NEW_STATE, { + device_feature_external_id: 'matter:1234:1:768:color', + state: 16711680, + }); + }); + it('should listen to state change (ColorControl color temperature)', async () => { + const clusterClient = { + id: ColorControl.Complete.id, + supportedFeatures: { + colorTemperature: true, + }, + addColorTemperatureMiredsAttributeListener: (callback) => { + callback(370); + }, + getColorTemperatureMiredsAttribute: fake.resolves(370), + }; + const device = { + number: 1, + getClusterClientById: (id) => (id === clusterClient.id ? clusterClient : null), + }; + await matterHandler.listenToStateChange(1234n, '1', device); + assert.calledWith(gladys.event.emit, EVENTS.DEVICE.NEW_STATE, { + device_feature_external_id: 'matter:1234:1:768:temperature', + state: 370, + }); + }); + it('should not listen to any color attribute when no color mode is supported', async () => { + const clusterClient = { + id: ColorControl.Complete.id, + supportedFeatures: {}, + }; + const device = { + number: 1, + getClusterClientById: (id) => (id === clusterClient.id ? clusterClient : null), + }; + await matterHandler.listenToStateChange(1234n, '1', device); + assert.notCalled(gladys.event.emit); + }); }); diff --git a/server/test/services/matter/lib/matter.readInitialDeviceStates.test.js b/server/test/services/matter/lib/matter.readInitialDeviceStates.test.js index b3a9f24372..3eef97a46b 100644 --- a/server/test/services/matter/lib/matter.readInitialDeviceStates.test.js +++ b/server/test/services/matter/lib/matter.readInitialDeviceStates.test.js @@ -671,6 +671,90 @@ describe('Matter.readInitialDeviceStates', () => { await matterHandler.readInitialDeviceStates(1234n, '1', device); + assert.notCalled(gladys.event.emit); + }); + it('should emit the color state from the XY mode', async () => { + const colorControl = { + supportedFeatures: { xy: true }, + getCurrentXAttribute: fake.resolves(45915), + getCurrentYAttribute: fake.resolves(19615), + }; + const device = { + getClusterClientById: (id) => (id === ColorControl.Complete.id ? colorControl : null), + }; + + await matterHandler.readInitialDeviceStates(1234n, '1', device); + + assert.calledOnceWithExactly(gladys.event.emit, EVENTS.DEVICE.NEW_STATE, { + device_feature_external_id: `matter:1234:1:${ColorControl.Complete.id}:color`, + state: 16711680, + }); + }); + + it('should skip the XY color when an attribute is not readable', async () => { + const colorControl = { + supportedFeatures: { xy: true }, + getCurrentXAttribute: fake.rejects(new Error('read failed')), + getCurrentYAttribute: fake.resolves(19615), + }; + const device = { + getClusterClientById: (id) => (id === ColorControl.Complete.id ? colorControl : null), + }; + + await matterHandler.readInitialDeviceStates(1234n, '1', device); + + assert.notCalled(gladys.event.emit); + }); + + it('should prefer the hue/saturation mode when both modes are supported', async () => { + const colorControl = { + supportedFeatures: { hueSaturation: true, xy: true }, + getCurrentHueAttribute: fake.resolves(100), + getCurrentSaturationAttribute: fake.resolves(40), + getCurrentXAttribute: fake.resolves(45915), + getCurrentYAttribute: fake.resolves(19615), + }; + const device = { + getClusterClientById: (id) => (id === ColorControl.Complete.id ? colorControl : null), + }; + + await matterHandler.readInitialDeviceStates(1234n, '1', device); + + assert.notCalled(colorControl.getCurrentXAttribute); + assert.calledOnceWithExactly(gladys.event.emit, EVENTS.DEVICE.NEW_STATE, { + device_feature_external_id: `matter:1234:1:${ColorControl.Complete.id}:color`, + state: 14090213, + }); + }); + + it('should emit the color temperature state in mireds', async () => { + const colorControl = { + supportedFeatures: { colorTemperature: true }, + getColorTemperatureMiredsAttribute: fake.resolves(370), + }; + const device = { + getClusterClientById: (id) => (id === ColorControl.Complete.id ? colorControl : null), + }; + + await matterHandler.readInitialDeviceStates(1234n, '1', device); + + assert.calledOnceWithExactly(gladys.event.emit, EVENTS.DEVICE.NEW_STATE, { + device_feature_external_id: `matter:1234:1:${ColorControl.Complete.id}:temperature`, + state: 370, + }); + }); + + it('should skip the color temperature when the attribute is not readable', async () => { + const colorControl = { + supportedFeatures: { colorTemperature: true }, + getColorTemperatureMiredsAttribute: fake.rejects(new Error('read failed')), + }; + const device = { + getClusterClientById: (id) => (id === ColorControl.Complete.id ? colorControl : null), + }; + + await matterHandler.readInitialDeviceStates(1234n, '1', device); + assert.notCalled(gladys.event.emit); }); }); diff --git a/server/test/services/matter/lib/matter.setValue.test.js b/server/test/services/matter/lib/matter.setValue.test.js index 8f20dbcab5..16c14399c3 100644 --- a/server/test/services/matter/lib/matter.setValue.test.js +++ b/server/test/services/matter/lib/matter.setValue.test.js @@ -453,6 +453,159 @@ describe('Matter.setValue', () => { }); assert.calledOnce(onOff.on); }); + it('should control a light color with the XY mode', async () => { + const gladysDevice = { + external_id: 'matter:12345:1', + }; + + const gladysFeature = { + category: DEVICE_FEATURE_CATEGORIES.LIGHT, + type: DEVICE_FEATURE_TYPES.LIGHT.COLOR, + }; + + const clusterClients = new Map(); + + const clusterClient = { + moveToColor: fake.resolves(null), + moveToHueAndSaturation: fake.resolves(null), + supportedFeatures: { + xy: true, + }, + }; + clusterClients.set(768, clusterClient); + const onOff = { + on: fake.resolves(null), + }; + clusterClients.set(6, onOff); + + matterHandler.nodesMap.set(12345n, { + isConnected: true, + getDevices: fake.returns([ + { + number: 1, + getClusterClientById: (id) => clusterClients.get(id), + getChildEndpoints: () => [], + }, + ]), + }); + + await matterHandler.setValue(gladysDevice, gladysFeature, 16711680); + + assert.notCalled(clusterClient.moveToHueAndSaturation); + assert.calledWith(clusterClient.moveToColor, { + colorX: 45915, + colorY: 19615, + transitionTime: 0, + optionsMask: 1, + optionsOverride: 1, + }); + assert.calledOnce(onOff.on); + }); + + it('should fail to control a light color when no color mode is supported', async () => { + const gladysDevice = { + external_id: 'matter:12345:1', + }; + + const gladysFeature = { + category: DEVICE_FEATURE_CATEGORIES.LIGHT, + type: DEVICE_FEATURE_TYPES.LIGHT.COLOR, + }; + + const clusterClients = new Map(); + clusterClients.set(768, { supportedFeatures: undefined }); + clusterClients.set(6, { on: fake.resolves(null) }); + + matterHandler.nodesMap.set(12345n, { + isConnected: true, + getDevices: fake.returns([ + { + number: 1, + getClusterClientById: (id) => clusterClients.get(id), + getChildEndpoints: () => [], + }, + ]), + }); + + await chaiAssert.isRejected( + matterHandler.setValue(gladysDevice, gladysFeature, 16711680), + 'Device does not support any ColorControl color mode', + ); + }); + + it('should control a light color temperature', async () => { + const gladysDevice = { + external_id: 'matter:12345:1', + }; + + const gladysFeature = { + category: DEVICE_FEATURE_CATEGORIES.LIGHT, + type: DEVICE_FEATURE_TYPES.LIGHT.TEMPERATURE, + }; + + const clusterClients = new Map(); + + const clusterClient = { + moveToColorTemperature: fake.resolves(null), + supportedFeatures: { + colorTemperature: true, + }, + }; + clusterClients.set(768, clusterClient); + const onOff = { + on: fake.resolves(null), + }; + clusterClients.set(6, onOff); + + matterHandler.nodesMap.set(12345n, { + isConnected: true, + getDevices: fake.returns([ + { + number: 1, + getClusterClientById: (id) => clusterClients.get(id), + getChildEndpoints: () => [], + }, + ]), + }); + + await matterHandler.setValue(gladysDevice, gladysFeature, 370.4); + + assert.calledWith(clusterClient.moveToColorTemperature, { + colorTemperatureMireds: 370, + transitionTime: 0, + optionsMask: 1, + optionsOverride: 1, + }); + assert.calledOnce(onOff.on); + }); + + it('should fail to control a light color temperature without ColorControl cluster', async () => { + const gladysDevice = { + external_id: 'matter:12345:1', + }; + + const gladysFeature = { + category: DEVICE_FEATURE_CATEGORIES.LIGHT, + type: DEVICE_FEATURE_TYPES.LIGHT.TEMPERATURE, + }; + + matterHandler.nodesMap.set(12345n, { + isConnected: true, + getDevices: fake.returns([ + { + number: 1, + getClusterClientById: () => undefined, + getChildEndpoints: () => [], + }, + ]), + }); + + await chaiAssert.isRejected( + matterHandler.setValue(gladysDevice, gladysFeature, 300), + 'Device does not support ColorControl cluster', + ); + }); + it('should control a thermostat target temperature (heating)', async () => { const gladysDevice = { external_id: 'matter:12345:1', diff --git a/server/test/services/matter/utils/colorControlMatterMapping.test.js b/server/test/services/matter/utils/colorControlMatterMapping.test.js new file mode 100644 index 0000000000..6733002c5a --- /dev/null +++ b/server/test/services/matter/utils/colorControlMatterMapping.test.js @@ -0,0 +1,111 @@ +const { expect } = require('chai'); + +const { + matterXyToInt, + intToMatterXy, + isValidMireds, + getColorTemperatureMiredsRange, + DEFAULT_MIN_MIREDS, + DEFAULT_MAX_MIREDS, +} = require('../../../../services/matter/utils/colorControlMatterMapping'); + +describe('Matter.colorControlMatterMapping', () => { + describe('matterXyToInt', () => { + it('should convert Matter XY red to the Gladys int color', () => { + // 0.7006 * 65536 = 45918, 0.2993 * 65536 = 19617 + expect(matterXyToInt(45918, 19617)).to.equal(16711680); + }); + + it('should convert Matter XY green to the Gladys int color', () => { + expect(matterXyToInt(11299, 48944)).to.equal(65280); + }); + + it('should convert Matter XY blue to the Gladys int color', () => { + expect(matterXyToInt(8880, 2613)).to.equal(255); + }); + + it('should convert Matter XY white to the Gladys int color', () => { + expect(matterXyToInt(21150, 21561)).to.equal(16777215); + }); + }); + + describe('intToMatterXy', () => { + it('should convert the Gladys int red to Matter XY', () => { + expect(intToMatterXy(16711680)).to.deep.equal({ colorX: 45915, colorY: 19615 }); + }); + + it('should convert the Gladys int green to Matter XY', () => { + expect(intToMatterXy(65280)).to.deep.equal({ colorX: 11299, colorY: 48942 }); + }); + + it('should convert the Gladys int blue to Matter XY', () => { + expect(intToMatterXy(255)).to.deep.equal({ colorX: 8880, colorY: 2613 }); + }); + + it('should convert black to 0,0', () => { + expect(intToMatterXy(0)).to.deep.equal({ colorX: 0, colorY: 0 }); + }); + + it('should be the reverse of matterXyToInt', () => { + [16711680, 65280, 255, 16777215, 14090213].forEach((intColor) => { + const { colorX, colorY } = intToMatterXy(intColor); + expect(matterXyToInt(colorX, colorY)).to.equal(intColor); + }); + }); + }); + + describe('isValidMireds', () => { + it('should accept a valid mireds value', () => { + expect(isValidMireds(250)).to.equal(true); + }); + + it('should reject a non number value', () => { + expect(isValidMireds('250')).to.equal(false); + expect(isValidMireds(null)).to.equal(false); + expect(isValidMireds(undefined)).to.equal(false); + }); + + it('should reject a non finite value', () => { + expect(isValidMireds(Number.POSITIVE_INFINITY)).to.equal(false); + expect(isValidMireds(Number.NaN)).to.equal(false); + }); + + it('should reject a value out of the Matter uint16 range', () => { + expect(isValidMireds(0)).to.equal(false); + expect(isValidMireds(-1)).to.equal(false); + expect(isValidMireds(65280)).to.equal(false); + }); + }); + + describe('getColorTemperatureMiredsRange', () => { + it('should use the physical range advertised by the bulb', () => { + expect(getColorTemperatureMiredsRange(200, 454)).to.deep.equal({ min: 200, max: 454 }); + }); + + it('should fallback to the default min when the bulb does not advertise it', () => { + expect(getColorTemperatureMiredsRange(undefined, 454)).to.deep.equal({ min: DEFAULT_MIN_MIREDS, max: 454 }); + }); + + it('should fallback to the default max when the bulb does not advertise it', () => { + expect(getColorTemperatureMiredsRange(200, undefined)).to.deep.equal({ min: 200, max: DEFAULT_MAX_MIREDS }); + }); + + it('should fallback to the default range when the bulb advertises an inconsistent range', () => { + expect(getColorTemperatureMiredsRange(500, 200)).to.deep.equal({ + min: DEFAULT_MIN_MIREDS, + max: DEFAULT_MAX_MIREDS, + }); + expect(getColorTemperatureMiredsRange(300, 300)).to.deep.equal({ + min: DEFAULT_MIN_MIREDS, + max: DEFAULT_MAX_MIREDS, + }); + }); + + it('should fallback to the default range when the bulb advertises nothing', () => { + expect(getColorTemperatureMiredsRange(null, null)).to.deep.equal({ + min: DEFAULT_MIN_MIREDS, + max: DEFAULT_MAX_MIREDS, + }); + }); + }); +}); diff --git a/server/test/utils/colors.test.js b/server/test/utils/colors.test.js index 876a138034..18a869a733 100644 --- a/server/test/utils/colors.test.js +++ b/server/test/utils/colors.test.js @@ -5,6 +5,7 @@ const { intToRgb, rgbToInt, xyToInt, + intToXy, hsbToRgb, rgbToHsb, kelvinToRGB, @@ -80,6 +81,36 @@ describe('colors', () => { }); } }); + + const intToXyTable = [ + { name: 'red', int: 16711680, x: 0.7006, y: 0.2993 }, + { name: 'lime', int: 65280, x: 0.1724, y: 0.7468 }, + { name: 'blue', int: 255, x: 0.1355, y: 0.0399 }, + { name: 'white', int: 16777215, x: 0.3227, y: 0.329 }, + { name: 'black', int: 0, x: 0, y: 0 }, + ]; + + intToXyTable.forEach(({ name, int, x, y }) => { + it(`[${name}] intToXy (${int} -> ${x}, ${y})`, () => { + const value = intToXy(int); + expect(value.x).to.be.closeTo(x, 0.001); + expect(value.y).to.be.closeTo(y, 0.001); + }); + }); + + it('intToXy should be the reverse of xyToInt for saturated colors', () => { + [16711680, 65280, 255, 16777215, 65535, 16776960].forEach((int) => { + const { x, y } = intToXy(int); + expect(xyToInt(x, y)).to.equal(int); + }); + }); + + it('intToXy should handle dark colors below the gamma correction threshold', () => { + // 0x020202 has all its channels below the 0.04045 gamma correction threshold + const { x, y } = intToXy(131586); + expect(x).to.be.closeTo(0.3227, 0.001); + expect(y).to.be.closeTo(0.329, 0.001); + }); }); describe('ColorTemperature', () => { diff --git a/server/utils/colors.js b/server/utils/colors.js index d60b861cfe..5f2f7830bf 100644 --- a/server/utils/colors.js +++ b/server/utils/colors.js @@ -215,6 +215,50 @@ function xyToInt(x, y) { return (red << 16) | (green << 8) | blue; } +/** + * @description Gamma correction applied when converting a sRGB channel to linear RGB. + * @param {number} value - Color channel between 0 and 1. + * @returns {number} GammaCorrectedValue - Linear color channel between 0 and 1. + * @example + * const value = getGammaCorrectedValue(0.5); + * console.log(value === 0.21404114048223255); + */ +function getGammaCorrectedValue(value) { + return value > 0.04045 ? ((value + 0.055) / (1.0 + 0.055)) ** 2.4 : value / 12.92; +} + +/** + * @description Converts an int color to XY color (CIE 1931 color space). + * This is the reverse conversion of `xyToInt`. + * @param {number} intColor - Color between 0 and 16777215. + * @returns {object} Object with x and y properties, both between 0 and 1. + * @example + * const { x, y } = intToXy(16711680); + * console.log(x === 0.7006); + */ +function intToXy(intColor) { + const [red, green, blue] = intToRgb(intColor); + + // Convert the sRGB values to linear RGB + const r = getGammaCorrectedValue(red / 255); + const g = getGammaCorrectedValue(green / 255); + const b = getGammaCorrectedValue(blue / 255); + + // Convert to XYZ using the Wide RGB D50 conversion (inverse matrix of the one used in xyToInt) + const X = r * 0.664511 + g * 0.154324 + b * 0.162028; + const Y = r * 0.283881 + g * 0.668433 + b * 0.047685; + const Z = r * 0.000088 + g * 0.07231 + b * 0.986039; + + const sum = X + Y + Z; + + // Black has no chromaticity, we return 0,0 to avoid a division by zero + if (sum === 0) { + return { x: 0, y: 0 }; + } + + return { x: X / sum, y: Y / sum }; +} + /** * @description Converts int color to HSB (Hue, Saturation, Brightness). * @param {number} intColor - Color between 0 and 16777215. @@ -256,6 +300,7 @@ module.exports = { intToHex, hexToInt, xyToInt, + intToXy, hsbToRgb, rgbToHsb, intToHsb, From c1eb4384e89def5368a53833759ab44d76884982 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 16 Aug 2026 05:17:11 +0000 Subject: [PATCH 2/2] fix(matter): guard the ColorControl state listeners and the color setValue path Address review feedback on the ColorControl XY / color temperature support: - the XY listener now wraps its attribute reads in a try/catch and ignores non-finite coordinates, so a bulb reporting no XY while in color temperature mode no longer emits a black color nor an unhandled rejection - the ColorTemperatureMireds listener ignores non-numeric reports, like the initial-read path already does, instead of wiping the saved state - setValue checks the presence of the ColorControl cluster before reading its supported features, so a missing cluster throws the same explicit error as the color temperature branch Co-Authored-By: Claude Autofix-Pass: 1 --- .../matter/lib/matter.listenToStateChange.js | 27 ++++++-- server/services/matter/lib/matter.setValue.js | 3 + .../matter/lib/listenToStateChange.test.js | 67 +++++++++++++++++++ .../matter/lib/matter.setValue.test.js | 27 ++++++++ 4 files changed, 118 insertions(+), 6 deletions(-) diff --git a/server/services/matter/lib/matter.listenToStateChange.js b/server/services/matter/lib/matter.listenToStateChange.js index 9d8dfe2235..0e1dcaef54 100644 --- a/server/services/matter/lib/matter.listenToStateChange.js +++ b/server/services/matter/lib/matter.listenToStateChange.js @@ -227,13 +227,24 @@ async function listenToStateChange(nodeId, devicePath, device) { // Function to convert the XY (CIE 1931) color to integer and emit state change const emitXyColorState = async () => { logger.debug(`Matter: Emitting XY color state`); - const currentX = await colorControl.getCurrentXAttribute(); - const currentY = await colorControl.getCurrentYAttribute(); + try { + const currentX = await colorControl.getCurrentXAttribute(); + const currentY = await colorControl.getCurrentYAttribute(); + + // A bulb currently in color temperature mode can report no XY coordinate at all, + // in that case we keep the last known color instead of emitting a black state + if (!Number.isFinite(currentX) || !Number.isFinite(currentY)) { + logger.debug(`Matter: Ignoring XY color state, invalid coordinates (${currentX}, ${currentY})`); + return; + } - this.gladys.event.emit(EVENTS.DEVICE.NEW_STATE, { - device_feature_external_id: `matter:${nodeId}:${devicePath}:${ColorControl.Complete.id}:color`, - state: matterXyToInt(currentX, currentY), - }); + this.gladys.event.emit(EVENTS.DEVICE.NEW_STATE, { + device_feature_external_id: `matter:${nodeId}:${devicePath}:${ColorControl.Complete.id}:color`, + state: matterXyToInt(currentX, currentY), + }); + } catch (error) { + logger.debug(`Matter: Could not read the XY color attributes: ${error.message}`); + } }; if (colorControl.supportedFeatures.hueSaturation) { @@ -262,6 +273,10 @@ async function listenToStateChange(nodeId, devicePath, device) { // Gladys stores the color temperature in mireds, which is the unit used by Matter colorControl.addColorTemperatureMiredsAttributeListener((value) => { logger.debug(`Matter: ColorControl colorTemperatureMireds attribute changed to ${value}`); + // Like on the initial read, a non-numeric value is ignored so it doesn't wipe the saved state + if (!Number.isFinite(value)) { + return; + } this.gladys.event.emit(EVENTS.DEVICE.NEW_STATE, { device_feature_external_id: `matter:${nodeId}:${devicePath}:${ColorControl.Complete.id}:temperature`, state: value, diff --git a/server/services/matter/lib/matter.setValue.js b/server/services/matter/lib/matter.setValue.js index 4516863511..db5ea4ad8f 100644 --- a/server/services/matter/lib/matter.setValue.js +++ b/server/services/matter/lib/matter.setValue.js @@ -167,6 +167,9 @@ async function setValue(gladysDevice, gladysFeature, value) { gladysFeature.type === DEVICE_FEATURE_TYPES.LIGHT.COLOR ) { const colorControl = targetDevice.getClusterClientById(ColorControl.Complete.id); + if (!colorControl) { + throw new Error('Device does not support ColorControl cluster'); + } const onOff = targetDevice.getClusterClientById(OnOff.Complete.id); const colorControlFeatures = colorControl.supportedFeatures || {}; diff --git a/server/test/services/matter/lib/listenToStateChange.test.js b/server/test/services/matter/lib/listenToStateChange.test.js index 9cfe59a661..a6e8e3dceb 100644 --- a/server/test/services/matter/lib/listenToStateChange.test.js +++ b/server/test/services/matter/lib/listenToStateChange.test.js @@ -1071,6 +1071,73 @@ describe('Matter.listenToStateChange', () => { state: 370, }); }); + it('should not emit any color state when the XY attributes cannot be read', async () => { + const clusterClient = { + id: ColorControl.Complete.id, + supportedFeatures: { + xy: true, + }, + addCurrentXAttributeListener: (callback) => { + callback(45915); + }, + addCurrentYAttributeListener: (callback) => { + callback(19615); + }, + getCurrentXAttribute: fake.rejects(new Error('Attribute not available')), + getCurrentYAttribute: fake.rejects(new Error('Attribute not available')), + }; + const device = { + number: 1, + getClusterClientById: (id) => (id === clusterClient.id ? clusterClient : null), + }; + await matterHandler.listenToStateChange(1234n, '1', device); + await new Promise((resolve) => { + setImmediate(resolve); + }); + assert.notCalled(gladys.event.emit); + }); + it('should not emit any color state when the XY attributes are not numbers', async () => { + const clusterClient = { + id: ColorControl.Complete.id, + supportedFeatures: { + xy: true, + }, + addCurrentXAttributeListener: (callback) => { + callback(null); + }, + addCurrentYAttributeListener: (callback) => { + callback(null); + }, + getCurrentXAttribute: fake.resolves(null), + getCurrentYAttribute: fake.resolves(undefined), + }; + const device = { + number: 1, + getClusterClientById: (id) => (id === clusterClient.id ? clusterClient : null), + }; + await matterHandler.listenToStateChange(1234n, '1', device); + await new Promise((resolve) => { + setImmediate(resolve); + }); + assert.notCalled(gladys.event.emit); + }); + it('should not emit any color temperature state when the value is not a number', async () => { + const clusterClient = { + id: ColorControl.Complete.id, + supportedFeatures: { + colorTemperature: true, + }, + addColorTemperatureMiredsAttributeListener: (callback) => { + callback(null); + }, + }; + const device = { + number: 1, + getClusterClientById: (id) => (id === clusterClient.id ? clusterClient : null), + }; + await matterHandler.listenToStateChange(1234n, '1', device); + assert.notCalled(gladys.event.emit); + }); it('should not listen to any color attribute when no color mode is supported', async () => { const clusterClient = { id: ColorControl.Complete.id, diff --git a/server/test/services/matter/lib/matter.setValue.test.js b/server/test/services/matter/lib/matter.setValue.test.js index 16c14399c3..5b99a2c70b 100644 --- a/server/test/services/matter/lib/matter.setValue.test.js +++ b/server/test/services/matter/lib/matter.setValue.test.js @@ -533,6 +533,33 @@ describe('Matter.setValue', () => { ); }); + it('should fail to control a light color without ColorControl cluster', async () => { + const gladysDevice = { + external_id: 'matter:12345:1', + }; + + const gladysFeature = { + category: DEVICE_FEATURE_CATEGORIES.LIGHT, + type: DEVICE_FEATURE_TYPES.LIGHT.COLOR, + }; + + matterHandler.nodesMap.set(12345n, { + isConnected: true, + getDevices: fake.returns([ + { + number: 1, + getClusterClientById: () => undefined, + getChildEndpoints: () => [], + }, + ]), + }); + + await chaiAssert.isRejected( + matterHandler.setValue(gladysDevice, gladysFeature, 16711680), + 'Device does not support ColorControl cluster', + ); + }); + it('should control a light color temperature', async () => { const gladysDevice = { external_id: 'matter:12345:1',