From df64958fb330bed72db998f5236bfb348d90778a Mon Sep 17 00:00:00 2001 From: Pierre-Gilles Leymarie <7365207+Pierre-Gilles@users.noreply.github.com> Date: Sat, 4 Apr 2026 15:44:22 +0200 Subject: [PATCH 01/10] Add Matter Vacuum Cleaner --- .../boxs/device-in-room/DeviceRow.jsx | 4 +- .../VacuumCleanerDockDeviceFeature.jsx | 45 ++++ .../sensor-value/SensorDeviceFeature.jsx | 4 +- .../VacuumCleanerStateDeviceValue.jsx | 25 ++ front/src/config/i18n/de.json | 22 +- front/src/config/i18n/en.json | 22 +- front/src/config/i18n/fr.json | 22 +- front/src/utils/consts.js | 6 + .../matter/lib/matter.listenToStateChange.js | 74 ++++++ server/services/matter/lib/matter.setValue.js | 54 ++++- .../matter/utils/convertToGladysDevice.js | 64 +++++ .../matter/utils/vacuumCleanerStateMapping.js | 129 +++++++++++ .../matter/lib/convertToGladysDevice.test.js | 166 ++++++++++++- .../matter/lib/listenToStateChange.test.js | 72 ++++++ .../matter/lib/matter.setValue.test.js | 219 ++++++++++++++++++ .../utils/vacuumCleanerStateMapping.test.js | 102 ++++++++ server/utils/constants.js | 25 ++ 17 files changed, 1048 insertions(+), 7 deletions(-) create mode 100644 front/src/components/boxs/device-in-room/device-features/VacuumCleanerDockDeviceFeature.jsx create mode 100644 front/src/components/boxs/device-in-room/device-features/sensor-value/VacuumCleanerStateDeviceValue.jsx create mode 100644 server/services/matter/utils/vacuumCleanerStateMapping.js create mode 100644 server/test/services/matter/utils/vacuumCleanerStateMapping.test.js diff --git a/front/src/components/boxs/device-in-room/DeviceRow.jsx b/front/src/components/boxs/device-in-room/DeviceRow.jsx index 7f631e4e1c..75303c641f 100644 --- a/front/src/components/boxs/device-in-room/DeviceRow.jsx +++ b/front/src/components/boxs/device-in-room/DeviceRow.jsx @@ -15,6 +15,7 @@ import AirConditioningModeDeviceFeature from './device-features/AirConditioningM import PilotWireModeDeviceFeature from './device-features/PilotWireModeDeviceFeature'; import LMHVolumeDeviceFeature from './device-features/LMHVolumeDeviceFeature'; import PushDeviceFeature from './device-features/PushDeviceFeature'; +import VacuumCleanerDockDeviceFeature from './device-features/VacuumCleanerDockDeviceFeature'; const ROW_TYPE_BY_FEATURE_TYPE = { [DEVICE_FEATURE_TYPES.LIGHT.BINARY]: BinaryDeviceFeature, @@ -44,7 +45,8 @@ const ROW_TYPE_BY_FEATURE_TYPE = { [DEVICE_FEATURE_TYPES.ELECTRICAL_VEHICLE_CLIMATE.CLIMATE_ON]: BinaryDeviceFeature, [DEVICE_FEATURE_TYPES.ELECTRICAL_VEHICLE_CLIMATE.TARGET_TEMPERATURE]: SetpointDeviceFeature, [DEVICE_FEATURE_TYPES.ELECTRICAL_VEHICLE_COMMAND.ALARM]: BinaryDeviceFeature, - [DEVICE_FEATURE_TYPES.ELECTRICAL_VEHICLE_COMMAND.LOCK]: BinaryDeviceFeature + [DEVICE_FEATURE_TYPES.ELECTRICAL_VEHICLE_COMMAND.LOCK]: BinaryDeviceFeature, + [DEVICE_FEATURE_TYPES.VACUUM_CLEANER.DOCK]: VacuumCleanerDockDeviceFeature }; const DeviceRow = ({ children, ...props }) => { diff --git a/front/src/components/boxs/device-in-room/device-features/VacuumCleanerDockDeviceFeature.jsx b/front/src/components/boxs/device-in-room/device-features/VacuumCleanerDockDeviceFeature.jsx new file mode 100644 index 0000000000..9b7a795bd5 --- /dev/null +++ b/front/src/components/boxs/device-in-room/device-features/VacuumCleanerDockDeviceFeature.jsx @@ -0,0 +1,45 @@ +import { Component } from 'preact'; +import cx from 'classnames'; +import { Text } from 'preact-i18n'; +import style from './style.css'; + +class VacuumCleanerDockDeviceFeature extends Component { + constructor(props) { + super(props); + this.state = { + loading: false + }; + } + + dock = async () => { + await this.setState({ loading: true }); + this.props.updateValue(this.props.deviceFeature, 1); + setTimeout(() => { + this.setState({ loading: false }); + }, 350); + }; + + render(props, { loading }) { + return ( + + + + + {props.rowName} + + + + + ); + } +} + +export default VacuumCleanerDockDeviceFeature; diff --git a/front/src/components/boxs/device-in-room/device-features/sensor-value/SensorDeviceFeature.jsx b/front/src/components/boxs/device-in-room/device-features/sensor-value/SensorDeviceFeature.jsx index ddc9e20eb5..4be4bd64f0 100644 --- a/front/src/components/boxs/device-in-room/device-features/sensor-value/SensorDeviceFeature.jsx +++ b/front/src/components/boxs/device-in-room/device-features/sensor-value/SensorDeviceFeature.jsx @@ -18,6 +18,7 @@ import NoRecentValueBadge from './NoRecentValueBadge'; import TemperatureSensorDeviceValue from './TemperatureSensorDeviceValue'; import LevelSensorDeviceValue from './LevelSensorDeviceValue'; import PressureSensorDeviceValue from './PressureSensorDeviceValue'; +import VacuumCleanerStateDeviceValue from './VacuumCleanerStateDeviceValue'; const DISPLAY_BY_FEATURE_CATEGORY = { [DEVICE_FEATURE_CATEGORIES.MOTION_SENSOR]: MotionSensorDeviceValue, @@ -50,7 +51,8 @@ const DISPLAY_BY_FEATURE_TYPE = { [DEVICE_FEATURE_TYPES.ELECTRICAL_VEHICLE_CLIMATE.INDOOR_TEMPERATURE]: TemperatureSensorDeviceValue, [DEVICE_FEATURE_TYPES.ELECTRICAL_VEHICLE_BATTERY.BATTERY_RANGE_ESTIMATE]: DistanceSensorDeviceValue, [DEVICE_FEATURE_TYPES.ELECTRICAL_VEHICLE_STATE.ODOMETER]: DistanceSensorDeviceValue, - [DEVICE_FEATURE_TYPES.ELECTRICAL_VEHICLE_STATE.TIRE_PRESSURE]: PressureSensorDeviceValue + [DEVICE_FEATURE_TYPES.ELECTRICAL_VEHICLE_STATE.TIRE_PRESSURE]: PressureSensorDeviceValue, + [DEVICE_FEATURE_TYPES.VACUUM_CLEANER.STATE]: VacuumCleanerStateDeviceValue }; const DEVICE_FEATURES_WITHOUT_EXPIRATION = [ diff --git a/front/src/components/boxs/device-in-room/device-features/sensor-value/VacuumCleanerStateDeviceValue.jsx b/front/src/components/boxs/device-in-room/device-features/sensor-value/VacuumCleanerStateDeviceValue.jsx new file mode 100644 index 0000000000..801c69776a --- /dev/null +++ b/front/src/components/boxs/device-in-room/device-features/sensor-value/VacuumCleanerStateDeviceValue.jsx @@ -0,0 +1,25 @@ +import { Text } from 'preact-i18n'; +import cx from 'classnames'; + +const VacuumCleanerStateDeviceValue = props => { + const { last_value: lastValue = null } = props.deviceFeature; + const valued = lastValue !== null; + + return ( + + {!valued && } + {valued && ( + + + + )} + + ); +}; + +export default VacuumCleanerStateDeviceValue; diff --git a/front/src/config/i18n/de.json b/front/src/config/i18n/de.json index 85554c0613..00f41f6557 100644 --- a/front/src/config/i18n/de.json +++ b/front/src/config/i18n/de.json @@ -314,7 +314,8 @@ "addButton": "+", "substractButton": "-", "motionDetected": "Bewegung erkannt", - "pushButton": "Schieben" + "pushButton": "Schieben", + "vacuumDock": "Zurück zur Ladestation" }, "devices": { "editDeviceFeaturesLabel": "Wähle die Geräte aus, die du anzeigen möchtest:", @@ -3577,6 +3578,18 @@ "3": "Hoch", "4": "Netz kritisch" } + }, + "vacuum-cleaner": { + "state": { + "unknown": "{{value}} (unbekannt)", + "0": "Gestoppt", + "1": "Läuft", + "2": "Pausiert", + "3": "Fehler", + "4": "Kehrt zur Ladestation zurück", + "5": "Lädt", + "6": "Angedockt" + } } } }, @@ -4042,6 +4055,13 @@ "unknown": { "shortCategoryName": "Unbekannt", "unknown": "Unbekannt" + }, + "vacuum-cleaner": { + "shortCategoryName": "Saugroboter", + "state": "Betriebszustand", + "run-mode": "Betriebsmodus", + "clean-mode": "Reinigungsmodus", + "dock": "Zurück zur Ladestation" } }, "errorPage": { diff --git a/front/src/config/i18n/en.json b/front/src/config/i18n/en.json index ab930030f8..38c7af50bc 100644 --- a/front/src/config/i18n/en.json +++ b/front/src/config/i18n/en.json @@ -314,7 +314,8 @@ "addButton": "+", "substractButton": "-", "motionDetected": "Motion detected", - "pushButton": "Push" + "pushButton": "Push", + "vacuumDock": "Return to Dock" }, "devices": { "editDeviceFeaturesLabel": "Select the devices you want to display:", @@ -3577,6 +3578,18 @@ "3": "High", "4": "Critical" } + }, + "vacuum-cleaner": { + "state": { + "unknown": "{{value}} (unknown)", + "0": "Stopped", + "1": "Running", + "2": "Paused", + "3": "Error", + "4": "Returning to Dock", + "5": "Charging", + "6": "Docked" + } } } }, @@ -4042,6 +4055,13 @@ "unknown": { "shortCategoryName": "Unknown", "unknown": "Unknown" + }, + "vacuum-cleaner": { + "shortCategoryName": "Vacuum Cleaner", + "state": "Operational State", + "run-mode": "Run Mode", + "clean-mode": "Clean Mode", + "dock": "Return to Dock" } }, "errorPage": { diff --git a/front/src/config/i18n/fr.json b/front/src/config/i18n/fr.json index bfa52ce8ab..6b658dd2fe 100644 --- a/front/src/config/i18n/fr.json +++ b/front/src/config/i18n/fr.json @@ -314,7 +314,8 @@ "addButton": "+", "substractButton": "-", "motionDetected": "Mouvement détecté", - "pushButton": "Appuyer" + "pushButton": "Appuyer", + "vacuumDock": "Retour à la base" }, "devices": { "editDeviceFeaturesLabel": "Vous pouvez modifier le nom affiché ici :", @@ -3577,6 +3578,18 @@ "3": "Élevé", "4": "Critique" } + }, + "vacuum-cleaner": { + "state": { + "unknown": "{{value}} (inconnu)", + "0": "Arrêté", + "1": "En cours", + "2": "En pause", + "3": "Erreur", + "4": "Retour à la base", + "5": "En charge", + "6": "Sur la base" + } } } }, @@ -4042,6 +4055,13 @@ "unknown": { "shortCategoryName": "Inconnu", "unknown": "Inconnu" + }, + "vacuum-cleaner": { + "shortCategoryName": "Aspirateur Robot", + "state": "État opérationnel", + "run-mode": "Mode de fonctionnement", + "clean-mode": "Mode de nettoyage", + "dock": "Retour à la base" } }, "errorPage": { diff --git a/front/src/utils/consts.js b/front/src/utils/consts.js index 0ad650621b..cf56b084cd 100644 --- a/front/src/utils/consts.js +++ b/front/src/utils/consts.js @@ -494,6 +494,12 @@ export const DeviceFeatureCategoriesIcon = { }, [DEVICE_FEATURE_CATEGORIES.HEPA_FILTER_MONITORING]: { [DEVICE_FEATURE_TYPES.FILTER_MONITORING.FILTER_LIFE_REMAINING]: 'bar-chart-2' + }, + [DEVICE_FEATURE_CATEGORIES.VACUUM_CLEANER]: { + [DEVICE_FEATURE_TYPES.VACUUM_CLEANER.STATE]: 'disc', + [DEVICE_FEATURE_TYPES.VACUUM_CLEANER.RUN_MODE]: 'settings', + [DEVICE_FEATURE_TYPES.VACUUM_CLEANER.CLEAN_MODE]: 'settings', + [DEVICE_FEATURE_TYPES.VACUUM_CLEANER.DOCK]: 'home' } }; diff --git a/server/services/matter/lib/matter.listenToStateChange.js b/server/services/matter/lib/matter.listenToStateChange.js index 99b34f395c..8f2addf527 100644 --- a/server/services/matter/lib/matter.listenToStateChange.js +++ b/server/services/matter/lib/matter.listenToStateChange.js @@ -17,12 +17,20 @@ const { ElectricalPowerMeasurement, ElectricalEnergyMeasurement, HepaFilterMonitoring, + RvcOperationalState, + RvcRunMode, + RvcCleanMode, + PowerSource, // eslint-disable-next-line import/no-unresolved } = require('@matter/main/clusters'); const logger = require('../../../utils/logger'); const { hsbToRgb, rgbToInt } = require('../../../utils/colors'); const { EVENTS, STATE, BUTTON_STATUS } = require('../../../utils/constants'); +const { + convertMatterOperationalStateToGladys, + convertMatterRunModeToGladys, +} = require('../utils/vacuumCleanerStateMapping'); /** * @description Listen to state changes of a device. @@ -408,6 +416,72 @@ async function listenToStateChange(nodeId, devicePath, device) { }); }); } + + const rvcOperationalState = device.clusterClients.get(RvcOperationalState.Complete.id); + if (rvcOperationalState && !this.stateChangeListeners.has(rvcOperationalState)) { + logger.debug(`Matter: Adding state change listener for RvcOperationalState cluster ${rvcOperationalState.name}`); + this.stateChangeListeners.add(rvcOperationalState); + // Subscribe to RvcOperationalState attribute changes + rvcOperationalState.addOperationalStateAttributeListener((value) => { + logger.debug(`Matter: RvcOperationalState attribute changed to ${value}`); + // Convert Matter state to Gladys standard state + const gladysState = convertMatterOperationalStateToGladys(value); + this.gladys.event.emit(EVENTS.DEVICE.NEW_STATE, { + device_feature_external_id: `matter:${nodeId}:${devicePath}:${RvcOperationalState.Complete.id}:state`, + state: gladysState, + }); + }); + } + + const rvcRunMode = device.clusterClients.get(RvcRunMode.Complete.id); + if (rvcRunMode && !this.stateChangeListeners.has(rvcRunMode)) { + logger.debug(`Matter: Adding state change listener for RvcRunMode cluster ${rvcRunMode.name}`); + this.stateChangeListeners.add(rvcRunMode); + // Subscribe to RvcRunMode attribute changes + rvcRunMode.addCurrentModeAttributeListener((value) => { + logger.debug(`Matter: RvcRunMode currentMode attribute changed to ${value}`); + // Convert Matter mode to Gladys standard mode + const gladysMode = convertMatterRunModeToGladys(value); + this.gladys.event.emit(EVENTS.DEVICE.NEW_STATE, { + device_feature_external_id: `matter:${nodeId}:${devicePath}:${RvcRunMode.Complete.id}`, + state: gladysMode, + }); + }); + } + + const rvcCleanMode = device.clusterClients.get(RvcCleanMode.Complete.id); + if (rvcCleanMode && !this.stateChangeListeners.has(rvcCleanMode)) { + logger.debug(`Matter: Adding state change listener for RvcCleanMode cluster ${rvcCleanMode.name}`); + this.stateChangeListeners.add(rvcCleanMode); + // Subscribe to RvcCleanMode attribute changes (clean mode uses same mapping as run mode) + rvcCleanMode.addCurrentModeAttributeListener((value) => { + logger.debug(`Matter: RvcCleanMode currentMode attribute changed to ${value}`); + // Convert Matter mode to Gladys standard mode + const gladysMode = convertMatterRunModeToGladys(value); + this.gladys.event.emit(EVENTS.DEVICE.NEW_STATE, { + device_feature_external_id: `matter:${nodeId}:${devicePath}:${RvcCleanMode.Complete.id}`, + state: gladysMode, + }); + }); + } + + const powerSource = device.clusterClients.get(PowerSource.Complete.id); + if (powerSource && !this.stateChangeListeners.has(powerSource)) { + logger.debug(`Matter: Adding state change listener for PowerSource cluster ${powerSource.name}`); + this.stateChangeListeners.add(powerSource); + // Subscribe to PowerSource battery percentage attribute changes + if (powerSource.addBatPercentRemainingAttributeListener) { + powerSource.addBatPercentRemainingAttributeListener((value) => { + logger.debug(`Matter: PowerSource batPercentRemaining attribute changed to ${value}`); + // Value is in half-percent units (0-200), convert to percent (0-100) + const batteryPercent = value !== null ? Math.round(value / 2) : null; + this.gladys.event.emit(EVENTS.DEVICE.NEW_STATE, { + device_feature_external_id: `matter:${nodeId}:${devicePath}:${PowerSource.Complete.id}:battery`, + state: batteryPercent, + }); + }); + } + } } module.exports = { diff --git a/server/services/matter/lib/matter.setValue.js b/server/services/matter/lib/matter.setValue.js index 287bb95d03..b292980542 100644 --- a/server/services/matter/lib/matter.setValue.js +++ b/server/services/matter/lib/matter.setValue.js @@ -1,8 +1,18 @@ // eslint-disable-next-line import/no-unresolved -const { OnOff, WindowCovering, LevelControl, ColorControl, Thermostat } = require('@matter/main/clusters'); +const { + OnOff, + WindowCovering, + LevelControl, + ColorControl, + Thermostat, + RvcOperationalState, + RvcRunMode, + RvcCleanMode, +} = require('@matter/main/clusters'); const { DEVICE_FEATURE_TYPES, DEVICE_FEATURE_CATEGORIES, COVER_STATE } = require('../../../utils/constants'); const { intToHsb } = require('../../../utils/colors'); const logger = require('../../../utils/logger'); +const { convertGladysRunModeToMatter } = require('../utils/vacuumCleanerStateMapping'); /** * @description Find a device recursively through child endpoints. @@ -185,6 +195,48 @@ async function setValue(gladysDevice, gladysFeature, value) { const thermostat = targetDevice.clusterClients.get(Thermostat.Complete.id); await thermostat.setOccupiedCoolingSetpointAttribute(value * 100); } + + // Handle vacuum cleaner dock command + if ( + gladysFeature.category === DEVICE_FEATURE_CATEGORIES.VACUUM_CLEANER && + gladysFeature.type === DEVICE_FEATURE_TYPES.VACUUM_CLEANER.DOCK + ) { + const rvcOperationalState = targetDevice.clusterClients.get(RvcOperationalState.Complete.id); + if (!rvcOperationalState) { + throw new Error('Device does not support RvcOperationalState cluster'); + } + if (value === 1) { + await rvcOperationalState.goHome(); + } + } + + // Handle vacuum cleaner run mode + if ( + gladysFeature.category === DEVICE_FEATURE_CATEGORIES.VACUUM_CLEANER && + gladysFeature.type === DEVICE_FEATURE_TYPES.VACUUM_CLEANER.RUN_MODE + ) { + const rvcRunMode = targetDevice.clusterClients.get(RvcRunMode.Complete.id); + if (!rvcRunMode) { + throw new Error('Device does not support RvcRunMode cluster'); + } + // Convert Gladys standard mode to Matter mode + const matterMode = convertGladysRunModeToMatter(value); + await rvcRunMode.changeToMode({ newMode: matterMode }); + } + + // Handle vacuum cleaner clean mode + if ( + gladysFeature.category === DEVICE_FEATURE_CATEGORIES.VACUUM_CLEANER && + gladysFeature.type === DEVICE_FEATURE_TYPES.VACUUM_CLEANER.CLEAN_MODE + ) { + const rvcCleanMode = targetDevice.clusterClients.get(RvcCleanMode.Complete.id); + if (!rvcCleanMode) { + throw new Error('Device does not support RvcCleanMode cluster'); + } + // Convert Gladys standard mode to Matter mode (uses same mapping as run mode) + const matterMode = convertGladysRunModeToMatter(value); + await rvcCleanMode.changeToMode({ newMode: matterMode }); + } } module.exports = { setValue }; diff --git a/server/services/matter/utils/convertToGladysDevice.js b/server/services/matter/utils/convertToGladysDevice.js index 7975f66333..f6ea28562a 100644 --- a/server/services/matter/utils/convertToGladysDevice.js +++ b/server/services/matter/utils/convertToGladysDevice.js @@ -18,6 +18,10 @@ const { ElectricalPowerMeasurement, ElectricalEnergyMeasurement, HepaFilterMonitoring, + RvcOperationalState, + RvcRunMode, + RvcCleanMode, + PowerSource, // eslint-disable-next-line import/no-unresolved } = require('@matter/main/clusters'); const Promise = require('bluebird'); @@ -414,6 +418,66 @@ async function convertToGladysDevice(serviceId, nodeId, device, nodeDetailDevice min: 0, max: 100, }); + } else if (clusterIndex === RvcOperationalState.Complete.id) { + gladysDevice.features.push({ + name: `${clusterClient.name} - ${clusterClient.endpointId} (State)`, + selector: slugify(`matter-${device.name}-${clusterClient.name}-state`, true), + category: DEVICE_FEATURE_CATEGORIES.VACUUM_CLEANER, + type: DEVICE_FEATURE_TYPES.VACUUM_CLEANER.STATE, + read_only: true, + has_feedback: true, + external_id: `matter:${nodeId}:${devicePath}:${clusterIndex}:state`, + min: 0, + max: 255, + }); + gladysDevice.features.push({ + name: `${clusterClient.name} - ${clusterClient.endpointId} (Dock)`, + selector: slugify(`matter-${device.name}-${clusterClient.name}-dock`, true), + category: DEVICE_FEATURE_CATEGORIES.VACUUM_CLEANER, + type: DEVICE_FEATURE_TYPES.VACUUM_CLEANER.DOCK, + read_only: false, + has_feedback: false, + external_id: `matter:${nodeId}:${devicePath}:${clusterIndex}:dock`, + min: 0, + max: 1, + }); + } else if (clusterIndex === RvcRunMode.Complete.id) { + gladysDevice.features.push({ + ...commonNewFeature, + category: DEVICE_FEATURE_CATEGORIES.VACUUM_CLEANER, + type: DEVICE_FEATURE_TYPES.VACUUM_CLEANER.RUN_MODE, + read_only: false, + has_feedback: true, + external_id: `matter:${nodeId}:${devicePath}:${clusterIndex}`, + min: 0, + max: 255, + }); + } else if (clusterIndex === RvcCleanMode.Complete.id) { + gladysDevice.features.push({ + ...commonNewFeature, + category: DEVICE_FEATURE_CATEGORIES.VACUUM_CLEANER, + type: DEVICE_FEATURE_TYPES.VACUUM_CLEANER.CLEAN_MODE, + read_only: false, + has_feedback: true, + external_id: `matter:${nodeId}:${devicePath}:${clusterIndex}`, + min: 0, + max: 255, + }); + } else if (clusterIndex === PowerSource.Complete.id) { + if (clusterClient.supportedFeatures && clusterClient.supportedFeatures.battery) { + gladysDevice.features.push({ + name: `${clusterClient.name} - ${clusterClient.endpointId} (Battery)`, + selector: slugify(`matter-${device.name}-${clusterClient.name}-battery`, true), + category: DEVICE_FEATURE_CATEGORIES.BATTERY, + type: DEVICE_FEATURE_TYPES.BATTERY.INTEGER, + read_only: true, + has_feedback: true, + unit: DEVICE_FEATURE_UNITS.PERCENT, + external_id: `matter:${nodeId}:${devicePath}:${clusterIndex}:battery`, + min: 0, + max: 100, + }); + } } }); } diff --git a/server/services/matter/utils/vacuumCleanerStateMapping.js b/server/services/matter/utils/vacuumCleanerStateMapping.js new file mode 100644 index 0000000000..dadfdfac6b --- /dev/null +++ b/server/services/matter/utils/vacuumCleanerStateMapping.js @@ -0,0 +1,129 @@ +const { VACUUM_CLEANER_STATE, VACUUM_CLEANER_MODE } = require('../../../utils/constants'); + +/** + * Matter RvcOperationalState values (from Matter specification). + * @see https://github.com/project-chip/connectedhomeip/blob/master/src/app/clusters/rvc-operational-state-server/rvc-operational-state-server.h + */ +const MATTER_RVC_OPERATIONAL_STATE = { + STOPPED: 0, + RUNNING: 1, + PAUSED: 2, + ERROR: 3, + SEEKING_CHARGER: 64, + CHARGING: 65, + DOCKED: 66, +}; + +/** + * Matter RvcRunMode values (from Matter specification). + */ +const MATTER_RVC_RUN_MODE = { + IDLE: 0, + CLEANING: 1, + MAPPING: 2, +}; + +/** + * @description Convert Matter RvcOperationalState to Gladys vacuum cleaner state. + * @param {number} matterState - The Matter RvcOperationalState value. + * @returns {number} The Gladys vacuum cleaner state. + * @example + * const gladysState = convertMatterOperationalStateToGladys(66); // Returns VACUUM_CLEANER_STATE.DOCKED (6) + */ +function convertMatterOperationalStateToGladys(matterState) { + switch (matterState) { + case MATTER_RVC_OPERATIONAL_STATE.STOPPED: + return VACUUM_CLEANER_STATE.STOPPED; + case MATTER_RVC_OPERATIONAL_STATE.RUNNING: + return VACUUM_CLEANER_STATE.RUNNING; + case MATTER_RVC_OPERATIONAL_STATE.PAUSED: + return VACUUM_CLEANER_STATE.PAUSED; + case MATTER_RVC_OPERATIONAL_STATE.ERROR: + return VACUUM_CLEANER_STATE.ERROR; + case MATTER_RVC_OPERATIONAL_STATE.SEEKING_CHARGER: + return VACUUM_CLEANER_STATE.RETURNING_TO_DOCK; + case MATTER_RVC_OPERATIONAL_STATE.CHARGING: + return VACUUM_CLEANER_STATE.CHARGING; + case MATTER_RVC_OPERATIONAL_STATE.DOCKED: + return VACUUM_CLEANER_STATE.DOCKED; + default: + return matterState; + } +} + +/** + * @description Convert Gladys vacuum cleaner state to Matter RvcOperationalState. + * @param {number} gladysState - The Gladys vacuum cleaner state. + * @returns {number} The Matter RvcOperationalState value. + * @example + * const matterState = convertGladysOperationalStateToMatter(6); // Returns MATTER_RVC_OPERATIONAL_STATE.DOCKED (66) + */ +function convertGladysOperationalStateToMatter(gladysState) { + switch (gladysState) { + case VACUUM_CLEANER_STATE.STOPPED: + return MATTER_RVC_OPERATIONAL_STATE.STOPPED; + case VACUUM_CLEANER_STATE.RUNNING: + return MATTER_RVC_OPERATIONAL_STATE.RUNNING; + case VACUUM_CLEANER_STATE.PAUSED: + return MATTER_RVC_OPERATIONAL_STATE.PAUSED; + case VACUUM_CLEANER_STATE.ERROR: + return MATTER_RVC_OPERATIONAL_STATE.ERROR; + case VACUUM_CLEANER_STATE.RETURNING_TO_DOCK: + return MATTER_RVC_OPERATIONAL_STATE.SEEKING_CHARGER; + case VACUUM_CLEANER_STATE.CHARGING: + return MATTER_RVC_OPERATIONAL_STATE.CHARGING; + case VACUUM_CLEANER_STATE.DOCKED: + return MATTER_RVC_OPERATIONAL_STATE.DOCKED; + default: + return gladysState; + } +} + +/** + * @description Convert Matter RvcRunMode to Gladys vacuum cleaner mode. + * @param {number} matterMode - The Matter RvcRunMode value. + * @returns {number} The Gladys vacuum cleaner mode. + * @example + * const gladysMode = convertMatterRunModeToGladys(1); // Returns VACUUM_CLEANER_MODE.CLEANING (1) + */ +function convertMatterRunModeToGladys(matterMode) { + switch (matterMode) { + case MATTER_RVC_RUN_MODE.IDLE: + return VACUUM_CLEANER_MODE.IDLE; + case MATTER_RVC_RUN_MODE.CLEANING: + return VACUUM_CLEANER_MODE.CLEANING; + case MATTER_RVC_RUN_MODE.MAPPING: + return VACUUM_CLEANER_MODE.MAPPING; + default: + return matterMode; + } +} + +/** + * @description Convert Gladys vacuum cleaner mode to Matter RvcRunMode. + * @param {number} gladysMode - The Gladys vacuum cleaner mode. + * @returns {number} The Matter RvcRunMode value. + * @example + * const matterMode = convertGladysRunModeToMatter(1); // Returns MATTER_RVC_RUN_MODE.CLEANING (1) + */ +function convertGladysRunModeToMatter(gladysMode) { + switch (gladysMode) { + case VACUUM_CLEANER_MODE.IDLE: + return MATTER_RVC_RUN_MODE.IDLE; + case VACUUM_CLEANER_MODE.CLEANING: + return MATTER_RVC_RUN_MODE.CLEANING; + case VACUUM_CLEANER_MODE.MAPPING: + return MATTER_RVC_RUN_MODE.MAPPING; + default: + return gladysMode; + } +} + +module.exports = { + MATTER_RVC_OPERATIONAL_STATE, + MATTER_RVC_RUN_MODE, + convertMatterOperationalStateToGladys, + convertGladysOperationalStateToMatter, + convertMatterRunModeToGladys, + convertGladysRunModeToMatter, +}; diff --git a/server/test/services/matter/lib/convertToGladysDevice.test.js b/server/test/services/matter/lib/convertToGladysDevice.test.js index a241489ecf..830e0c451d 100644 --- a/server/test/services/matter/lib/convertToGladysDevice.test.js +++ b/server/test/services/matter/lib/convertToGladysDevice.test.js @@ -1,6 +1,13 @@ const { expect } = require('chai'); // eslint-disable-next-line import/no-unresolved -const { BooleanState, Switch } = require('@matter/main/clusters'); +const { + BooleanState, + Switch, + RvcOperationalState, + RvcRunMode, + RvcCleanMode, + PowerSource, +} = require('@matter/main/clusters'); const { convertToGladysDevice } = require('../../../../services/matter/utils/convertToGladysDevice'); @@ -71,4 +78,161 @@ describe('Matter.convertToGladysDevice', () => { max: 84, }); }); + + it('should create vacuum cleaner features for RvcOperationalState cluster', async () => { + const clusterClients = new Map(); + clusterClients.set(RvcOperationalState.Complete.id, { + id: RvcOperationalState.Complete.id, + name: 'RvcOperationalState', + endpointId: 2, + }); + + const device = { + name: 'Robot Vacuum', + number: 2, + clusterClients, + }; + + const gladysDevice = await convertToGladysDevice(serviceId, nodeId, device, basicInformation, '1:child_endpoint:2'); + + expect(gladysDevice.features).to.have.lengthOf(2); + expect(gladysDevice.features[0]).to.deep.equal({ + name: 'RvcOperationalState - 2 (State)', + selector: gladysDevice.features[0].selector, + category: 'vacuum-cleaner', + type: 'state', + read_only: true, + has_feedback: true, + external_id: 'matter:12345:1:child_endpoint:2:97:state', + min: 0, + max: 255, + }); + expect(gladysDevice.features[1]).to.deep.equal({ + name: 'RvcOperationalState - 2 (Dock)', + selector: gladysDevice.features[1].selector, + category: 'vacuum-cleaner', + type: 'dock', + read_only: false, + has_feedback: false, + external_id: 'matter:12345:1:child_endpoint:2:97:dock', + min: 0, + max: 1, + }); + }); + + it('should create vacuum cleaner run mode feature for RvcRunMode cluster', async () => { + const clusterClients = new Map(); + clusterClients.set(RvcRunMode.Complete.id, { + id: RvcRunMode.Complete.id, + name: 'RvcRunMode', + endpointId: 2, + }); + + const device = { + name: 'Robot Vacuum', + number: 2, + clusterClients, + }; + + const gladysDevice = await convertToGladysDevice(serviceId, nodeId, device, basicInformation, '2'); + + expect(gladysDevice.features).to.have.lengthOf(1); + expect(gladysDevice.features[0]).to.deep.equal({ + name: 'RvcRunMode - 2', + selector: gladysDevice.features[0].selector, + category: 'vacuum-cleaner', + type: 'run-mode', + read_only: false, + has_feedback: true, + external_id: 'matter:12345:2:84', + min: 0, + max: 255, + }); + }); + + it('should create vacuum cleaner clean mode feature for RvcCleanMode cluster', async () => { + const clusterClients = new Map(); + clusterClients.set(RvcCleanMode.Complete.id, { + id: RvcCleanMode.Complete.id, + name: 'RvcCleanMode', + endpointId: 2, + }); + + const device = { + name: 'Robot Vacuum', + number: 2, + clusterClients, + }; + + const gladysDevice = await convertToGladysDevice(serviceId, nodeId, device, basicInformation, '2'); + + expect(gladysDevice.features).to.have.lengthOf(1); + expect(gladysDevice.features[0]).to.deep.equal({ + name: 'RvcCleanMode - 2', + selector: gladysDevice.features[0].selector, + category: 'vacuum-cleaner', + type: 'clean-mode', + read_only: false, + has_feedback: true, + external_id: 'matter:12345:2:85', + min: 0, + max: 255, + }); + }); + + it('should create battery feature for PowerSource cluster with battery support', async () => { + const clusterClients = new Map(); + clusterClients.set(PowerSource.Complete.id, { + id: PowerSource.Complete.id, + name: 'PowerSource', + endpointId: 2, + supportedFeatures: { + battery: true, + }, + }); + + const device = { + name: 'Robot Vacuum', + number: 2, + clusterClients, + }; + + const gladysDevice = await convertToGladysDevice(serviceId, nodeId, device, basicInformation, '2'); + + expect(gladysDevice.features).to.have.lengthOf(1); + expect(gladysDevice.features[0]).to.deep.equal({ + name: 'PowerSource - 2 (Battery)', + selector: gladysDevice.features[0].selector, + category: 'battery', + type: 'integer', + read_only: true, + has_feedback: true, + unit: 'percent', + external_id: 'matter:12345:2:47:battery', + min: 0, + max: 100, + }); + }); + + it('should not create battery feature for PowerSource cluster without battery support', async () => { + const clusterClients = new Map(); + clusterClients.set(PowerSource.Complete.id, { + id: PowerSource.Complete.id, + name: 'PowerSource', + endpointId: 2, + supportedFeatures: { + wired: true, + }, + }); + + const device = { + name: 'Wired Device', + number: 2, + clusterClients, + }; + + const gladysDevice = await convertToGladysDevice(serviceId, nodeId, device, basicInformation, '2'); + + 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 2e95802e47..7bc4baa500 100644 --- a/server/test/services/matter/lib/listenToStateChange.test.js +++ b/server/test/services/matter/lib/listenToStateChange.test.js @@ -17,6 +17,10 @@ const { ElectricalPowerMeasurement, ElectricalEnergyMeasurement, HepaFilterMonitoring, + RvcOperationalState, + RvcRunMode, + RvcCleanMode, + PowerSource, // eslint-disable-next-line import/no-unresolved } = require('@matter/main/clusters'); @@ -530,4 +534,72 @@ describe('Matter.listenToStateChange', () => { state: 75, }); }); + it('should listen to state change (RvcOperationalState)', async () => { + const clusterClients = new Map(); + clusterClients.set(RvcOperationalState.Complete.id, { + addOperationalStateAttributeListener: (callback) => { + callback(66); // Matter DOCKED state (66) should be converted to Gladys DOCKED state (6) + }, + }); + const device = { + number: 2, + clusterClients, + }; + await matterHandler.listenToStateChange(1234n, '1:child_endpoint:2', device); + assert.calledWith(gladys.event.emit, EVENTS.DEVICE.NEW_STATE, { + device_feature_external_id: 'matter:1234:1:child_endpoint:2:97:state', + state: 6, // Gladys standard DOCKED state + }); + }); + it('should listen to state change (RvcRunMode)', async () => { + const clusterClients = new Map(); + clusterClients.set(RvcRunMode.Complete.id, { + addCurrentModeAttributeListener: (callback) => { + callback(1); // Cleaning mode + }, + }); + const device = { + number: 2, + clusterClients, + }; + await matterHandler.listenToStateChange(1234n, '2', device); + assert.calledWith(gladys.event.emit, EVENTS.DEVICE.NEW_STATE, { + device_feature_external_id: 'matter:1234:2:84', + state: 1, + }); + }); + it('should listen to state change (RvcCleanMode)', async () => { + const clusterClients = new Map(); + clusterClients.set(RvcCleanMode.Complete.id, { + addCurrentModeAttributeListener: (callback) => { + callback(2); // Deep clean mode + }, + }); + const device = { + number: 2, + clusterClients, + }; + await matterHandler.listenToStateChange(1234n, '2', device); + assert.calledWith(gladys.event.emit, EVENTS.DEVICE.NEW_STATE, { + device_feature_external_id: 'matter:1234:2:85', + state: 2, + }); + }); + it('should listen to state change (PowerSource battery)', async () => { + const clusterClients = new Map(); + clusterClients.set(PowerSource.Complete.id, { + addBatPercentRemainingAttributeListener: (callback) => { + callback(150); // 150 half-percent = 75% + }, + }); + const device = { + number: 2, + clusterClients, + }; + await matterHandler.listenToStateChange(1234n, '2', device); + assert.calledWith(gladys.event.emit, EVENTS.DEVICE.NEW_STATE, { + device_feature_external_id: 'matter:1234:2:47:battery', + state: 75, + }); + }); }); diff --git a/server/test/services/matter/lib/matter.setValue.test.js b/server/test/services/matter/lib/matter.setValue.test.js index 8d18617404..cb958d0e60 100644 --- a/server/test/services/matter/lib/matter.setValue.test.js +++ b/server/test/services/matter/lib/matter.setValue.test.js @@ -550,4 +550,223 @@ describe('Matter.setValue', () => { const promise = matterHandler.setValue(gladysDevice, gladysFeature, value); await chaiAssert.isRejected(promise, 'Device not found for path 1:child_endpoint:2'); }); + it('should send vacuum cleaner to dock', async () => { + const gladysDevice = { + external_id: 'matter:12345:1:child_endpoint:2', + }; + + const gladysFeature = { + category: DEVICE_FEATURE_CATEGORIES.VACUUM_CLEANER, + type: DEVICE_FEATURE_TYPES.VACUUM_CLEANER.DOCK, + }; + + const value = 1; + + const clusterClients = new Map(); + + const rvcOperationalStateCluster = { + goHome: fake.resolves(null), + }; + clusterClients.set(97, rvcOperationalStateCluster); + + matterHandler.nodesMap.set(12345n, { + isConnected: true, + getDevices: fake.returns([ + { + number: 1, + childEndpoints: [ + { + number: 2, + clusterClients, + }, + ], + }, + ]), + }); + + await matterHandler.setValue(gladysDevice, gladysFeature, value); + assert.calledOnce(rvcOperationalStateCluster.goHome); + }); + it('should not call goHome when dock value is 0', async () => { + const gladysDevice = { + external_id: 'matter:12345:1:child_endpoint:2', + }; + + const gladysFeature = { + category: DEVICE_FEATURE_CATEGORIES.VACUUM_CLEANER, + type: DEVICE_FEATURE_TYPES.VACUUM_CLEANER.DOCK, + }; + + const value = 0; + + const clusterClients = new Map(); + + const rvcOperationalStateCluster = { + goHome: fake.resolves(null), + }; + clusterClients.set(97, rvcOperationalStateCluster); + + matterHandler.nodesMap.set(12345n, { + isConnected: true, + getDevices: fake.returns([ + { + number: 1, + childEndpoints: [ + { + number: 2, + clusterClients, + }, + ], + }, + ]), + }); + + await matterHandler.setValue(gladysDevice, gladysFeature, value); + assert.notCalled(rvcOperationalStateCluster.goHome); + }); + it('should change vacuum cleaner run mode', async () => { + const gladysDevice = { + external_id: 'matter:12345:2', + }; + + const gladysFeature = { + category: DEVICE_FEATURE_CATEGORIES.VACUUM_CLEANER, + type: DEVICE_FEATURE_TYPES.VACUUM_CLEANER.RUN_MODE, + }; + + const value = 1; + + const clusterClients = new Map(); + + const rvcRunModeCluster = { + changeToMode: fake.resolves(null), + }; + clusterClients.set(84, rvcRunModeCluster); + + matterHandler.nodesMap.set(12345n, { + isConnected: true, + getDevices: fake.returns([ + { + number: 2, + clusterClients, + }, + ]), + }); + + await matterHandler.setValue(gladysDevice, gladysFeature, value); + assert.calledOnceWithExactly(rvcRunModeCluster.changeToMode, { newMode: 1 }); + }); + it('should change vacuum cleaner clean mode', async () => { + const gladysDevice = { + external_id: 'matter:12345:2', + }; + + const gladysFeature = { + category: DEVICE_FEATURE_CATEGORIES.VACUUM_CLEANER, + type: DEVICE_FEATURE_TYPES.VACUUM_CLEANER.CLEAN_MODE, + }; + + const value = 2; + + const clusterClients = new Map(); + + const rvcCleanModeCluster = { + changeToMode: fake.resolves(null), + }; + clusterClients.set(85, rvcCleanModeCluster); + + matterHandler.nodesMap.set(12345n, { + isConnected: true, + getDevices: fake.returns([ + { + number: 2, + clusterClients, + }, + ]), + }); + + await matterHandler.setValue(gladysDevice, gladysFeature, value); + assert.calledOnceWithExactly(rvcCleanModeCluster.changeToMode, { newMode: 2 }); + }); + it('should throw error when RvcOperationalState cluster is not available for dock', async () => { + const gladysDevice = { + external_id: 'matter:12345:2', + }; + + const gladysFeature = { + category: DEVICE_FEATURE_CATEGORIES.VACUUM_CLEANER, + type: DEVICE_FEATURE_TYPES.VACUUM_CLEANER.DOCK, + }; + + const value = 1; + + const clusterClients = new Map(); + + matterHandler.nodesMap.set(12345n, { + isConnected: true, + getDevices: fake.returns([ + { + number: 2, + clusterClients, + }, + ]), + }); + + const promise = matterHandler.setValue(gladysDevice, gladysFeature, value); + await chaiAssert.isRejected(promise, 'Device does not support RvcOperationalState cluster'); + }); + it('should throw error when RvcRunMode cluster is not available', async () => { + const gladysDevice = { + external_id: 'matter:12345:2', + }; + + const gladysFeature = { + category: DEVICE_FEATURE_CATEGORIES.VACUUM_CLEANER, + type: DEVICE_FEATURE_TYPES.VACUUM_CLEANER.RUN_MODE, + }; + + const value = 1; + + const clusterClients = new Map(); + + matterHandler.nodesMap.set(12345n, { + isConnected: true, + getDevices: fake.returns([ + { + number: 2, + clusterClients, + }, + ]), + }); + + const promise = matterHandler.setValue(gladysDevice, gladysFeature, value); + await chaiAssert.isRejected(promise, 'Device does not support RvcRunMode cluster'); + }); + it('should throw error when RvcCleanMode cluster is not available', async () => { + const gladysDevice = { + external_id: 'matter:12345:2', + }; + + const gladysFeature = { + category: DEVICE_FEATURE_CATEGORIES.VACUUM_CLEANER, + type: DEVICE_FEATURE_TYPES.VACUUM_CLEANER.CLEAN_MODE, + }; + + const value = 1; + + const clusterClients = new Map(); + + matterHandler.nodesMap.set(12345n, { + isConnected: true, + getDevices: fake.returns([ + { + number: 2, + clusterClients, + }, + ]), + }); + + const promise = matterHandler.setValue(gladysDevice, gladysFeature, value); + await chaiAssert.isRejected(promise, 'Device does not support RvcCleanMode cluster'); + }); }); diff --git a/server/test/services/matter/utils/vacuumCleanerStateMapping.test.js b/server/test/services/matter/utils/vacuumCleanerStateMapping.test.js new file mode 100644 index 0000000000..c00f879790 --- /dev/null +++ b/server/test/services/matter/utils/vacuumCleanerStateMapping.test.js @@ -0,0 +1,102 @@ +const { expect } = require('chai'); + +const { + MATTER_RVC_OPERATIONAL_STATE, + MATTER_RVC_RUN_MODE, + convertMatterOperationalStateToGladys, + convertGladysOperationalStateToMatter, + convertMatterRunModeToGladys, + convertGladysRunModeToMatter, +} = require('../../../../services/matter/utils/vacuumCleanerStateMapping'); + +const { VACUUM_CLEANER_STATE, VACUUM_CLEANER_MODE } = require('../../../../utils/constants'); + +describe('Matter.vacuumCleanerStateMapping', () => { + describe('convertMatterOperationalStateToGladys', () => { + it('should convert Matter STOPPED to Gladys STOPPED', () => { + expect(convertMatterOperationalStateToGladys(MATTER_RVC_OPERATIONAL_STATE.STOPPED)).to.equal(VACUUM_CLEANER_STATE.STOPPED); + }); + + it('should convert Matter RUNNING to Gladys RUNNING', () => { + expect(convertMatterOperationalStateToGladys(MATTER_RVC_OPERATIONAL_STATE.RUNNING)).to.equal(VACUUM_CLEANER_STATE.RUNNING); + }); + + it('should convert Matter PAUSED to Gladys PAUSED', () => { + expect(convertMatterOperationalStateToGladys(MATTER_RVC_OPERATIONAL_STATE.PAUSED)).to.equal(VACUUM_CLEANER_STATE.PAUSED); + }); + + it('should convert Matter ERROR to Gladys ERROR', () => { + expect(convertMatterOperationalStateToGladys(MATTER_RVC_OPERATIONAL_STATE.ERROR)).to.equal(VACUUM_CLEANER_STATE.ERROR); + }); + + it('should convert Matter SEEKING_CHARGER (64) to Gladys RETURNING_TO_DOCK (4)', () => { + expect(convertMatterOperationalStateToGladys(MATTER_RVC_OPERATIONAL_STATE.SEEKING_CHARGER)).to.equal(VACUUM_CLEANER_STATE.RETURNING_TO_DOCK); + }); + + it('should convert Matter CHARGING (65) to Gladys CHARGING (5)', () => { + expect(convertMatterOperationalStateToGladys(MATTER_RVC_OPERATIONAL_STATE.CHARGING)).to.equal(VACUUM_CLEANER_STATE.CHARGING); + }); + + it('should convert Matter DOCKED (66) to Gladys DOCKED (6)', () => { + expect(convertMatterOperationalStateToGladys(MATTER_RVC_OPERATIONAL_STATE.DOCKED)).to.equal(VACUUM_CLEANER_STATE.DOCKED); + }); + + it('should return unknown values as-is', () => { + expect(convertMatterOperationalStateToGladys(99)).to.equal(99); + }); + }); + + describe('convertGladysOperationalStateToMatter', () => { + it('should convert Gladys STOPPED to Matter STOPPED', () => { + expect(convertGladysOperationalStateToMatter(VACUUM_CLEANER_STATE.STOPPED)).to.equal(MATTER_RVC_OPERATIONAL_STATE.STOPPED); + }); + + it('should convert Gladys RETURNING_TO_DOCK (4) to Matter SEEKING_CHARGER (64)', () => { + expect(convertGladysOperationalStateToMatter(VACUUM_CLEANER_STATE.RETURNING_TO_DOCK)).to.equal(MATTER_RVC_OPERATIONAL_STATE.SEEKING_CHARGER); + }); + + it('should convert Gladys DOCKED (6) to Matter DOCKED (66)', () => { + expect(convertGladysOperationalStateToMatter(VACUUM_CLEANER_STATE.DOCKED)).to.equal(MATTER_RVC_OPERATIONAL_STATE.DOCKED); + }); + + it('should return unknown values as-is', () => { + expect(convertGladysOperationalStateToMatter(99)).to.equal(99); + }); + }); + + describe('convertMatterRunModeToGladys', () => { + it('should convert Matter IDLE to Gladys IDLE', () => { + expect(convertMatterRunModeToGladys(MATTER_RVC_RUN_MODE.IDLE)).to.equal(VACUUM_CLEANER_MODE.IDLE); + }); + + it('should convert Matter CLEANING to Gladys CLEANING', () => { + expect(convertMatterRunModeToGladys(MATTER_RVC_RUN_MODE.CLEANING)).to.equal(VACUUM_CLEANER_MODE.CLEANING); + }); + + it('should convert Matter MAPPING to Gladys MAPPING', () => { + expect(convertMatterRunModeToGladys(MATTER_RVC_RUN_MODE.MAPPING)).to.equal(VACUUM_CLEANER_MODE.MAPPING); + }); + + it('should return unknown values as-is', () => { + expect(convertMatterRunModeToGladys(99)).to.equal(99); + }); + }); + + describe('convertGladysRunModeToMatter', () => { + it('should convert Gladys IDLE to Matter IDLE', () => { + expect(convertGladysRunModeToMatter(VACUUM_CLEANER_MODE.IDLE)).to.equal(MATTER_RVC_RUN_MODE.IDLE); + }); + + it('should convert Gladys CLEANING to Matter CLEANING', () => { + expect(convertGladysRunModeToMatter(VACUUM_CLEANER_MODE.CLEANING)).to.equal(MATTER_RVC_RUN_MODE.CLEANING); + }); + + it('should convert Gladys MAPPING to Matter MAPPING', () => { + expect(convertGladysRunModeToMatter(VACUUM_CLEANER_MODE.MAPPING)).to.equal(MATTER_RVC_RUN_MODE.MAPPING); + }); + + it('should return unknown values as-is', () => { + expect(convertGladysRunModeToMatter(99)).to.equal(99); + }); + }); +}); diff --git a/server/utils/constants.js b/server/utils/constants.js index 162535eded..4214be0eb5 100644 --- a/server/utils/constants.js +++ b/server/utils/constants.js @@ -158,6 +158,22 @@ const LEVEL_MATTER_STATE = { CRITICAL: 4, }; +const VACUUM_CLEANER_STATE = { + STOPPED: 0, + RUNNING: 1, + PAUSED: 2, + ERROR: 3, + RETURNING_TO_DOCK: 4, + CHARGING: 5, + DOCKED: 6, +}; + +const VACUUM_CLEANER_MODE = { + IDLE: 0, + CLEANING: 1, + MAPPING: 2, +}; + const USER_ROLE = { ADMIN: 'admin', HABITANT: 'habitant', @@ -591,6 +607,7 @@ const DEVICE_FEATURE_CATEGORIES = { VOC_INDEX_SENSOR: 'voc-index-sensor', VOC_MATTER_INDEX_SENSOR: 'voc-matter-index-sensor', VOLUME_SENSOR: 'volume-sensor', + VACUUM_CLEANER: 'vacuum-cleaner', TEXT: 'text', INPUT: 'input', }; @@ -929,6 +946,12 @@ const DEVICE_FEATURE_TYPES = { FILTER_MONITORING: { FILTER_LIFE_REMAINING: 'filter-life-remaining', // Remaining life of the HEPA filter in percent (integer - sensor) }, + VACUUM_CLEANER: { + STATE: 'state', // Operational state of the vacuum (integer - sensor) + RUN_MODE: 'run-mode', // Run mode of the vacuum (integer - command) + CLEAN_MODE: 'clean-mode', // Clean mode of the vacuum (integer - command) + DOCK: 'dock', // Send vacuum to dock (binary - command) + }, }; const DEVICE_FEATURE_UNITS = { @@ -1553,6 +1576,8 @@ module.exports.LOCK = LOCK; module.exports.SIREN_LMH_VOLUME = SIREN_LMH_VOLUME; module.exports.AC_MODE = AC_MODE; module.exports.PILOT_WIRE_MODE = PILOT_WIRE_MODE; +module.exports.VACUUM_CLEANER_STATE = VACUUM_CLEANER_STATE; +module.exports.VACUUM_CLEANER_MODE = VACUUM_CLEANER_MODE; module.exports.LIQUID_STATE = LIQUID_STATE; module.exports.EVENTS = EVENTS; module.exports.LIFE_EVENTS = LIFE_EVENTS; From 81e9e408f242e6a4d557412004b353f896331ce7 Mon Sep 17 00:00:00 2001 From: Pierre-Gilles Leymarie <7365207+Pierre-Gilles@users.noreply.github.com> Date: Sat, 4 Apr 2026 16:37:59 +0200 Subject: [PATCH 02/10] Control vacuum cleaner modes --- .../boxs/device-in-room/DeviceRow.jsx | 5 ++- .../VacuumCleanerModeDeviceFeature.jsx | 43 +++++++++++++++++++ front/src/config/i18n/de.json | 7 +++ front/src/config/i18n/en.json | 7 +++ front/src/config/i18n/fr.json | 7 +++ .../matter/utils/convertToGladysDevice.js | 4 +- 6 files changed, 70 insertions(+), 3 deletions(-) create mode 100644 front/src/components/boxs/device-in-room/device-features/VacuumCleanerModeDeviceFeature.jsx diff --git a/front/src/components/boxs/device-in-room/DeviceRow.jsx b/front/src/components/boxs/device-in-room/DeviceRow.jsx index 75303c641f..c9393c1fc0 100644 --- a/front/src/components/boxs/device-in-room/DeviceRow.jsx +++ b/front/src/components/boxs/device-in-room/DeviceRow.jsx @@ -16,6 +16,7 @@ import PilotWireModeDeviceFeature from './device-features/PilotWireModeDeviceFea import LMHVolumeDeviceFeature from './device-features/LMHVolumeDeviceFeature'; import PushDeviceFeature from './device-features/PushDeviceFeature'; import VacuumCleanerDockDeviceFeature from './device-features/VacuumCleanerDockDeviceFeature'; +import VacuumCleanerModeDeviceFeature from './device-features/VacuumCleanerModeDeviceFeature'; const ROW_TYPE_BY_FEATURE_TYPE = { [DEVICE_FEATURE_TYPES.LIGHT.BINARY]: BinaryDeviceFeature, @@ -46,7 +47,9 @@ const ROW_TYPE_BY_FEATURE_TYPE = { [DEVICE_FEATURE_TYPES.ELECTRICAL_VEHICLE_CLIMATE.TARGET_TEMPERATURE]: SetpointDeviceFeature, [DEVICE_FEATURE_TYPES.ELECTRICAL_VEHICLE_COMMAND.ALARM]: BinaryDeviceFeature, [DEVICE_FEATURE_TYPES.ELECTRICAL_VEHICLE_COMMAND.LOCK]: BinaryDeviceFeature, - [DEVICE_FEATURE_TYPES.VACUUM_CLEANER.DOCK]: VacuumCleanerDockDeviceFeature + [DEVICE_FEATURE_TYPES.VACUUM_CLEANER.DOCK]: VacuumCleanerDockDeviceFeature, + [DEVICE_FEATURE_TYPES.VACUUM_CLEANER.RUN_MODE]: VacuumCleanerModeDeviceFeature, + [DEVICE_FEATURE_TYPES.VACUUM_CLEANER.CLEAN_MODE]: VacuumCleanerModeDeviceFeature }; const DeviceRow = ({ children, ...props }) => { diff --git a/front/src/components/boxs/device-in-room/device-features/VacuumCleanerModeDeviceFeature.jsx b/front/src/components/boxs/device-in-room/device-features/VacuumCleanerModeDeviceFeature.jsx new file mode 100644 index 0000000000..5e3b943c60 --- /dev/null +++ b/front/src/components/boxs/device-in-room/device-features/VacuumCleanerModeDeviceFeature.jsx @@ -0,0 +1,43 @@ +import get from 'get-value'; +import { Text } from 'preact-i18n'; + +import { DeviceFeatureCategoriesIcon } from '../../../../utils/consts'; +import { VACUUM_CLEANER_MODE } from '../../../../../../server/utils/constants'; + +const VacuumCleanerModeDeviceFeature = ({ children, ...props }) => { + const { deviceFeature } = props; + const { category, type } = deviceFeature; + + function updateValue(e) { + props.updateValueWithDebounce(deviceFeature, e.currentTarget.value); + } + + return ( + + + + + {props.rowName} + + +
+
+ +
+
+ + + ); +}; + +export default VacuumCleanerModeDeviceFeature; diff --git a/front/src/config/i18n/de.json b/front/src/config/i18n/de.json index 00f41f6557..395c2f8913 100644 --- a/front/src/config/i18n/de.json +++ b/front/src/config/i18n/de.json @@ -3374,6 +3374,13 @@ "medium": "Mittel", "high": "Hoch" } + }, + "vacuum-cleaner": { + "mode": { + "idle": "Leerlauf", + "cleaning": "Reinigen", + "mapping": "Kartieren" + } } } }, diff --git a/front/src/config/i18n/en.json b/front/src/config/i18n/en.json index 38c7af50bc..363a73c3a9 100644 --- a/front/src/config/i18n/en.json +++ b/front/src/config/i18n/en.json @@ -3374,6 +3374,13 @@ "medium": "Medium", "high": "High" } + }, + "vacuum-cleaner": { + "mode": { + "idle": "Idle", + "cleaning": "Clean", + "mapping": "Map" + } } } }, diff --git a/front/src/config/i18n/fr.json b/front/src/config/i18n/fr.json index 6b658dd2fe..e8ebb2e713 100644 --- a/front/src/config/i18n/fr.json +++ b/front/src/config/i18n/fr.json @@ -3374,6 +3374,13 @@ "medium": "Moyen", "high": "Fort" } + }, + "vacuum-cleaner": { + "mode": { + "idle": "Repos", + "cleaning": "Nettoyer", + "mapping": "Cartographier" + } } } }, diff --git a/server/services/matter/utils/convertToGladysDevice.js b/server/services/matter/utils/convertToGladysDevice.js index f6ea28562a..5b60f36546 100644 --- a/server/services/matter/utils/convertToGladysDevice.js +++ b/server/services/matter/utils/convertToGladysDevice.js @@ -450,7 +450,7 @@ async function convertToGladysDevice(serviceId, nodeId, device, nodeDetailDevice has_feedback: true, external_id: `matter:${nodeId}:${devicePath}:${clusterIndex}`, min: 0, - max: 255, + max: 2, }); } else if (clusterIndex === RvcCleanMode.Complete.id) { gladysDevice.features.push({ @@ -461,7 +461,7 @@ async function convertToGladysDevice(serviceId, nodeId, device, nodeDetailDevice has_feedback: true, external_id: `matter:${nodeId}:${devicePath}:${clusterIndex}`, min: 0, - max: 255, + max: 2, }); } else if (clusterIndex === PowerSource.Complete.id) { if (clusterClient.supportedFeatures && clusterClient.supportedFeatures.battery) { From 8740793cafcd80bfb448d426a9a27dba6fe60c6b Mon Sep 17 00:00:00 2001 From: Pierre-Gilles Leymarie <7365207+Pierre-Gilles@users.noreply.github.com> Date: Sat, 4 Apr 2026 16:42:55 +0200 Subject: [PATCH 03/10] Fix prettier & lint --- server/services/matter/lib/matter.setValue.js | 2 +- .../matter/lib/convertToGladysDevice.test.js | 3 +- .../utils/vacuumCleanerStateMapping.test.js | 40 ++++++++++++++----- 3 files changed, 33 insertions(+), 12 deletions(-) diff --git a/server/services/matter/lib/matter.setValue.js b/server/services/matter/lib/matter.setValue.js index b292980542..444380e069 100644 --- a/server/services/matter/lib/matter.setValue.js +++ b/server/services/matter/lib/matter.setValue.js @@ -1,4 +1,3 @@ -// eslint-disable-next-line import/no-unresolved const { OnOff, WindowCovering, @@ -8,6 +7,7 @@ const { RvcOperationalState, RvcRunMode, RvcCleanMode, + // eslint-disable-next-line import/no-unresolved } = require('@matter/main/clusters'); const { DEVICE_FEATURE_TYPES, DEVICE_FEATURE_CATEGORIES, COVER_STATE } = require('../../../utils/constants'); const { intToHsb } = require('../../../utils/colors'); diff --git a/server/test/services/matter/lib/convertToGladysDevice.test.js b/server/test/services/matter/lib/convertToGladysDevice.test.js index 830e0c451d..e71c93db27 100644 --- a/server/test/services/matter/lib/convertToGladysDevice.test.js +++ b/server/test/services/matter/lib/convertToGladysDevice.test.js @@ -1,5 +1,5 @@ const { expect } = require('chai'); -// eslint-disable-next-line import/no-unresolved + const { BooleanState, Switch, @@ -7,6 +7,7 @@ const { RvcRunMode, RvcCleanMode, PowerSource, + // eslint-disable-next-line import/no-unresolved } = require('@matter/main/clusters'); const { convertToGladysDevice } = require('../../../../services/matter/utils/convertToGladysDevice'); diff --git a/server/test/services/matter/utils/vacuumCleanerStateMapping.test.js b/server/test/services/matter/utils/vacuumCleanerStateMapping.test.js index c00f879790..587c852a1f 100644 --- a/server/test/services/matter/utils/vacuumCleanerStateMapping.test.js +++ b/server/test/services/matter/utils/vacuumCleanerStateMapping.test.js @@ -14,31 +14,45 @@ const { VACUUM_CLEANER_STATE, VACUUM_CLEANER_MODE } = require('../../../../utils describe('Matter.vacuumCleanerStateMapping', () => { describe('convertMatterOperationalStateToGladys', () => { it('should convert Matter STOPPED to Gladys STOPPED', () => { - expect(convertMatterOperationalStateToGladys(MATTER_RVC_OPERATIONAL_STATE.STOPPED)).to.equal(VACUUM_CLEANER_STATE.STOPPED); + expect(convertMatterOperationalStateToGladys(MATTER_RVC_OPERATIONAL_STATE.STOPPED)).to.equal( + VACUUM_CLEANER_STATE.STOPPED, + ); }); it('should convert Matter RUNNING to Gladys RUNNING', () => { - expect(convertMatterOperationalStateToGladys(MATTER_RVC_OPERATIONAL_STATE.RUNNING)).to.equal(VACUUM_CLEANER_STATE.RUNNING); + expect(convertMatterOperationalStateToGladys(MATTER_RVC_OPERATIONAL_STATE.RUNNING)).to.equal( + VACUUM_CLEANER_STATE.RUNNING, + ); }); it('should convert Matter PAUSED to Gladys PAUSED', () => { - expect(convertMatterOperationalStateToGladys(MATTER_RVC_OPERATIONAL_STATE.PAUSED)).to.equal(VACUUM_CLEANER_STATE.PAUSED); + expect(convertMatterOperationalStateToGladys(MATTER_RVC_OPERATIONAL_STATE.PAUSED)).to.equal( + VACUUM_CLEANER_STATE.PAUSED, + ); }); it('should convert Matter ERROR to Gladys ERROR', () => { - expect(convertMatterOperationalStateToGladys(MATTER_RVC_OPERATIONAL_STATE.ERROR)).to.equal(VACUUM_CLEANER_STATE.ERROR); + expect(convertMatterOperationalStateToGladys(MATTER_RVC_OPERATIONAL_STATE.ERROR)).to.equal( + VACUUM_CLEANER_STATE.ERROR, + ); }); it('should convert Matter SEEKING_CHARGER (64) to Gladys RETURNING_TO_DOCK (4)', () => { - expect(convertMatterOperationalStateToGladys(MATTER_RVC_OPERATIONAL_STATE.SEEKING_CHARGER)).to.equal(VACUUM_CLEANER_STATE.RETURNING_TO_DOCK); + expect(convertMatterOperationalStateToGladys(MATTER_RVC_OPERATIONAL_STATE.SEEKING_CHARGER)).to.equal( + VACUUM_CLEANER_STATE.RETURNING_TO_DOCK, + ); }); it('should convert Matter CHARGING (65) to Gladys CHARGING (5)', () => { - expect(convertMatterOperationalStateToGladys(MATTER_RVC_OPERATIONAL_STATE.CHARGING)).to.equal(VACUUM_CLEANER_STATE.CHARGING); + expect(convertMatterOperationalStateToGladys(MATTER_RVC_OPERATIONAL_STATE.CHARGING)).to.equal( + VACUUM_CLEANER_STATE.CHARGING, + ); }); it('should convert Matter DOCKED (66) to Gladys DOCKED (6)', () => { - expect(convertMatterOperationalStateToGladys(MATTER_RVC_OPERATIONAL_STATE.DOCKED)).to.equal(VACUUM_CLEANER_STATE.DOCKED); + expect(convertMatterOperationalStateToGladys(MATTER_RVC_OPERATIONAL_STATE.DOCKED)).to.equal( + VACUUM_CLEANER_STATE.DOCKED, + ); }); it('should return unknown values as-is', () => { @@ -48,15 +62,21 @@ describe('Matter.vacuumCleanerStateMapping', () => { describe('convertGladysOperationalStateToMatter', () => { it('should convert Gladys STOPPED to Matter STOPPED', () => { - expect(convertGladysOperationalStateToMatter(VACUUM_CLEANER_STATE.STOPPED)).to.equal(MATTER_RVC_OPERATIONAL_STATE.STOPPED); + expect(convertGladysOperationalStateToMatter(VACUUM_CLEANER_STATE.STOPPED)).to.equal( + MATTER_RVC_OPERATIONAL_STATE.STOPPED, + ); }); it('should convert Gladys RETURNING_TO_DOCK (4) to Matter SEEKING_CHARGER (64)', () => { - expect(convertGladysOperationalStateToMatter(VACUUM_CLEANER_STATE.RETURNING_TO_DOCK)).to.equal(MATTER_RVC_OPERATIONAL_STATE.SEEKING_CHARGER); + expect(convertGladysOperationalStateToMatter(VACUUM_CLEANER_STATE.RETURNING_TO_DOCK)).to.equal( + MATTER_RVC_OPERATIONAL_STATE.SEEKING_CHARGER, + ); }); it('should convert Gladys DOCKED (6) to Matter DOCKED (66)', () => { - expect(convertGladysOperationalStateToMatter(VACUUM_CLEANER_STATE.DOCKED)).to.equal(MATTER_RVC_OPERATIONAL_STATE.DOCKED); + expect(convertGladysOperationalStateToMatter(VACUUM_CLEANER_STATE.DOCKED)).to.equal( + MATTER_RVC_OPERATIONAL_STATE.DOCKED, + ); }); it('should return unknown values as-is', () => { From 5ddfe0141c65a082d696097c9d7db642c3dae740 Mon Sep 17 00:00:00 2001 From: Pierre-Gilles Leymarie <7365207+Pierre-Gilles@users.noreply.github.com> Date: Sat, 4 Apr 2026 16:58:21 +0200 Subject: [PATCH 04/10] Handle clean mode according to spec --- .../boxs/device-in-room/DeviceRow.jsx | 3 +- .../VacuumCleanerCleanModeDeviceFeature.jsx | 55 +++++++++++++ front/src/config/i18n/de.json | 9 +++ front/src/config/i18n/en.json | 9 +++ front/src/config/i18n/fr.json | 9 +++ .../matter/lib/matter.listenToStateChange.js | 7 +- server/services/matter/lib/matter.setValue.js | 6 +- .../matter/utils/convertToGladysDevice.js | 2 +- .../matter/utils/vacuumCleanerStateMapping.js | 79 ++++++++++++++++++- .../matter/lib/convertToGladysDevice.test.js | 4 +- .../matter/lib/listenToStateChange.test.js | 4 +- .../utils/vacuumCleanerStateMapping.test.js | 63 ++++++++++++++- server/utils/constants.js | 11 +++ 13 files changed, 247 insertions(+), 14 deletions(-) create mode 100644 front/src/components/boxs/device-in-room/device-features/VacuumCleanerCleanModeDeviceFeature.jsx diff --git a/front/src/components/boxs/device-in-room/DeviceRow.jsx b/front/src/components/boxs/device-in-room/DeviceRow.jsx index c9393c1fc0..5016f1145b 100644 --- a/front/src/components/boxs/device-in-room/DeviceRow.jsx +++ b/front/src/components/boxs/device-in-room/DeviceRow.jsx @@ -17,6 +17,7 @@ import LMHVolumeDeviceFeature from './device-features/LMHVolumeDeviceFeature'; import PushDeviceFeature from './device-features/PushDeviceFeature'; import VacuumCleanerDockDeviceFeature from './device-features/VacuumCleanerDockDeviceFeature'; import VacuumCleanerModeDeviceFeature from './device-features/VacuumCleanerModeDeviceFeature'; +import VacuumCleanerCleanModeDeviceFeature from './device-features/VacuumCleanerCleanModeDeviceFeature'; const ROW_TYPE_BY_FEATURE_TYPE = { [DEVICE_FEATURE_TYPES.LIGHT.BINARY]: BinaryDeviceFeature, @@ -49,7 +50,7 @@ const ROW_TYPE_BY_FEATURE_TYPE = { [DEVICE_FEATURE_TYPES.ELECTRICAL_VEHICLE_COMMAND.LOCK]: BinaryDeviceFeature, [DEVICE_FEATURE_TYPES.VACUUM_CLEANER.DOCK]: VacuumCleanerDockDeviceFeature, [DEVICE_FEATURE_TYPES.VACUUM_CLEANER.RUN_MODE]: VacuumCleanerModeDeviceFeature, - [DEVICE_FEATURE_TYPES.VACUUM_CLEANER.CLEAN_MODE]: VacuumCleanerModeDeviceFeature + [DEVICE_FEATURE_TYPES.VACUUM_CLEANER.CLEAN_MODE]: VacuumCleanerCleanModeDeviceFeature }; const DeviceRow = ({ children, ...props }) => { diff --git a/front/src/components/boxs/device-in-room/device-features/VacuumCleanerCleanModeDeviceFeature.jsx b/front/src/components/boxs/device-in-room/device-features/VacuumCleanerCleanModeDeviceFeature.jsx new file mode 100644 index 0000000000..4b93e9d886 --- /dev/null +++ b/front/src/components/boxs/device-in-room/device-features/VacuumCleanerCleanModeDeviceFeature.jsx @@ -0,0 +1,55 @@ +import get from 'get-value'; +import { Text } from 'preact-i18n'; + +import { DeviceFeatureCategoriesIcon } from '../../../../utils/consts'; +import { VACUUM_CLEANER_CLEAN_MODE } from '../../../../../../server/utils/constants'; + +const VacuumCleanerCleanModeDeviceFeature = ({ children, ...props }) => { + const { deviceFeature } = props; + const { category, type } = deviceFeature; + + function updateValue(e) { + props.updateValueWithDebounce(deviceFeature, e.currentTarget.value); + } + + return ( + + + + + {props.rowName} + + +
+
+ +
+
+ + + ); +}; + +export default VacuumCleanerCleanModeDeviceFeature; diff --git a/front/src/config/i18n/de.json b/front/src/config/i18n/de.json index 395c2f8913..50d14fc9c1 100644 --- a/front/src/config/i18n/de.json +++ b/front/src/config/i18n/de.json @@ -3380,6 +3380,15 @@ "idle": "Leerlauf", "cleaning": "Reinigen", "mapping": "Kartieren" + }, + "clean-mode": { + "auto": "Auto", + "quick": "Schnell", + "quiet": "Leise", + "low-noise": "Geräuscharm", + "deep-clean": "Tiefenreinigung", + "vacuum": "Saugen", + "mop": "Wischen" } } } diff --git a/front/src/config/i18n/en.json b/front/src/config/i18n/en.json index 363a73c3a9..c7f4279be3 100644 --- a/front/src/config/i18n/en.json +++ b/front/src/config/i18n/en.json @@ -3380,6 +3380,15 @@ "idle": "Idle", "cleaning": "Clean", "mapping": "Map" + }, + "clean-mode": { + "auto": "Auto", + "quick": "Quick", + "quiet": "Quiet", + "low-noise": "Low Noise", + "deep-clean": "Deep Clean", + "vacuum": "Vacuum", + "mop": "Mop" } } } diff --git a/front/src/config/i18n/fr.json b/front/src/config/i18n/fr.json index e8ebb2e713..782c8e1f44 100644 --- a/front/src/config/i18n/fr.json +++ b/front/src/config/i18n/fr.json @@ -3380,6 +3380,15 @@ "idle": "Repos", "cleaning": "Nettoyer", "mapping": "Cartographier" + }, + "clean-mode": { + "auto": "Auto", + "quick": "Rapide", + "quiet": "Silencieux", + "low-noise": "Faible bruit", + "deep-clean": "Nettoyage profond", + "vacuum": "Aspiration", + "mop": "Serpillère" } } } diff --git a/server/services/matter/lib/matter.listenToStateChange.js b/server/services/matter/lib/matter.listenToStateChange.js index 8f2addf527..6378c56eb4 100644 --- a/server/services/matter/lib/matter.listenToStateChange.js +++ b/server/services/matter/lib/matter.listenToStateChange.js @@ -30,6 +30,7 @@ const { EVENTS, STATE, BUTTON_STATUS } = require('../../../utils/constants'); const { convertMatterOperationalStateToGladys, convertMatterRunModeToGladys, + convertMatterCleanModeToGladys, } = require('../utils/vacuumCleanerStateMapping'); /** @@ -453,11 +454,11 @@ async function listenToStateChange(nodeId, devicePath, device) { if (rvcCleanMode && !this.stateChangeListeners.has(rvcCleanMode)) { logger.debug(`Matter: Adding state change listener for RvcCleanMode cluster ${rvcCleanMode.name}`); this.stateChangeListeners.add(rvcCleanMode); - // Subscribe to RvcCleanMode attribute changes (clean mode uses same mapping as run mode) + // Subscribe to RvcCleanMode attribute changes rvcCleanMode.addCurrentModeAttributeListener((value) => { logger.debug(`Matter: RvcCleanMode currentMode attribute changed to ${value}`); - // Convert Matter mode to Gladys standard mode - const gladysMode = convertMatterRunModeToGladys(value); + // Convert Matter clean mode to Gladys standard clean mode + const gladysMode = convertMatterCleanModeToGladys(value); this.gladys.event.emit(EVENTS.DEVICE.NEW_STATE, { device_feature_external_id: `matter:${nodeId}:${devicePath}:${RvcCleanMode.Complete.id}`, state: gladysMode, diff --git a/server/services/matter/lib/matter.setValue.js b/server/services/matter/lib/matter.setValue.js index 444380e069..f29f12e8c4 100644 --- a/server/services/matter/lib/matter.setValue.js +++ b/server/services/matter/lib/matter.setValue.js @@ -12,7 +12,7 @@ const { const { DEVICE_FEATURE_TYPES, DEVICE_FEATURE_CATEGORIES, COVER_STATE } = require('../../../utils/constants'); const { intToHsb } = require('../../../utils/colors'); const logger = require('../../../utils/logger'); -const { convertGladysRunModeToMatter } = require('../utils/vacuumCleanerStateMapping'); +const { convertGladysRunModeToMatter, convertGladysCleanModeToMatter } = require('../utils/vacuumCleanerStateMapping'); /** * @description Find a device recursively through child endpoints. @@ -233,8 +233,8 @@ async function setValue(gladysDevice, gladysFeature, value) { if (!rvcCleanMode) { throw new Error('Device does not support RvcCleanMode cluster'); } - // Convert Gladys standard mode to Matter mode (uses same mapping as run mode) - const matterMode = convertGladysRunModeToMatter(value); + // Convert Gladys standard clean mode to Matter clean mode + const matterMode = convertGladysCleanModeToMatter(value); await rvcCleanMode.changeToMode({ newMode: matterMode }); } } diff --git a/server/services/matter/utils/convertToGladysDevice.js b/server/services/matter/utils/convertToGladysDevice.js index 5b60f36546..914c72d8a0 100644 --- a/server/services/matter/utils/convertToGladysDevice.js +++ b/server/services/matter/utils/convertToGladysDevice.js @@ -461,7 +461,7 @@ async function convertToGladysDevice(serviceId, nodeId, device, nodeDetailDevice has_feedback: true, external_id: `matter:${nodeId}:${devicePath}:${clusterIndex}`, min: 0, - max: 2, + max: 6, }); } else if (clusterIndex === PowerSource.Complete.id) { if (clusterClient.supportedFeatures && clusterClient.supportedFeatures.battery) { diff --git a/server/services/matter/utils/vacuumCleanerStateMapping.js b/server/services/matter/utils/vacuumCleanerStateMapping.js index dadfdfac6b..02f8b2eb74 100644 --- a/server/services/matter/utils/vacuumCleanerStateMapping.js +++ b/server/services/matter/utils/vacuumCleanerStateMapping.js @@ -1,4 +1,5 @@ -const { VACUUM_CLEANER_STATE, VACUUM_CLEANER_MODE } = require('../../../utils/constants'); +const { VACUUM_CLEANER_STATE, VACUUM_CLEANER_MODE, VACUUM_CLEANER_CLEAN_MODE } = require('../../../utils/constants'); +const logger = require('../../../utils/logger'); /** * Matter RvcOperationalState values (from Matter specification). @@ -23,6 +24,21 @@ const MATTER_RVC_RUN_MODE = { MAPPING: 2, }; +/** + * Matter RvcCleanMode values (from Matter specification). + * Standard modes are 0-3, manufacturer-specific modes start at 16384. + */ +const MATTER_RVC_CLEAN_MODE = { + AUTO: 0, + QUICK: 1, + QUIET: 2, + LOW_NOISE: 3, + // Manufacturer-specific modes (16384+) + DEEP_CLEAN: 16384, + VACUUM: 16385, + MOP: 16386, +}; + /** * @description Convert Matter RvcOperationalState to Gladys vacuum cleaner state. * @param {number} matterState - The Matter RvcOperationalState value. @@ -119,11 +135,72 @@ function convertGladysRunModeToMatter(gladysMode) { } } +/** + * @description Convert Matter RvcCleanMode to Gladys vacuum cleaner clean mode. + * @param {number} matterMode - The Matter RvcCleanMode value. + * @returns {number|null} The Gladys vacuum cleaner clean mode, or null if unknown. + * @example + * const gladysMode = convertMatterCleanModeToGladys(0); // Returns VACUUM_CLEANER_CLEAN_MODE.AUTO (0) + */ +function convertMatterCleanModeToGladys(matterMode) { + switch (matterMode) { + case MATTER_RVC_CLEAN_MODE.AUTO: + return VACUUM_CLEANER_CLEAN_MODE.AUTO; + case MATTER_RVC_CLEAN_MODE.QUICK: + return VACUUM_CLEANER_CLEAN_MODE.QUICK; + case MATTER_RVC_CLEAN_MODE.QUIET: + return VACUUM_CLEANER_CLEAN_MODE.QUIET; + case MATTER_RVC_CLEAN_MODE.LOW_NOISE: + return VACUUM_CLEANER_CLEAN_MODE.LOW_NOISE; + case MATTER_RVC_CLEAN_MODE.DEEP_CLEAN: + return VACUUM_CLEANER_CLEAN_MODE.DEEP_CLEAN; + case MATTER_RVC_CLEAN_MODE.VACUUM: + return VACUUM_CLEANER_CLEAN_MODE.VACUUM; + case MATTER_RVC_CLEAN_MODE.MOP: + return VACUUM_CLEANER_CLEAN_MODE.MOP; + default: + logger.debug(`Matter: Unknown RvcCleanMode value ${matterMode}, returning as-is`); + return matterMode; + } +} + +/** + * @description Convert Gladys vacuum cleaner clean mode to Matter RvcCleanMode. + * @param {number} gladysMode - The Gladys vacuum cleaner clean mode. + * @returns {number} The Matter RvcCleanMode value. + * @example + * const matterMode = convertGladysCleanModeToMatter(4); // Returns MATTER_RVC_CLEAN_MODE.DEEP_CLEAN (16384) + */ +function convertGladysCleanModeToMatter(gladysMode) { + switch (gladysMode) { + case VACUUM_CLEANER_CLEAN_MODE.AUTO: + return MATTER_RVC_CLEAN_MODE.AUTO; + case VACUUM_CLEANER_CLEAN_MODE.QUICK: + return MATTER_RVC_CLEAN_MODE.QUICK; + case VACUUM_CLEANER_CLEAN_MODE.QUIET: + return MATTER_RVC_CLEAN_MODE.QUIET; + case VACUUM_CLEANER_CLEAN_MODE.LOW_NOISE: + return MATTER_RVC_CLEAN_MODE.LOW_NOISE; + case VACUUM_CLEANER_CLEAN_MODE.DEEP_CLEAN: + return MATTER_RVC_CLEAN_MODE.DEEP_CLEAN; + case VACUUM_CLEANER_CLEAN_MODE.VACUUM: + return MATTER_RVC_CLEAN_MODE.VACUUM; + case VACUUM_CLEANER_CLEAN_MODE.MOP: + return MATTER_RVC_CLEAN_MODE.MOP; + default: + logger.debug(`Matter: Unknown Gladys clean mode value ${gladysMode}, returning as-is`); + return gladysMode; + } +} + module.exports = { MATTER_RVC_OPERATIONAL_STATE, MATTER_RVC_RUN_MODE, + MATTER_RVC_CLEAN_MODE, convertMatterOperationalStateToGladys, convertGladysOperationalStateToMatter, convertMatterRunModeToGladys, convertGladysRunModeToMatter, + convertMatterCleanModeToGladys, + convertGladysCleanModeToMatter, }; diff --git a/server/test/services/matter/lib/convertToGladysDevice.test.js b/server/test/services/matter/lib/convertToGladysDevice.test.js index e71c93db27..0890be0c48 100644 --- a/server/test/services/matter/lib/convertToGladysDevice.test.js +++ b/server/test/services/matter/lib/convertToGladysDevice.test.js @@ -147,7 +147,7 @@ describe('Matter.convertToGladysDevice', () => { has_feedback: true, external_id: 'matter:12345:2:84', min: 0, - max: 255, + max: 2, }); }); @@ -177,7 +177,7 @@ describe('Matter.convertToGladysDevice', () => { has_feedback: true, external_id: 'matter:12345:2:85', min: 0, - max: 255, + max: 6, }); }); diff --git a/server/test/services/matter/lib/listenToStateChange.test.js b/server/test/services/matter/lib/listenToStateChange.test.js index 7bc4baa500..f71b16c9fd 100644 --- a/server/test/services/matter/lib/listenToStateChange.test.js +++ b/server/test/services/matter/lib/listenToStateChange.test.js @@ -572,7 +572,7 @@ describe('Matter.listenToStateChange', () => { const clusterClients = new Map(); clusterClients.set(RvcCleanMode.Complete.id, { addCurrentModeAttributeListener: (callback) => { - callback(2); // Deep clean mode + callback(16384); // Matter DEEP_CLEAN (16384) should be converted to Gladys DEEP_CLEAN (4) }, }); const device = { @@ -582,7 +582,7 @@ describe('Matter.listenToStateChange', () => { await matterHandler.listenToStateChange(1234n, '2', device); assert.calledWith(gladys.event.emit, EVENTS.DEVICE.NEW_STATE, { device_feature_external_id: 'matter:1234:2:85', - state: 2, + state: 4, // Gladys standard DEEP_CLEAN mode }); }); it('should listen to state change (PowerSource battery)', async () => { diff --git a/server/test/services/matter/utils/vacuumCleanerStateMapping.test.js b/server/test/services/matter/utils/vacuumCleanerStateMapping.test.js index 587c852a1f..f0ee79474a 100644 --- a/server/test/services/matter/utils/vacuumCleanerStateMapping.test.js +++ b/server/test/services/matter/utils/vacuumCleanerStateMapping.test.js @@ -3,13 +3,16 @@ const { expect } = require('chai'); const { MATTER_RVC_OPERATIONAL_STATE, MATTER_RVC_RUN_MODE, + MATTER_RVC_CLEAN_MODE, convertMatterOperationalStateToGladys, convertGladysOperationalStateToMatter, convertMatterRunModeToGladys, convertGladysRunModeToMatter, + convertMatterCleanModeToGladys, + convertGladysCleanModeToMatter, } = require('../../../../services/matter/utils/vacuumCleanerStateMapping'); -const { VACUUM_CLEANER_STATE, VACUUM_CLEANER_MODE } = require('../../../../utils/constants'); +const { VACUUM_CLEANER_STATE, VACUUM_CLEANER_MODE, VACUUM_CLEANER_CLEAN_MODE } = require('../../../../utils/constants'); describe('Matter.vacuumCleanerStateMapping', () => { describe('convertMatterOperationalStateToGladys', () => { @@ -119,4 +122,62 @@ describe('Matter.vacuumCleanerStateMapping', () => { expect(convertGladysRunModeToMatter(99)).to.equal(99); }); }); + + describe('convertMatterCleanModeToGladys', () => { + it('should convert Matter AUTO to Gladys AUTO', () => { + expect(convertMatterCleanModeToGladys(MATTER_RVC_CLEAN_MODE.AUTO)).to.equal(VACUUM_CLEANER_CLEAN_MODE.AUTO); + }); + + it('should convert Matter QUICK to Gladys QUICK', () => { + expect(convertMatterCleanModeToGladys(MATTER_RVC_CLEAN_MODE.QUICK)).to.equal(VACUUM_CLEANER_CLEAN_MODE.QUICK); + }); + + it('should convert Matter QUIET to Gladys QUIET', () => { + expect(convertMatterCleanModeToGladys(MATTER_RVC_CLEAN_MODE.QUIET)).to.equal(VACUUM_CLEANER_CLEAN_MODE.QUIET); + }); + + it('should convert Matter LOW_NOISE to Gladys LOW_NOISE', () => { + expect(convertMatterCleanModeToGladys(MATTER_RVC_CLEAN_MODE.LOW_NOISE)).to.equal( + VACUUM_CLEANER_CLEAN_MODE.LOW_NOISE, + ); + }); + + it('should convert Matter DEEP_CLEAN (16384) to Gladys DEEP_CLEAN (4)', () => { + expect(convertMatterCleanModeToGladys(MATTER_RVC_CLEAN_MODE.DEEP_CLEAN)).to.equal( + VACUUM_CLEANER_CLEAN_MODE.DEEP_CLEAN, + ); + }); + + it('should convert Matter VACUUM (16385) to Gladys VACUUM (5)', () => { + expect(convertMatterCleanModeToGladys(MATTER_RVC_CLEAN_MODE.VACUUM)).to.equal(VACUUM_CLEANER_CLEAN_MODE.VACUUM); + }); + + it('should convert Matter MOP (16386) to Gladys MOP (6)', () => { + expect(convertMatterCleanModeToGladys(MATTER_RVC_CLEAN_MODE.MOP)).to.equal(VACUUM_CLEANER_CLEAN_MODE.MOP); + }); + + it('should return unknown values as-is', () => { + expect(convertMatterCleanModeToGladys(99)).to.equal(99); + }); + }); + + describe('convertGladysCleanModeToMatter', () => { + it('should convert Gladys AUTO to Matter AUTO', () => { + expect(convertGladysCleanModeToMatter(VACUUM_CLEANER_CLEAN_MODE.AUTO)).to.equal(MATTER_RVC_CLEAN_MODE.AUTO); + }); + + it('should convert Gladys DEEP_CLEAN (4) to Matter DEEP_CLEAN (16384)', () => { + expect(convertGladysCleanModeToMatter(VACUUM_CLEANER_CLEAN_MODE.DEEP_CLEAN)).to.equal( + MATTER_RVC_CLEAN_MODE.DEEP_CLEAN, + ); + }); + + it('should convert Gladys MOP (6) to Matter MOP (16386)', () => { + expect(convertGladysCleanModeToMatter(VACUUM_CLEANER_CLEAN_MODE.MOP)).to.equal(MATTER_RVC_CLEAN_MODE.MOP); + }); + + it('should return unknown values as-is', () => { + expect(convertGladysCleanModeToMatter(99)).to.equal(99); + }); + }); }); diff --git a/server/utils/constants.js b/server/utils/constants.js index 4214be0eb5..93db96b0c4 100644 --- a/server/utils/constants.js +++ b/server/utils/constants.js @@ -174,6 +174,16 @@ const VACUUM_CLEANER_MODE = { MAPPING: 2, }; +const VACUUM_CLEANER_CLEAN_MODE = { + AUTO: 0, + QUICK: 1, + QUIET: 2, + LOW_NOISE: 3, + DEEP_CLEAN: 4, + VACUUM: 5, + MOP: 6, +}; + const USER_ROLE = { ADMIN: 'admin', HABITANT: 'habitant', @@ -1578,6 +1588,7 @@ module.exports.AC_MODE = AC_MODE; module.exports.PILOT_WIRE_MODE = PILOT_WIRE_MODE; module.exports.VACUUM_CLEANER_STATE = VACUUM_CLEANER_STATE; module.exports.VACUUM_CLEANER_MODE = VACUUM_CLEANER_MODE; +module.exports.VACUUM_CLEANER_CLEAN_MODE = VACUUM_CLEANER_CLEAN_MODE; module.exports.LIQUID_STATE = LIQUID_STATE; module.exports.EVENTS = EVENTS; module.exports.LIFE_EVENTS = LIFE_EVENTS; From 8ce8fddc573d6c1e8784017c15cd165b07fc7058 Mon Sep 17 00:00:00 2001 From: Pierre-Gilles Leymarie <7365207+Pierre-Gilles@users.noreply.github.com> Date: Sat, 4 Apr 2026 17:07:42 +0200 Subject: [PATCH 05/10] Add missing tests --- .../utils/vacuumCleanerStateMapping.test.js | 42 +++++++++++++++++++ 1 file changed, 42 insertions(+) diff --git a/server/test/services/matter/utils/vacuumCleanerStateMapping.test.js b/server/test/services/matter/utils/vacuumCleanerStateMapping.test.js index f0ee79474a..897ce3bcec 100644 --- a/server/test/services/matter/utils/vacuumCleanerStateMapping.test.js +++ b/server/test/services/matter/utils/vacuumCleanerStateMapping.test.js @@ -70,12 +70,36 @@ describe('Matter.vacuumCleanerStateMapping', () => { ); }); + it('should convert Gladys RUNNING to Matter RUNNING', () => { + expect(convertGladysOperationalStateToMatter(VACUUM_CLEANER_STATE.RUNNING)).to.equal( + MATTER_RVC_OPERATIONAL_STATE.RUNNING, + ); + }); + + it('should convert Gladys PAUSED to Matter PAUSED', () => { + expect(convertGladysOperationalStateToMatter(VACUUM_CLEANER_STATE.PAUSED)).to.equal( + MATTER_RVC_OPERATIONAL_STATE.PAUSED, + ); + }); + + it('should convert Gladys ERROR to Matter ERROR', () => { + expect(convertGladysOperationalStateToMatter(VACUUM_CLEANER_STATE.ERROR)).to.equal( + MATTER_RVC_OPERATIONAL_STATE.ERROR, + ); + }); + it('should convert Gladys RETURNING_TO_DOCK (4) to Matter SEEKING_CHARGER (64)', () => { expect(convertGladysOperationalStateToMatter(VACUUM_CLEANER_STATE.RETURNING_TO_DOCK)).to.equal( MATTER_RVC_OPERATIONAL_STATE.SEEKING_CHARGER, ); }); + it('should convert Gladys CHARGING (5) to Matter CHARGING (65)', () => { + expect(convertGladysOperationalStateToMatter(VACUUM_CLEANER_STATE.CHARGING)).to.equal( + MATTER_RVC_OPERATIONAL_STATE.CHARGING, + ); + }); + it('should convert Gladys DOCKED (6) to Matter DOCKED (66)', () => { expect(convertGladysOperationalStateToMatter(VACUUM_CLEANER_STATE.DOCKED)).to.equal( MATTER_RVC_OPERATIONAL_STATE.DOCKED, @@ -166,12 +190,30 @@ describe('Matter.vacuumCleanerStateMapping', () => { expect(convertGladysCleanModeToMatter(VACUUM_CLEANER_CLEAN_MODE.AUTO)).to.equal(MATTER_RVC_CLEAN_MODE.AUTO); }); + it('should convert Gladys QUICK to Matter QUICK', () => { + expect(convertGladysCleanModeToMatter(VACUUM_CLEANER_CLEAN_MODE.QUICK)).to.equal(MATTER_RVC_CLEAN_MODE.QUICK); + }); + + it('should convert Gladys QUIET to Matter QUIET', () => { + expect(convertGladysCleanModeToMatter(VACUUM_CLEANER_CLEAN_MODE.QUIET)).to.equal(MATTER_RVC_CLEAN_MODE.QUIET); + }); + + it('should convert Gladys LOW_NOISE to Matter LOW_NOISE', () => { + expect(convertGladysCleanModeToMatter(VACUUM_CLEANER_CLEAN_MODE.LOW_NOISE)).to.equal( + MATTER_RVC_CLEAN_MODE.LOW_NOISE, + ); + }); + it('should convert Gladys DEEP_CLEAN (4) to Matter DEEP_CLEAN (16384)', () => { expect(convertGladysCleanModeToMatter(VACUUM_CLEANER_CLEAN_MODE.DEEP_CLEAN)).to.equal( MATTER_RVC_CLEAN_MODE.DEEP_CLEAN, ); }); + it('should convert Gladys VACUUM (5) to Matter VACUUM (16385)', () => { + expect(convertGladysCleanModeToMatter(VACUUM_CLEANER_CLEAN_MODE.VACUUM)).to.equal(MATTER_RVC_CLEAN_MODE.VACUUM); + }); + it('should convert Gladys MOP (6) to Matter MOP (16386)', () => { expect(convertGladysCleanModeToMatter(VACUUM_CLEANER_CLEAN_MODE.MOP)).to.equal(MATTER_RVC_CLEAN_MODE.MOP); }); From 60f0f301ef36f9a9016d249140eb0b07866c38a2 Mon Sep 17 00:00:00 2001 From: Pierre-Gilles Leymarie <7365207+Pierre-Gilles@users.noreply.github.com> Date: Fri, 10 Apr 2026 16:39:30 +0200 Subject: [PATCH 06/10] Fix type bug --- server/services/matter/lib/matter.setValue.js | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/server/services/matter/lib/matter.setValue.js b/server/services/matter/lib/matter.setValue.js index f29f12e8c4..5527892ba3 100644 --- a/server/services/matter/lib/matter.setValue.js +++ b/server/services/matter/lib/matter.setValue.js @@ -219,8 +219,8 @@ async function setValue(gladysDevice, gladysFeature, value) { if (!rvcRunMode) { throw new Error('Device does not support RvcRunMode cluster'); } - // Convert Gladys standard mode to Matter mode - const matterMode = convertGladysRunModeToMatter(value); + // Convert Gladys standard mode to Matter mode (ensure value is a number) + const matterMode = convertGladysRunModeToMatter(Number(value)); await rvcRunMode.changeToMode({ newMode: matterMode }); } @@ -233,8 +233,8 @@ async function setValue(gladysDevice, gladysFeature, value) { if (!rvcCleanMode) { throw new Error('Device does not support RvcCleanMode cluster'); } - // Convert Gladys standard clean mode to Matter clean mode - const matterMode = convertGladysCleanModeToMatter(value); + // Convert Gladys standard clean mode to Matter clean mode (ensure value is a number) + const matterMode = convertGladysCleanModeToMatter(Number(value)); await rvcCleanMode.changeToMode({ newMode: matterMode }); } } From 2883aba74327cbb59790474854a97db30478b4b5 Mon Sep 17 00:00:00 2001 From: Pierre-Gilles Leymarie <7365207+Pierre-Gilles@users.noreply.github.com> Date: Mon, 13 Apr 2026 13:35:35 +0200 Subject: [PATCH 07/10] Add supportedModes handling --- server/services/matter/lib/index.js | 4 + .../matter/lib/matter.listenToStateChange.js | 38 +++- server/services/matter/lib/matter.setValue.js | 12 +- .../matter/utils/vacuumCleanerStateMapping.js | 175 +++++++++++++++++- .../utils/vacuumCleanerStateMapping.test.js | 131 +++++++++++++ 5 files changed, 348 insertions(+), 12 deletions(-) diff --git a/server/services/matter/lib/index.js b/server/services/matter/lib/index.js index 676b18ee50..4a41d80e6f 100644 --- a/server/services/matter/lib/index.js +++ b/server/services/matter/lib/index.js @@ -34,6 +34,10 @@ const MatterHandler = function MatterHandler(gladys, MatterMain, ProjectChipMatt this.nodesMap = new Map(); this.stateChangeListeners = new Set(); this.commissioningController = null; + // Map to store supported modes for RvcRunMode and RvcCleanMode clusters + // Key: external_id (e.g., "matter:nodeId:devicePath:clusterId") + // Value: { supportedModes: [{mode: number, label: string, modeTags: [...]}], modeTagToValue: Map } + this.supportedModesMap = new Map(); this.backupController = gladys.job.wrapper(JOB_TYPES.SERVICE_MATTER_BACKUP, this.backupController.bind(this)); process.on('SIGTERM', this.stop); process.on('SIGINT', this.stop); diff --git a/server/services/matter/lib/matter.listenToStateChange.js b/server/services/matter/lib/matter.listenToStateChange.js index 6378c56eb4..9b0cd5d691 100644 --- a/server/services/matter/lib/matter.listenToStateChange.js +++ b/server/services/matter/lib/matter.listenToStateChange.js @@ -438,13 +438,26 @@ async function listenToStateChange(nodeId, devicePath, device) { if (rvcRunMode && !this.stateChangeListeners.has(rvcRunMode)) { logger.debug(`Matter: Adding state change listener for RvcRunMode cluster ${rvcRunMode.name}`); this.stateChangeListeners.add(rvcRunMode); + + // Read and store supportedModes for this cluster + const externalId = `matter:${nodeId}:${devicePath}:${RvcRunMode.Complete.id}`; + try { + if (rvcRunMode.attributes && rvcRunMode.attributes.supportedModes) { + const supportedModes = await rvcRunMode.attributes.supportedModes.get(); + logger.info(`Matter: RvcRunMode supportedModes: ${JSON.stringify(supportedModes)}`); + this.supportedModesMap.set(externalId, { supportedModes, clusterType: 'RvcRunMode' }); + } + } catch (err) { + logger.warn(`Matter: Failed to read RvcRunMode supportedModes: ${err.message}`); + } + // Subscribe to RvcRunMode attribute changes rvcRunMode.addCurrentModeAttributeListener((value) => { logger.debug(`Matter: RvcRunMode currentMode attribute changed to ${value}`); - // Convert Matter mode to Gladys standard mode - const gladysMode = convertMatterRunModeToGladys(value); + // Convert Matter mode to Gladys standard mode using stored supportedModes or fallback + const gladysMode = convertMatterRunModeToGladys(value, this.supportedModesMap.get(externalId)); this.gladys.event.emit(EVENTS.DEVICE.NEW_STATE, { - device_feature_external_id: `matter:${nodeId}:${devicePath}:${RvcRunMode.Complete.id}`, + device_feature_external_id: externalId, state: gladysMode, }); }); @@ -454,13 +467,26 @@ async function listenToStateChange(nodeId, devicePath, device) { if (rvcCleanMode && !this.stateChangeListeners.has(rvcCleanMode)) { logger.debug(`Matter: Adding state change listener for RvcCleanMode cluster ${rvcCleanMode.name}`); this.stateChangeListeners.add(rvcCleanMode); + + // Read and store supportedModes for this cluster + const cleanModeExternalId = `matter:${nodeId}:${devicePath}:${RvcCleanMode.Complete.id}`; + try { + if (rvcCleanMode.attributes && rvcCleanMode.attributes.supportedModes) { + const supportedModes = await rvcCleanMode.attributes.supportedModes.get(); + logger.info(`Matter: RvcCleanMode supportedModes: ${JSON.stringify(supportedModes)}`); + this.supportedModesMap.set(cleanModeExternalId, { supportedModes, clusterType: 'RvcCleanMode' }); + } + } catch (err) { + logger.warn(`Matter: Failed to read RvcCleanMode supportedModes: ${err.message}`); + } + // Subscribe to RvcCleanMode attribute changes rvcCleanMode.addCurrentModeAttributeListener((value) => { logger.debug(`Matter: RvcCleanMode currentMode attribute changed to ${value}`); - // Convert Matter clean mode to Gladys standard clean mode - const gladysMode = convertMatterCleanModeToGladys(value); + // Convert Matter clean mode to Gladys standard clean mode using stored supportedModes or fallback + const gladysMode = convertMatterCleanModeToGladys(value, this.supportedModesMap.get(cleanModeExternalId)); this.gladys.event.emit(EVENTS.DEVICE.NEW_STATE, { - device_feature_external_id: `matter:${nodeId}:${devicePath}:${RvcCleanMode.Complete.id}`, + device_feature_external_id: cleanModeExternalId, state: gladysMode, }); }); diff --git a/server/services/matter/lib/matter.setValue.js b/server/services/matter/lib/matter.setValue.js index 5527892ba3..61c45d0a3d 100644 --- a/server/services/matter/lib/matter.setValue.js +++ b/server/services/matter/lib/matter.setValue.js @@ -219,8 +219,12 @@ async function setValue(gladysDevice, gladysFeature, value) { if (!rvcRunMode) { throw new Error('Device does not support RvcRunMode cluster'); } + // Get supportedModes for dynamic conversion + const runModeExternalId = gladysFeature.external_id; + const supportedModesData = this.supportedModesMap.get(runModeExternalId); // Convert Gladys standard mode to Matter mode (ensure value is a number) - const matterMode = convertGladysRunModeToMatter(Number(value)); + const matterMode = convertGladysRunModeToMatter(Number(value), supportedModesData); + logger.debug(`Matter: Setting RvcRunMode to ${matterMode} (Gladys value: ${value})`); await rvcRunMode.changeToMode({ newMode: matterMode }); } @@ -233,8 +237,12 @@ async function setValue(gladysDevice, gladysFeature, value) { if (!rvcCleanMode) { throw new Error('Device does not support RvcCleanMode cluster'); } + // Get supportedModes for dynamic conversion + const cleanModeExternalId = gladysFeature.external_id; + const supportedModesData = this.supportedModesMap.get(cleanModeExternalId); // Convert Gladys standard clean mode to Matter clean mode (ensure value is a number) - const matterMode = convertGladysCleanModeToMatter(Number(value)); + const matterMode = convertGladysCleanModeToMatter(Number(value), supportedModesData); + logger.debug(`Matter: Setting RvcCleanMode to ${matterMode} (Gladys value: ${value})`); await rvcCleanMode.changeToMode({ newMode: matterMode }); } } diff --git a/server/services/matter/utils/vacuumCleanerStateMapping.js b/server/services/matter/utils/vacuumCleanerStateMapping.js index 02f8b2eb74..3beed38453 100644 --- a/server/services/matter/utils/vacuumCleanerStateMapping.js +++ b/server/services/matter/utils/vacuumCleanerStateMapping.js @@ -39,6 +39,71 @@ const MATTER_RVC_CLEAN_MODE = { MOP: 16386, }; +/** + * Matter RvcRunMode ModeTag values (from Matter specification). + * These are used in the modeTags array of supportedModes to identify mode types. + */ +const MATTER_RVC_RUN_MODE_TAG = { + IDLE: 16384, // 0x4000 + CLEANING: 16385, // 0x4001 + MAPPING: 16386, // 0x4002 +}; + +/** + * Matter RvcCleanMode ModeTag values (from Matter specification). + * These are used in the modeTags array of supportedModes to identify mode types. + */ +const MATTER_RVC_CLEAN_MODE_TAG = { + DEEP_CLEAN: 16384, // 0x4000 + VACUUM: 16385, // 0x4001 + MOP: 16386, // 0x4002 +}; + +/** + * @description Find the Matter mode value for a given Gladys mode using supportedModes. + * @param {Array} supportedModes - The supportedModes array from the cluster. + * @param {number} targetModeTag - The ModeTag to search for. + * @returns {number|null} The Matter mode value, or null if not found. + */ +function findMatterModeByTag(supportedModes, targetModeTag) { + if (!supportedModes || !Array.isArray(supportedModes)) { + return null; + } + for (const mode of supportedModes) { + if (mode.modeTags && Array.isArray(mode.modeTags)) { + for (const tag of mode.modeTags) { + if (tag.value === targetModeTag) { + return mode.mode; + } + } + } + } + return null; +} + +/** + * @description Find the Gladys mode for a given Matter mode value using supportedModes. + * @param {Array} supportedModes - The supportedModes array from the cluster. + * @param {number} matterModeValue - The Matter mode value to convert. + * @param {object} modeTagMapping - Mapping of ModeTag values to Gladys mode values. + * @returns {number|null} The Gladys mode value, or null if not found. + */ +function findGladysModeByMatterValue(supportedModes, matterModeValue, modeTagMapping) { + if (!supportedModes || !Array.isArray(supportedModes)) { + return null; + } + for (const mode of supportedModes) { + if (mode.mode === matterModeValue && mode.modeTags && Array.isArray(mode.modeTags)) { + for (const tag of mode.modeTags) { + if (modeTagMapping[tag.value] !== undefined) { + return modeTagMapping[tag.value]; + } + } + } + } + return null; +} + /** * @description Convert Matter RvcOperationalState to Gladys vacuum cleaner state. * @param {number} matterState - The Matter RvcOperationalState value. @@ -95,14 +160,39 @@ function convertGladysOperationalStateToMatter(gladysState) { } } +/** + * Mapping of RvcRunMode ModeTag values to Gladys VACUUM_CLEANER_MODE values. + */ +const RVC_RUN_MODE_TAG_TO_GLADYS = { + [MATTER_RVC_RUN_MODE_TAG.IDLE]: VACUUM_CLEANER_MODE.IDLE, + [MATTER_RVC_RUN_MODE_TAG.CLEANING]: VACUUM_CLEANER_MODE.CLEANING, + [MATTER_RVC_RUN_MODE_TAG.MAPPING]: VACUUM_CLEANER_MODE.MAPPING, +}; + /** * @description Convert Matter RvcRunMode to Gladys vacuum cleaner mode. + * Uses dynamic supportedModes if available, otherwise falls back to static mapping. * @param {number} matterMode - The Matter RvcRunMode value. + * @param {object} supportedModesData - Optional object containing supportedModes array. * @returns {number} The Gladys vacuum cleaner mode. * @example * const gladysMode = convertMatterRunModeToGladys(1); // Returns VACUUM_CLEANER_MODE.CLEANING (1) */ -function convertMatterRunModeToGladys(matterMode) { +function convertMatterRunModeToGladys(matterMode, supportedModesData = null) { + // Try dynamic mapping using supportedModes + if (supportedModesData && supportedModesData.supportedModes) { + const gladysMode = findGladysModeByMatterValue( + supportedModesData.supportedModes, + matterMode, + RVC_RUN_MODE_TAG_TO_GLADYS, + ); + if (gladysMode !== null) { + return gladysMode; + } + logger.debug(`Matter: No ModeTag mapping found for RvcRunMode value ${matterMode}, using fallback`); + } + + // Fallback to static mapping (Matter spec default values) switch (matterMode) { case MATTER_RVC_RUN_MODE.IDLE: return VACUUM_CLEANER_MODE.IDLE; @@ -115,14 +205,39 @@ function convertMatterRunModeToGladys(matterMode) { } } +/** + * Mapping of Gladys VACUUM_CLEANER_MODE values to RvcRunMode ModeTag values. + */ +const GLADYS_TO_RVC_RUN_MODE_TAG = { + [VACUUM_CLEANER_MODE.IDLE]: MATTER_RVC_RUN_MODE_TAG.IDLE, + [VACUUM_CLEANER_MODE.CLEANING]: MATTER_RVC_RUN_MODE_TAG.CLEANING, + [VACUUM_CLEANER_MODE.MAPPING]: MATTER_RVC_RUN_MODE_TAG.MAPPING, +}; + /** * @description Convert Gladys vacuum cleaner mode to Matter RvcRunMode. + * Uses dynamic supportedModes if available, otherwise falls back to static mapping. * @param {number} gladysMode - The Gladys vacuum cleaner mode. + * @param {object} supportedModesData - Optional object containing supportedModes array. * @returns {number} The Matter RvcRunMode value. * @example * const matterMode = convertGladysRunModeToMatter(1); // Returns MATTER_RVC_RUN_MODE.CLEANING (1) */ -function convertGladysRunModeToMatter(gladysMode) { +function convertGladysRunModeToMatter(gladysMode, supportedModesData = null) { + // Try dynamic mapping using supportedModes + if (supportedModesData && supportedModesData.supportedModes) { + const targetModeTag = GLADYS_TO_RVC_RUN_MODE_TAG[gladysMode]; + if (targetModeTag !== undefined) { + const matterMode = findMatterModeByTag(supportedModesData.supportedModes, targetModeTag); + if (matterMode !== null) { + logger.debug(`Matter: Converted Gladys mode ${gladysMode} to Matter mode ${matterMode} using supportedModes`); + return matterMode; + } + } + logger.debug(`Matter: No supportedModes mapping found for Gladys mode ${gladysMode}, using fallback`); + } + + // Fallback to static mapping (Matter spec default values) switch (gladysMode) { case VACUUM_CLEANER_MODE.IDLE: return MATTER_RVC_RUN_MODE.IDLE; @@ -135,14 +250,48 @@ function convertGladysRunModeToMatter(gladysMode) { } } +/** + * Mapping of RvcCleanMode ModeTag values to Gladys VACUUM_CLEANER_CLEAN_MODE values. + */ +const RVC_CLEAN_MODE_TAG_TO_GLADYS = { + [MATTER_RVC_CLEAN_MODE_TAG.DEEP_CLEAN]: VACUUM_CLEANER_CLEAN_MODE.DEEP_CLEAN, + [MATTER_RVC_CLEAN_MODE_TAG.VACUUM]: VACUUM_CLEANER_CLEAN_MODE.VACUUM, + [MATTER_RVC_CLEAN_MODE_TAG.MOP]: VACUUM_CLEANER_CLEAN_MODE.MOP, +}; + +/** + * Mapping of Gladys VACUUM_CLEANER_CLEAN_MODE values to RvcCleanMode ModeTag values. + */ +const GLADYS_TO_RVC_CLEAN_MODE_TAG = { + [VACUUM_CLEANER_CLEAN_MODE.DEEP_CLEAN]: MATTER_RVC_CLEAN_MODE_TAG.DEEP_CLEAN, + [VACUUM_CLEANER_CLEAN_MODE.VACUUM]: MATTER_RVC_CLEAN_MODE_TAG.VACUUM, + [VACUUM_CLEANER_CLEAN_MODE.MOP]: MATTER_RVC_CLEAN_MODE_TAG.MOP, +}; + /** * @description Convert Matter RvcCleanMode to Gladys vacuum cleaner clean mode. + * Uses dynamic supportedModes if available, otherwise falls back to static mapping. * @param {number} matterMode - The Matter RvcCleanMode value. + * @param {object} supportedModesData - Optional object containing supportedModes array. * @returns {number|null} The Gladys vacuum cleaner clean mode, or null if unknown. * @example * const gladysMode = convertMatterCleanModeToGladys(0); // Returns VACUUM_CLEANER_CLEAN_MODE.AUTO (0) */ -function convertMatterCleanModeToGladys(matterMode) { +function convertMatterCleanModeToGladys(matterMode, supportedModesData = null) { + // Try dynamic mapping using supportedModes + if (supportedModesData && supportedModesData.supportedModes) { + const gladysMode = findGladysModeByMatterValue( + supportedModesData.supportedModes, + matterMode, + RVC_CLEAN_MODE_TAG_TO_GLADYS, + ); + if (gladysMode !== null) { + return gladysMode; + } + logger.debug(`Matter: No ModeTag mapping found for RvcCleanMode value ${matterMode}, using fallback`); + } + + // Fallback to static mapping (Matter spec default values) switch (matterMode) { case MATTER_RVC_CLEAN_MODE.AUTO: return VACUUM_CLEANER_CLEAN_MODE.AUTO; @@ -166,12 +315,30 @@ function convertMatterCleanModeToGladys(matterMode) { /** * @description Convert Gladys vacuum cleaner clean mode to Matter RvcCleanMode. + * Uses dynamic supportedModes if available, otherwise falls back to static mapping. * @param {number} gladysMode - The Gladys vacuum cleaner clean mode. + * @param {object} supportedModesData - Optional object containing supportedModes array. * @returns {number} The Matter RvcCleanMode value. * @example * const matterMode = convertGladysCleanModeToMatter(4); // Returns MATTER_RVC_CLEAN_MODE.DEEP_CLEAN (16384) */ -function convertGladysCleanModeToMatter(gladysMode) { +function convertGladysCleanModeToMatter(gladysMode, supportedModesData = null) { + // Try dynamic mapping using supportedModes + if (supportedModesData && supportedModesData.supportedModes) { + const targetModeTag = GLADYS_TO_RVC_CLEAN_MODE_TAG[gladysMode]; + if (targetModeTag !== undefined) { + const matterMode = findMatterModeByTag(supportedModesData.supportedModes, targetModeTag); + if (matterMode !== null) { + logger.debug( + `Matter: Converted Gladys clean mode ${gladysMode} to Matter mode ${matterMode} using supportedModes`, + ); + return matterMode; + } + } + logger.debug(`Matter: No supportedModes mapping found for Gladys clean mode ${gladysMode}, using fallback`); + } + + // Fallback to static mapping (Matter spec default values) switch (gladysMode) { case VACUUM_CLEANER_CLEAN_MODE.AUTO: return MATTER_RVC_CLEAN_MODE.AUTO; diff --git a/server/test/services/matter/utils/vacuumCleanerStateMapping.test.js b/server/test/services/matter/utils/vacuumCleanerStateMapping.test.js index 897ce3bcec..30931bd5a0 100644 --- a/server/test/services/matter/utils/vacuumCleanerStateMapping.test.js +++ b/server/test/services/matter/utils/vacuumCleanerStateMapping.test.js @@ -222,4 +222,135 @@ describe('Matter.vacuumCleanerStateMapping', () => { expect(convertGladysCleanModeToMatter(99)).to.equal(99); }); }); + + describe('dynamic supportedModes mapping', () => { + // Simulates Matterbridge Roborock plugin supportedModes where Idle=1, Cleaning=2 + const roborockRunModeSupportedModes = { + supportedModes: [ + { mode: 1, label: 'Idle', modeTags: [{ value: 16384 }] }, // ModeTag 16384 = Idle + { mode: 2, label: 'Cleaning', modeTags: [{ value: 16385 }] }, // ModeTag 16385 = Cleaning + ], + clusterType: 'RvcRunMode', + }; + + const roborockCleanModeSupportedModes = { + supportedModes: [{ mode: 1, label: 'Vacuum', modeTags: [{ value: 16385 }] }], // ModeTag 16385 = Vacuum + clusterType: 'RvcCleanMode', + }; + + describe('convertMatterRunModeToGladys with supportedModes', () => { + it('should convert Matter mode 1 to Gladys IDLE using supportedModes', () => { + expect(convertMatterRunModeToGladys(1, roborockRunModeSupportedModes)).to.equal(VACUUM_CLEANER_MODE.IDLE); + }); + + it('should convert Matter mode 2 to Gladys CLEANING using supportedModes', () => { + expect(convertMatterRunModeToGladys(2, roborockRunModeSupportedModes)).to.equal(VACUUM_CLEANER_MODE.CLEANING); + }); + + it('should fallback to static mapping when supportedModes is null', () => { + expect(convertMatterRunModeToGladys(0, null)).to.equal(VACUUM_CLEANER_MODE.IDLE); + }); + }); + + describe('convertGladysRunModeToMatter with supportedModes', () => { + it('should convert Gladys IDLE to Matter mode 1 using supportedModes', () => { + expect(convertGladysRunModeToMatter(VACUUM_CLEANER_MODE.IDLE, roborockRunModeSupportedModes)).to.equal(1); + }); + + it('should convert Gladys CLEANING to Matter mode 2 using supportedModes', () => { + expect(convertGladysRunModeToMatter(VACUUM_CLEANER_MODE.CLEANING, roborockRunModeSupportedModes)).to.equal(2); + }); + + it('should fallback to static mapping when supportedModes is null', () => { + expect(convertGladysRunModeToMatter(VACUUM_CLEANER_MODE.IDLE, null)).to.equal(MATTER_RVC_RUN_MODE.IDLE); + }); + }); + + describe('convertMatterCleanModeToGladys with supportedModes', () => { + it('should convert Matter mode 1 to Gladys VACUUM using supportedModes', () => { + expect(convertMatterCleanModeToGladys(1, roborockCleanModeSupportedModes)).to.equal( + VACUUM_CLEANER_CLEAN_MODE.VACUUM, + ); + }); + + it('should fallback to static mapping when supportedModes is null', () => { + expect(convertMatterCleanModeToGladys(0, null)).to.equal(VACUUM_CLEANER_CLEAN_MODE.AUTO); + }); + }); + + describe('convertGladysCleanModeToMatter with supportedModes', () => { + it('should convert Gladys VACUUM to Matter mode 1 using supportedModes', () => { + expect( + convertGladysCleanModeToMatter(VACUUM_CLEANER_CLEAN_MODE.VACUUM, roborockCleanModeSupportedModes), + ).to.equal(1); + }); + + it('should fallback to static mapping when supportedModes is null', () => { + expect(convertGladysCleanModeToMatter(VACUUM_CLEANER_CLEAN_MODE.AUTO, null)).to.equal( + MATTER_RVC_CLEAN_MODE.AUTO, + ); + }); + }); + + describe('fallback when mode not found in supportedModes', () => { + // supportedModes that doesn't contain all modes + const incompleteSupportedModes = { + supportedModes: [{ mode: 1, label: 'Idle', modeTags: [{ value: 16384 }] }], + clusterType: 'RvcRunMode', + }; + + const incompleteCleanModeSupportedModes = { + supportedModes: [{ mode: 1, label: 'Vacuum', modeTags: [{ value: 16385 }] }], + clusterType: 'RvcCleanMode', + }; + + it('should fallback when Matter mode not found in supportedModes (RvcRunMode)', () => { + // Mode 99 is not in supportedModes, should fallback to static mapping + expect(convertMatterRunModeToGladys(99, incompleteSupportedModes)).to.equal(99); + }); + + it('should fallback when Gladys mode not found in supportedModes (RvcRunMode)', () => { + // MAPPING mode tag is not in supportedModes, should fallback to static mapping + expect(convertGladysRunModeToMatter(VACUUM_CLEANER_MODE.MAPPING, incompleteSupportedModes)).to.equal( + MATTER_RVC_RUN_MODE.MAPPING, + ); + }); + + it('should fallback when Matter mode not found in supportedModes (RvcCleanMode)', () => { + // Mode 99 is not in supportedModes, should fallback to static mapping + expect(convertMatterCleanModeToGladys(99, incompleteCleanModeSupportedModes)).to.equal(99); + }); + + it('should fallback when Gladys mode not found in supportedModes (RvcCleanMode)', () => { + // MOP mode tag is not in supportedModes, should fallback to static mapping + expect( + convertGladysCleanModeToMatter(VACUUM_CLEANER_CLEAN_MODE.MOP, incompleteCleanModeSupportedModes), + ).to.equal(MATTER_RVC_CLEAN_MODE.MOP); + }); + }); + + describe('edge cases with invalid supportedModes data', () => { + it('should fallback when supportedModes is not an array (RvcRunMode)', () => { + const invalidData = { supportedModes: 'not-an-array', clusterType: 'RvcRunMode' }; + expect(convertMatterRunModeToGladys(0, invalidData)).to.equal(VACUUM_CLEANER_MODE.IDLE); + }); + + it('should fallback when supportedModes is not an array (RvcCleanMode)', () => { + const invalidData = { supportedModes: 'not-an-array', clusterType: 'RvcCleanMode' }; + expect(convertMatterCleanModeToGladys(0, invalidData)).to.equal(VACUUM_CLEANER_CLEAN_MODE.AUTO); + }); + + it('should fallback when supportedModes is not an array for Gladys to Matter (RvcRunMode)', () => { + const invalidData = { supportedModes: 'not-an-array', clusterType: 'RvcRunMode' }; + expect(convertGladysRunModeToMatter(VACUUM_CLEANER_MODE.IDLE, invalidData)).to.equal(MATTER_RVC_RUN_MODE.IDLE); + }); + + it('should fallback when supportedModes is not an array for Gladys to Matter (RvcCleanMode)', () => { + const invalidData = { supportedModes: 'not-an-array', clusterType: 'RvcCleanMode' }; + expect(convertGladysCleanModeToMatter(VACUUM_CLEANER_CLEAN_MODE.AUTO, invalidData)).to.equal( + MATTER_RVC_CLEAN_MODE.AUTO, + ); + }); + }); + }); }); From 3b51d5a471ede72060f5a321c5803a604488a685 Mon Sep 17 00:00:00 2001 From: Pierre-Gilles Leymarie <7365207+Pierre-Gilles@users.noreply.github.com> Date: Mon, 13 Apr 2026 13:39:49 +0200 Subject: [PATCH 08/10] Fix eslint --- .../matter/utils/vacuumCleanerStateMapping.js | 34 ++++++++----------- 1 file changed, 15 insertions(+), 19 deletions(-) diff --git a/server/services/matter/utils/vacuumCleanerStateMapping.js b/server/services/matter/utils/vacuumCleanerStateMapping.js index 3beed38453..ee80bcbe21 100644 --- a/server/services/matter/utils/vacuumCleanerStateMapping.js +++ b/server/services/matter/utils/vacuumCleanerStateMapping.js @@ -64,21 +64,17 @@ const MATTER_RVC_CLEAN_MODE_TAG = { * @param {Array} supportedModes - The supportedModes array from the cluster. * @param {number} targetModeTag - The ModeTag to search for. * @returns {number|null} The Matter mode value, or null if not found. + * @example + * const matterMode = findMatterModeByTag(supportedModes, 16384); // Returns mode value for Idle tag */ function findMatterModeByTag(supportedModes, targetModeTag) { if (!supportedModes || !Array.isArray(supportedModes)) { return null; } - for (const mode of supportedModes) { - if (mode.modeTags && Array.isArray(mode.modeTags)) { - for (const tag of mode.modeTags) { - if (tag.value === targetModeTag) { - return mode.mode; - } - } - } - } - return null; + const foundMode = supportedModes.find( + (mode) => mode.modeTags && Array.isArray(mode.modeTags) && mode.modeTags.some((tag) => tag.value === targetModeTag), + ); + return foundMode ? foundMode.mode : null; } /** @@ -87,21 +83,21 @@ function findMatterModeByTag(supportedModes, targetModeTag) { * @param {number} matterModeValue - The Matter mode value to convert. * @param {object} modeTagMapping - Mapping of ModeTag values to Gladys mode values. * @returns {number|null} The Gladys mode value, or null if not found. + * @example + * const gladysMode = findGladysModeByMatterValue(supportedModes, 1, tagMapping); // Returns Gladys mode */ function findGladysModeByMatterValue(supportedModes, matterModeValue, modeTagMapping) { if (!supportedModes || !Array.isArray(supportedModes)) { return null; } - for (const mode of supportedModes) { - if (mode.mode === matterModeValue && mode.modeTags && Array.isArray(mode.modeTags)) { - for (const tag of mode.modeTags) { - if (modeTagMapping[tag.value] !== undefined) { - return modeTagMapping[tag.value]; - } - } - } + const matchingMode = supportedModes.find( + (mode) => mode.mode === matterModeValue && mode.modeTags && Array.isArray(mode.modeTags), + ); + if (!matchingMode) { + return null; } - return null; + const matchingTag = matchingMode.modeTags.find((tag) => modeTagMapping[tag.value] !== undefined); + return matchingTag ? modeTagMapping[matchingTag.value] : null; } /** From d0c8523f2d4fac814918cc3b176ff68a9a6a473c Mon Sep 17 00:00:00 2001 From: Pierre-Gilles Leymarie <7365207+Pierre-Gilles@users.noreply.github.com> Date: Mon, 13 Apr 2026 13:41:47 +0200 Subject: [PATCH 09/10] Fix Rabbit feedback --- server/services/matter/lib/matter.setValue.js | 2 ++ server/test/services/matter/lib/matter.setValue.test.js | 5 +++-- 2 files changed, 5 insertions(+), 2 deletions(-) diff --git a/server/services/matter/lib/matter.setValue.js b/server/services/matter/lib/matter.setValue.js index 61c45d0a3d..9ef7a35ebd 100644 --- a/server/services/matter/lib/matter.setValue.js +++ b/server/services/matter/lib/matter.setValue.js @@ -207,6 +207,8 @@ async function setValue(gladysDevice, gladysFeature, value) { } if (value === 1) { await rvcOperationalState.goHome(); + } else { + throw new Error(`Unsupported dock command value: ${value}. Only value 1 (go home) is supported.`); } } diff --git a/server/test/services/matter/lib/matter.setValue.test.js b/server/test/services/matter/lib/matter.setValue.test.js index cb958d0e60..96554e27dd 100644 --- a/server/test/services/matter/lib/matter.setValue.test.js +++ b/server/test/services/matter/lib/matter.setValue.test.js @@ -587,7 +587,7 @@ describe('Matter.setValue', () => { await matterHandler.setValue(gladysDevice, gladysFeature, value); assert.calledOnce(rvcOperationalStateCluster.goHome); }); - it('should not call goHome when dock value is 0', async () => { + it('should throw error when dock value is not 1', async () => { const gladysDevice = { external_id: 'matter:12345:1:child_endpoint:2', }; @@ -621,7 +621,8 @@ describe('Matter.setValue', () => { ]), }); - await matterHandler.setValue(gladysDevice, gladysFeature, value); + const promise = matterHandler.setValue(gladysDevice, gladysFeature, value); + await chaiAssert.isRejected(promise, 'Unsupported dock command value: 0. Only value 1 (go home) is supported.'); assert.notCalled(rvcOperationalStateCluster.goHome); }); it('should change vacuum cleaner run mode', async () => { From d12f685369c73dd1747c02e004c3acb8a447dc50 Mon Sep 17 00:00:00 2001 From: Pierre-Gilles Leymarie <7365207+Pierre-Gilles@users.noreply.github.com> Date: Mon, 13 Apr 2026 13:50:31 +0200 Subject: [PATCH 10/10] Add missing tests --- .../matter/lib/listenToStateChange.test.js | 102 ++++++++++++++++++ 1 file changed, 102 insertions(+) diff --git a/server/test/services/matter/lib/listenToStateChange.test.js b/server/test/services/matter/lib/listenToStateChange.test.js index f71b16c9fd..4334ef06c4 100644 --- a/server/test/services/matter/lib/listenToStateChange.test.js +++ b/server/test/services/matter/lib/listenToStateChange.test.js @@ -25,6 +25,7 @@ const { } = require('@matter/main/clusters'); const sinon = require('sinon'); +const { expect } = require('chai'); const { fake, assert } = sinon; @@ -568,6 +569,58 @@ describe('Matter.listenToStateChange', () => { state: 1, }); }); + it('should listen to state change (RvcRunMode) with supportedModes', async () => { + const clusterClients = new Map(); + const supportedModes = [ + { mode: 1, label: 'Idle', modeTags: [{ value: 16384 }] }, + { mode: 2, label: 'Cleaning', modeTags: [{ value: 16385 }] }, + ]; + clusterClients.set(RvcRunMode.Complete.id, { + attributes: { + supportedModes: { + get: fake.resolves(supportedModes), + }, + }, + addCurrentModeAttributeListener: (callback) => { + callback(2); // Matter mode 2 with ModeTag 16385 (Cleaning) -> Gladys CLEANING (1) + }, + }); + const device = { + number: 2, + clusterClients, + }; + await matterHandler.listenToStateChange(1234n, '2', device); + assert.calledWith(gladys.event.emit, EVENTS.DEVICE.NEW_STATE, { + device_feature_external_id: 'matter:1234:2:84', + state: 1, // Gladys CLEANING mode + }); + // Verify supportedModes was stored + const storedData = matterHandler.supportedModesMap.get('matter:1234:2:84'); + expect(storedData).to.deep.equal({ supportedModes, clusterType: 'RvcRunMode' }); + }); + it('should handle RvcRunMode supportedModes read failure gracefully', async () => { + const clusterClients = new Map(); + clusterClients.set(RvcRunMode.Complete.id, { + attributes: { + supportedModes: { + get: fake.rejects(new Error('Read failed')), + }, + }, + addCurrentModeAttributeListener: (callback) => { + callback(1); + }, + }); + const device = { + number: 2, + clusterClients, + }; + // Should not throw, just log warning + await matterHandler.listenToStateChange(1234n, '2', device); + assert.calledWith(gladys.event.emit, EVENTS.DEVICE.NEW_STATE, { + device_feature_external_id: 'matter:1234:2:84', + state: 1, + }); + }); it('should listen to state change (RvcCleanMode)', async () => { const clusterClients = new Map(); clusterClients.set(RvcCleanMode.Complete.id, { @@ -585,6 +638,55 @@ describe('Matter.listenToStateChange', () => { state: 4, // Gladys standard DEEP_CLEAN mode }); }); + it('should listen to state change (RvcCleanMode) with supportedModes', async () => { + const clusterClients = new Map(); + const supportedModes = [{ mode: 1, label: 'Vacuum', modeTags: [{ value: 16385 }] }]; + clusterClients.set(RvcCleanMode.Complete.id, { + attributes: { + supportedModes: { + get: fake.resolves(supportedModes), + }, + }, + addCurrentModeAttributeListener: (callback) => { + callback(1); // Matter mode 1 with ModeTag 16385 (Vacuum) -> Gladys VACUUM (5) + }, + }); + const device = { + number: 2, + clusterClients, + }; + await matterHandler.listenToStateChange(1234n, '2', device); + assert.calledWith(gladys.event.emit, EVENTS.DEVICE.NEW_STATE, { + device_feature_external_id: 'matter:1234:2:85', + state: 5, // Gladys VACUUM mode + }); + // Verify supportedModes was stored + const storedData = matterHandler.supportedModesMap.get('matter:1234:2:85'); + expect(storedData).to.deep.equal({ supportedModes, clusterType: 'RvcCleanMode' }); + }); + it('should handle RvcCleanMode supportedModes read failure gracefully', async () => { + const clusterClients = new Map(); + clusterClients.set(RvcCleanMode.Complete.id, { + attributes: { + supportedModes: { + get: fake.rejects(new Error('Read failed')), + }, + }, + addCurrentModeAttributeListener: (callback) => { + callback(0); + }, + }); + const device = { + number: 2, + clusterClients, + }; + // Should not throw, just log warning + await matterHandler.listenToStateChange(1234n, '2', device); + assert.calledWith(gladys.event.emit, EVENTS.DEVICE.NEW_STATE, { + device_feature_external_id: 'matter:1234:2:85', + state: 0, + }); + }); it('should listen to state change (PowerSource battery)', async () => { const clusterClients = new Map(); clusterClients.set(PowerSource.Complete.id, {