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..0e1dcaef54 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,29 @@ 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`); + 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), + }); + } catch (error) { + logger.debug(`Matter: Could not read the XY color attributes: ${error.message}`); + } + }; + if (colorControl.supportedFeatures.hueSaturation) { // Listen for hue changes colorControl.addCurrentHueAttributeListener(() => { @@ -233,6 +257,31 @@ 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}`); + // 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.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..db5ea4ad8f 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. @@ -166,23 +167,64 @@ 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 [hue, saturation] = intToHsb(value); + const colorControlFeatures = colorControl.supportedFeatures || {}; + + if (colorControlFeatures.hueSaturation) { + const [hue, saturation] = intToHsb(value); - // 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); + // 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, + 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..a6e8e3dceb 100644 --- a/server/test/services/matter/lib/listenToStateChange.test.js +++ b/server/test/services/matter/lib/listenToStateChange.test.js @@ -1002,4 +1002,152 @@ 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 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, + 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..5b99a2c70b 100644 --- a/server/test/services/matter/lib/matter.setValue.test.js +++ b/server/test/services/matter/lib/matter.setValue.test.js @@ -453,6 +453,186 @@ 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 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', + }; + + 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,