Skip to content
Open
Show file tree
Hide file tree
Changes from 1 commit
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
34 changes: 34 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,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),
Comment thread
cursor[bot] marked this conversation as resolved.
Outdated
});
};

if (colorControl.supportedFeatures.hueSaturation) {
// Listen for hue changes
colorControl.addCurrentHueAttributeListener(() => {
Expand All @@ -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,
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
59 changes: 49 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 @@ -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 || {};
Comment thread
cursor[bot] marked this conversation as resolved.

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

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