Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion server/services/matter/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`. |
Expand Down
49 changes: 49 additions & 0 deletions server/services/matter/lib/matter.listenToStateChange.js
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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(() => {
Expand All @@ -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,
Comment thread
cursor[bot] marked this conversation as resolved.
});
});
}
}

Expand Down
33 changes: 25 additions & 8 deletions server/services/matter/lib/matter.readInitialDeviceStates.js
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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);
}
}

Expand Down
62 changes: 52 additions & 10 deletions server/services/matter/lib/matter.setValue.js
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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 || {};
Comment thread
cursor[bot] marked this conversation as resolved.

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();
}

Expand Down
80 changes: 80 additions & 0 deletions server/services/matter/utils/colorControlMatterMapping.js
Original file line number Diff line number Diff line change
@@ -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,
};
40 changes: 39 additions & 1 deletion server/services/matter/utils/convertToGladysDevice.js
Original file line number Diff line number Diff line change
Expand Up @@ -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<any|undefined>} 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.
Expand Down Expand Up @@ -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,
Expand All @@ -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,
Expand Down
Loading
Loading