diff --git a/front/src/components/boxs/device-in-room/device-features/sensor-value/BadgeNumberDeviceValue.jsx b/front/src/components/boxs/device-in-room/device-features/sensor-value/BadgeNumberDeviceValue.jsx
index 7d52000aa9..1c1064b1cb 100644
--- a/front/src/components/boxs/device-in-room/device-features/sensor-value/BadgeNumberDeviceValue.jsx
+++ b/front/src/components/boxs/device-in-room/device-features/sensor-value/BadgeNumberDeviceValue.jsx
@@ -2,7 +2,12 @@ import { Text } from 'preact-i18n';
import get from 'get-value';
import cx from 'classnames';
-import { DEVICE_FEATURE_CATEGORIES, DEVICE_FEATURE_UNITS } from '../../../../../../../server/utils/constants';
+import {
+ AIR_QUALITY_LEVEL,
+ DEVICE_FEATURE_CATEGORIES,
+ DEVICE_FEATURE_TYPES,
+ DEVICE_FEATURE_UNITS
+} from '../../../../../../../server/utils/constants';
import RawDeviceValue from './RawDeviceValue';
// Mass concentrations are declared in milligrams, micrograms or nanograms per cubic meter, while
@@ -99,6 +104,22 @@ const getLevelMatterIndexColor = value => {
return LEVEL_MATTER_INDEX_COLOR[value];
};
+// Same palette as the numeric air quality index, so the two ways of reporting air quality read
+// the same on a dashboard mixing devices.
+const AIR_QUALITY_LEVEL_COLOR = {
+ [AIR_QUALITY_LEVEL.UNKNOWN]: 'secondary',
+ [AIR_QUALITY_LEVEL.GOOD]: 'success',
+ [AIR_QUALITY_LEVEL.FAIR]: 'warning',
+ [AIR_QUALITY_LEVEL.MODERATE]: 'orange',
+ [AIR_QUALITY_LEVEL.POOR]: 'pink',
+ [AIR_QUALITY_LEVEL.VERY_POOR]: 'purple',
+ [AIR_QUALITY_LEVEL.EXTREMELY_POOR]: 'danger'
+};
+
+const getAirQualityLevelColor = value => {
+ return AIR_QUALITY_LEVEL_COLOR[value] || 'secondary';
+};
+
const BADGE_CATEGORIES = {
[DEVICE_FEATURE_CATEGORIES.CO2_SENSOR]: value => colorLowAsGreen(value, 600, 1200),
[DEVICE_FEATURE_CATEGORIES.VOC_SENSOR]: value => colorLowAsGreen(value, 250, 2000),
@@ -142,10 +163,25 @@ const BADGE_VALUE_CONVERTERS = {
}
};
+// A category can mix value scales across its types: air quality reports either a numeric index or
+// a qualitative level, and a level read on the index scale would stay green whatever the air is.
+// A type-specific color method therefore takes precedence over the category one.
+const BADGE_CATEGORIES_BY_TYPE = {
+ [DEVICE_FEATURE_CATEGORIES.AIRQUALITY_SENSOR]: {
+ [DEVICE_FEATURE_TYPES.AIRQUALITY_SENSOR.LEVEL]: value => getAirQualityLevelColor(value)
+ }
+};
+
+// Enum features whose translation key is the raw value, with no string conversion step: the
+// "unknown" label below catches a value the enum does not know.
+const BADGE_ENUM_TYPES_BY_CATEGORY = {
+ [DEVICE_FEATURE_CATEGORIES.AIRQUALITY_SENSOR]: [DEVICE_FEATURE_TYPES.AIRQUALITY_SENSOR.LEVEL]
+};
+
const BadgeNumberDeviceValue = props => {
const { category, type, last_value: lastValue = null, unit } = props.deviceFeature;
- const colorMethod = BADGE_CATEGORIES[category];
+ const colorMethod = get(BADGE_CATEGORIES_BY_TYPE, `${category}.${type}`) || BADGE_CATEGORIES[category];
if (!colorMethod) {
return ;
}
@@ -159,6 +195,8 @@ const BadgeNumberDeviceValue = props => {
if (BADGE_VALUE_CONVERTERS[category]) {
value = get(BADGE_VALUE_CONVERTERS[category], value, 'unknown');
valueIsEnum = true;
+ } else if ((BADGE_ENUM_TYPES_BY_CATEGORY[category] || []).includes(type)) {
+ valueIsEnum = true;
}
const colorClass = `bg-${valued ? colorMethod(value, unit) : 'secondary'}`;
@@ -174,7 +212,9 @@ const BadgeNumberDeviceValue = props => {
)}
{valued && valueIsEnum && (
-
+
+
+
)}
diff --git a/front/src/config/i18n/de.json b/front/src/config/i18n/de.json
index 4703487ea2..d026b3b719 100644
--- a/front/src/config/i18n/de.json
+++ b/front/src/config/i18n/de.json
@@ -5034,6 +5034,18 @@
"5": "Entlädt"
}
},
+ "airquality-sensor": {
+ "level": {
+ "unknown": "{{value}} (unbekannt)",
+ "0": "Unbekannt",
+ "1": "Gut",
+ "2": "Ausreichend",
+ "3": "Mäßig",
+ "4": "Schlecht",
+ "5": "Sehr schlecht",
+ "6": "Extrem schlecht"
+ }
+ },
"voc-matter-index-sensor": {
"integer": {
"unknown": "Unbekannt",
@@ -5621,7 +5633,8 @@
},
"airquality-sensor": {
"shortCategoryName": "Luftqualität",
- "aqi": "Luftqualitätsindex"
+ "aqi": "Luftqualitätsindex",
+ "level": "Luftqualitätsstufe"
},
"ph-sensor": {
"shortCategoryName": "pH-Sensor",
diff --git a/front/src/config/i18n/en.json b/front/src/config/i18n/en.json
index 3080417573..21d3096418 100644
--- a/front/src/config/i18n/en.json
+++ b/front/src/config/i18n/en.json
@@ -5034,6 +5034,18 @@
"5": "Discharging"
}
},
+ "airquality-sensor": {
+ "level": {
+ "unknown": "{{value}} (unknown)",
+ "0": "Unknown",
+ "1": "Good",
+ "2": "Fair",
+ "3": "Moderate",
+ "4": "Poor",
+ "5": "Very poor",
+ "6": "Extremely poor"
+ }
+ },
"voc-matter-index-sensor": {
"integer": {
"unknown": "Unknown",
@@ -5621,7 +5633,8 @@
},
"airquality-sensor": {
"shortCategoryName": "Air Quality",
- "aqi": "Air Quality Index"
+ "aqi": "Air Quality Index",
+ "level": "Air Quality Level"
},
"ph-sensor": {
"shortCategoryName": "pH Sensor",
diff --git a/front/src/config/i18n/fr.json b/front/src/config/i18n/fr.json
index 152327809a..cb790c6dda 100644
--- a/front/src/config/i18n/fr.json
+++ b/front/src/config/i18n/fr.json
@@ -5034,6 +5034,18 @@
"5": "En décharge"
}
},
+ "airquality-sensor": {
+ "level": {
+ "unknown": "{{value}} (inconnu)",
+ "0": "Inconnue",
+ "1": "Bonne",
+ "2": "Correcte",
+ "3": "Moyenne",
+ "4": "Mauvaise",
+ "5": "Très mauvaise",
+ "6": "Extrêmement mauvaise"
+ }
+ },
"voc-matter-index-sensor": {
"integer": {
"unknown": "Inconnu",
@@ -5621,7 +5633,8 @@
},
"airquality-sensor": {
"shortCategoryName": "Qualité de l'air",
- "aqi": "Indice de qualité de l'air"
+ "aqi": "Indice de qualité de l'air",
+ "level": "Niveau de qualité de l'air"
},
"ph-sensor": {
"shortCategoryName": "Capteur de pH",
diff --git a/front/src/routes/integration/all/mqtt/device-page/utils.js b/front/src/routes/integration/all/mqtt/device-page/utils.js
index 8b9cd4d632..d749ad66a3 100644
--- a/front/src/routes/integration/all/mqtt/device-page/utils.js
+++ b/front/src/routes/integration/all/mqtt/device-page/utils.js
@@ -6,7 +6,8 @@ import {
DEVICE_FEATURE_UNITS_BY_CATEGORY,
CHARGING_STATION_CONNECTOR_STATUS,
CHARGING_STATION_CHARGING_STATE,
- WATER_HEATER_MODE
+ WATER_HEATER_MODE,
+ AIR_QUALITY_LEVEL
} from '../../../../../../../server/utils/constants';
import { slugify } from '../../../../../../../server/utils/slugify';
import { CAMERA_MOVE_OPTIONS } from '../../../../../utils/cameraMove';
@@ -88,6 +89,8 @@ const CATEGORIES_WITHOUT_UNIT = new Set([
categoryTypeKey(DEVICE_FEATURE_CATEGORIES.COUNTER_SENSOR, 'integer'),
categoryTypeKey(DEVICE_FEATURE_CATEGORIES.COUNTER_SENSOR, 'decimal'),
categoryTypeKey(DEVICE_FEATURE_CATEGORIES.FAN, DEVICE_FEATURE_TYPES.FAN.SPEED),
+ // The qualitative air quality level is an enum: unlike `aqi`, it carries no unit.
+ categoryTypeKey(DEVICE_FEATURE_CATEGORIES.AIRQUALITY_SENSOR, DEVICE_FEATURE_TYPES.AIRQUALITY_SENSOR.LEVEL),
categoryTypeKey(DEVICE_FEATURE_CATEGORIES.UV_SENSOR, 'integer'),
categoryTypeKey(DEVICE_FEATURE_CATEGORIES.PH_SENSOR, 'decimal'),
categoryTypeKey(DEVICE_FEATURE_CATEGORIES.CUBE, DEVICE_FEATURE_TYPES.CUBE.MODE),
@@ -837,6 +840,18 @@ export const getFeatureDefaultValues = (category, type) => {
return applyDefaultUnit({ ...defaults, min: 0, max: 4, read_only: true }, category, type);
}
+ // The qualitative air quality level is an enum (Unknown to Extremely poor), not a 0-100 index.
+ if (
+ category === DEVICE_FEATURE_CATEGORIES.AIRQUALITY_SENSOR &&
+ type === DEVICE_FEATURE_TYPES.AIRQUALITY_SENSOR.LEVEL
+ ) {
+ return applyDefaultUnit(
+ { ...defaults, min: AIR_QUALITY_LEVEL.UNKNOWN, max: AIR_QUALITY_LEVEL.EXTREMELY_POOR, read_only: true },
+ category,
+ type
+ );
+ }
+
if (!isSensorCategory(category)) {
return applyDefaultUnit({ ...defaults, min: 0, max: 100, read_only: false }, category, type);
}
@@ -863,6 +878,10 @@ export const getCatalogPreviewLabelKey = (category, type) => {
DEVICE_FEATURE_CATEGORIES.NO2_MATTER_INDEX_SENSOR,
'integer'
)]: 'deviceFeatureValue.category.no2-matter-index-sensor.integer.medium',
+ [categoryTypeKey(
+ DEVICE_FEATURE_CATEGORIES.AIRQUALITY_SENSOR,
+ DEVICE_FEATURE_TYPES.AIRQUALITY_SENSOR.LEVEL
+ )]: `deviceFeatureValue.category.airquality-sensor.level.${AIR_QUALITY_LEVEL.GOOD}`,
[categoryTypeKey(DEVICE_FEATURE_CATEGORIES.RISK, 'integer')]: 'deviceFeatureValue.category.risk.integer.low-risk',
[categoryTypeKey(
DEVICE_FEATURE_CATEGORIES.ELECTRICAL_VEHICLE_CHARGE,
@@ -884,6 +903,13 @@ export const getCatalogPreviewLabelKey = (category, type) => {
export const getFeaturePreviewValue = (category, type) => {
// Category-specific blocks first: some of their types ('power', 'index', 'target-temperature',
// 'mode') also exist in other categories matched below by type only.
+ if (
+ category === DEVICE_FEATURE_CATEGORIES.AIRQUALITY_SENSOR &&
+ type === DEVICE_FEATURE_TYPES.AIRQUALITY_SENSOR.LEVEL
+ ) {
+ return AIR_QUALITY_LEVEL.GOOD;
+ }
+
if (category === DEVICE_FEATURE_CATEGORIES.WATER_HEATER) {
if (type === DEVICE_FEATURE_TYPES.WATER_HEATER.MODE) {
return WATER_HEATER_MODE.ECO;
diff --git a/front/src/utils/consts.js b/front/src/utils/consts.js
index df8d475b62..c5bcbb55db 100644
--- a/front/src/utils/consts.js
+++ b/front/src/utils/consts.js
@@ -503,7 +503,8 @@ export const DeviceFeatureCategoriesIcon = {
[DEVICE_FEATURE_TYPES.THERMOSTAT.OPERATING_STATE]: 'power'
},
[DEVICE_FEATURE_CATEGORIES.AIRQUALITY_SENSOR]: {
- [DEVICE_FEATURE_TYPES.AIRQUALITY_SENSOR.AQI]: 'bar-chart-2'
+ [DEVICE_FEATURE_TYPES.AIRQUALITY_SENSOR.AQI]: 'bar-chart-2',
+ [DEVICE_FEATURE_TYPES.AIRQUALITY_SENSOR.LEVEL]: 'bar-chart-2'
},
[DEVICE_FEATURE_CATEGORIES.PH_SENSOR]: {
[DEVICE_FEATURE_TYPES.PH_SENSOR.DECIMAL]: 'droplet'
diff --git a/server/services/matter/README.md b/server/services/matter/README.md
index ba20fbd61c..08af86c2aa 100644
--- a/server/services/matter/README.md
+++ b/server/services/matter/README.md
@@ -4,9 +4,9 @@ This file documents Matter cluster compatibility in the Gladys Matter integratio
- `matter.js` source: `@matter/main` / `@matter/types` `0.17.4`
- Clusters exposed by `matter.js`: **132**
-- Clusters handled by Gladys today: **26**
-- Compatibility progress: **19.7%**
-- Clusters with an existing Gladys feature (easy to wire): **26** additional
+- Clusters handled by Gladys today: **27**
+- Compatibility progress: **20.5%**
+- Clusters with an existing Gladys feature (easy to wire): **25** additional
A cluster is marked as handled when the current Gladys Matter integration contains explicit mapping logic for discovery, state reading/listening, and/or commands for that cluster.
@@ -19,7 +19,7 @@ The **Gladys feature** column lists matching `category/type` pairs from `server/
| `Actions` | This cluster provides a standardized way for a Node (typically a Bridge, but could be any Node) to expose logical grouping and actions. | No | — | Not explicitly handled in `server/services/matter`. |
| `ActivatedCarbonFilterMonitoring` | Reports the condition and remaining lifetime of an activated carbon filter. | No | hepa-filter-monitoring/filter-life-remaining (easy) | Not explicitly handled in `server/services/matter`. |
| `AdministratorCommissioning` | This cluster is used to trigger a Node to allow a new Administrator to commission it. | No | — | Not explicitly handled in `server/services/matter`. |
-| `AirQuality` | This cluster provides an interface to air quality classification using distinct levels with human-readable labels. | No | airquality-sensor/aqi (easy) | Not explicitly handled in `server/services/matter`. |
+| `AirQuality` | This cluster provides an interface to air quality classification using distinct levels with human-readable labels. | Yes | airquality-sensor/level | Overall air quality level (Unknown/Good/Fair/Moderate/Poor/VeryPoor/ExtremelyPoor), read-only. |
| `ApplicationBasic` | This cluster provides information about a Content App running on a Video Player device which is represented as an endpoint (see Device Type Library document). | No | — | Not explicitly handled in `server/services/matter`. |
| `ApplicationLauncher` | This cluster provides an interface for launching applications on a Video Player device such as a TV. | No | — | Not explicitly handled in `server/services/matter`. |
| `AudioOutput` | This cluster provides an interface for controlling the Output on a Video Player device such as a TV. | No | television/volume (easy) | Not explicitly handled in `server/services/matter`. |
@@ -149,7 +149,7 @@ The **Gladys feature** column lists matching `category/type` pairs from `server/
## How the percentage is calculated
-`26 / 132 = 19.7%`
+`27 / 132 = 20.5%`
This percentage reflects the number of Matter cluster definitions exported by `matter.js` that have explicit support in the current Gladys integration. It is a cluster support coverage indicator, not a guarantee that every device implementing a supported cluster will be fully interoperable across all feature combinations.
diff --git a/server/services/matter/lib/matter.listenToStateChange.js b/server/services/matter/lib/matter.listenToStateChange.js
index 22b28d60c6..a581049d8d 100644
--- a/server/services/matter/lib/matter.listenToStateChange.js
+++ b/server/services/matter/lib/matter.listenToStateChange.js
@@ -10,6 +10,7 @@ const {
ColorControl,
RelativeHumidityMeasurement,
Thermostat,
+ AirQuality,
Pm25ConcentrationMeasurement,
Pm10ConcentrationMeasurement,
TotalVolatileOrganicCompoundsConcentrationMeasurement,
@@ -252,6 +253,20 @@ async function listenToStateChange(nodeId, devicePath, device) {
});
}
+ const airQuality = device.getClusterClientById(AirQuality.Complete.id);
+ if (airQuality && !this.stateChangeListeners.has(airQuality)) {
+ logger.debug(`Matter: Adding state change listener for AirQuality cluster ${airQuality.name}`);
+ this.stateChangeListeners.add(airQuality);
+ // Subscribe to AirQuality attribute changes
+ airQuality.addAirQualityAttributeListener((value) => {
+ logger.debug(`Matter: AirQuality attribute changed to ${value}`);
+ this.gladys.event.emit(EVENTS.DEVICE.NEW_STATE, {
+ device_feature_external_id: `matter:${nodeId}:${devicePath}:${AirQuality.Complete.id}`,
+ state: value,
+ });
+ });
+ }
+
const pm25ConcentrationMeasurement = device.getClusterClientById(Pm25ConcentrationMeasurement.Complete.id);
if (pm25ConcentrationMeasurement && !this.stateChangeListeners.has(pm25ConcentrationMeasurement)) {
logger.debug(
diff --git a/server/services/matter/lib/matter.readInitialDeviceStates.js b/server/services/matter/lib/matter.readInitialDeviceStates.js
index fdfc5a00cb..ab9e80f1cc 100644
--- a/server/services/matter/lib/matter.readInitialDeviceStates.js
+++ b/server/services/matter/lib/matter.readInitialDeviceStates.js
@@ -9,6 +9,7 @@ const {
ColorControl,
RelativeHumidityMeasurement,
Thermostat,
+ AirQuality,
Pm25ConcentrationMeasurement,
Pm10ConcentrationMeasurement,
TotalVolatileOrganicCompoundsConcentrationMeasurement,
@@ -151,6 +152,12 @@ async function readInitialDeviceStates(nodeId, devicePath, device) {
}
}
+ const airQuality = device.getClusterClientById(AirQuality.Complete.id);
+ if (airQuality) {
+ const value = await safeReadAttribute(() => airQuality.getAirQualityAttribute());
+ emitState(`matter:${nodeId}:${devicePath}:${AirQuality.Complete.id}`, value);
+ }
+
const pm25ConcentrationMeasurement = device.getClusterClientById(Pm25ConcentrationMeasurement.Complete.id);
if (pm25ConcentrationMeasurement) {
const value = await safeReadAttribute(() => pm25ConcentrationMeasurement.getMeasuredValueAttribute());
diff --git a/server/services/matter/utils/airQualityMatterMapping.js b/server/services/matter/utils/airQualityMatterMapping.js
new file mode 100644
index 0000000000..718a25b129
--- /dev/null
+++ b/server/services/matter/utils/airQualityMatterMapping.js
@@ -0,0 +1,50 @@
+const { AIR_QUALITY_LEVEL } = require('../../../utils/constants');
+
+const AIR_QUALITY_LEVEL_LABELS = {
+ [AIR_QUALITY_LEVEL.UNKNOWN]: 'Unknown',
+ [AIR_QUALITY_LEVEL.GOOD]: 'Good',
+ [AIR_QUALITY_LEVEL.FAIR]: 'Fair',
+ [AIR_QUALITY_LEVEL.MODERATE]: 'Moderate',
+ [AIR_QUALITY_LEVEL.POOR]: 'Poor',
+ [AIR_QUALITY_LEVEL.VERY_POOR]: 'Very poor',
+ [AIR_QUALITY_LEVEL.EXTREMELY_POOR]: 'Extremely poor',
+};
+
+// Matter AirQuality cluster: Unknown, Good and Poor are always part of the AirQualityEnum, the
+// four other levels are each behind an optional cluster feature (Matter spec 2.9.5).
+const OPTIONAL_AIR_QUALITY_LEVEL_FEATURES = {
+ fair: AIR_QUALITY_LEVEL.FAIR,
+ moderate: AIR_QUALITY_LEVEL.MODERATE,
+ veryPoor: AIR_QUALITY_LEVEL.VERY_POOR,
+ extremelyPoor: AIR_QUALITY_LEVEL.EXTREMELY_POOR,
+};
+
+const MANDATORY_AIR_QUALITY_LEVELS = [AIR_QUALITY_LEVEL.UNKNOWN, AIR_QUALITY_LEVEL.GOOD, AIR_QUALITY_LEVEL.POOR];
+
+/**
+ * @description Build the supported_options list of the air quality level feature
+ * from the AirQuality cluster supported features.
+ * @param {object} supportedFeatures - AirQuality cluster supported features (fair/moderate/veryPoor/extremelyPoor).
+ * @returns {Array} Supported options ({ value, label }) sorted by AIR_QUALITY_LEVEL value.
+ * @example
+ * const supportedOptions = getAirQualityLevelSupportedOptions({ fair: true, moderate: true });
+ */
+function getAirQualityLevelSupportedOptions(supportedFeatures) {
+ const levels = [...MANDATORY_AIR_QUALITY_LEVELS];
+ Object.keys(OPTIONAL_AIR_QUALITY_LEVEL_FEATURES).forEach((featureName) => {
+ if (supportedFeatures && supportedFeatures[featureName]) {
+ levels.push(OPTIONAL_AIR_QUALITY_LEVEL_FEATURES[featureName]);
+ }
+ });
+ return levels
+ .sort((a, b) => a - b)
+ .map((level) => ({
+ value: level,
+ label: AIR_QUALITY_LEVEL_LABELS[level],
+ }));
+}
+
+module.exports = {
+ AIR_QUALITY_LEVEL_LABELS,
+ getAirQualityLevelSupportedOptions,
+};
diff --git a/server/services/matter/utils/convertToGladysDevice.js b/server/services/matter/utils/convertToGladysDevice.js
index d83a3100c0..74d936ffe6 100644
--- a/server/services/matter/utils/convertToGladysDevice.js
+++ b/server/services/matter/utils/convertToGladysDevice.js
@@ -10,6 +10,7 @@ const {
ColorControl,
RelativeHumidityMeasurement,
Thermostat,
+ AirQuality,
Pm25ConcentrationMeasurement,
Pm10ConcentrationMeasurement,
ConcentrationMeasurement,
@@ -40,6 +41,7 @@ const {
const { slugify } = require('../../../utils/slugify');
const { matterAttributeToNumber } = require('./fanMatterMapping');
const { getAcModeSupportedOptions } = require('./thermostatMatterMapping');
+const { getAirQualityLevelSupportedOptions } = require('./airQualityMatterMapping');
/**
* @description Build a stable Gladys selector from a Matter external_id.
@@ -321,6 +323,21 @@ async function convertToGladysDevice(serviceId, nodeId, device, nodeDetailDevice
supported_options: acModeSupportedOptions,
});
}
+ } else if (clusterIndex === AirQuality.Complete.id) {
+ // Only Unknown, Good and Poor are always reportable: the four other levels are optional
+ // cluster features, so the device itself tells us which verdicts it can publish
+ const airQualityLevelOptions = getAirQualityLevelSupportedOptions(clusterClient.supportedFeatures);
+ gladysDevice.features.push({
+ ...commonNewFeature,
+ category: DEVICE_FEATURE_CATEGORIES.AIRQUALITY_SENSOR,
+ type: DEVICE_FEATURE_TYPES.AIRQUALITY_SENSOR.LEVEL,
+ read_only: true,
+ has_feedback: true,
+ external_id: `matter:${nodeId}:${devicePath}:${clusterIndex}`,
+ min: airQualityLevelOptions[0].value,
+ max: airQualityLevelOptions[airQualityLevelOptions.length - 1].value,
+ supported_options: airQualityLevelOptions,
+ });
} else if (clusterIndex === Pm25ConcentrationMeasurement.Complete.id) {
const measurementUnit = await clusterClient.getMeasurementUnitAttribute();
const deviceFeatureUnit = convertMeasurementUnitToDeviceFeatureUnits(measurementUnit);
diff --git a/server/test/services/matter/lib/convertToGladysDevice.test.js b/server/test/services/matter/lib/convertToGladysDevice.test.js
index 8099679864..059dd1984c 100644
--- a/server/test/services/matter/lib/convertToGladysDevice.test.js
+++ b/server/test/services/matter/lib/convertToGladysDevice.test.js
@@ -9,6 +9,7 @@ const {
RvcCleanMode,
PowerSource,
Thermostat,
+ AirQuality,
CarbonDioxideConcentrationMeasurement,
// eslint-disable-next-line import/no-unresolved
} = require('@matter/main/clusters');
@@ -17,7 +18,7 @@ const {
convertToGladysDevice,
matterExternalIdToSelector,
} = require('../../../../services/matter/utils/convertToGladysDevice');
-const { AC_MODE } = require('../../../../utils/constants');
+const { AC_MODE, AIR_QUALITY_LEVEL } = require('../../../../utils/constants');
describe('Matter.convertToGladysDevice', () => {
const serviceId = 'service-1';
@@ -367,6 +368,81 @@ describe('Matter.convertToGladysDevice', () => {
expect(gladysDevice.features).to.have.lengthOf(0);
});
+ it('should create an air quality level feature for AirQuality cluster', async () => {
+ const clusterClient = {
+ id: AirQuality.Complete.id,
+ name: 'AirQuality',
+ endpointId: 1,
+ supportedFeatures: {
+ fair: true,
+ moderate: true,
+ veryPoor: true,
+ extremelyPoor: true,
+ },
+ };
+
+ const device = {
+ name: 'Air Quality Sensor',
+ number: 1,
+ getAllClusterClients: () => [clusterClient],
+ getChildEndpoints: () => [],
+ };
+
+ const gladysDevice = await convertToGladysDevice(serviceId, nodeId, device, basicInformation, '1');
+
+ expect(gladysDevice.features).to.have.lengthOf(1);
+ expect(gladysDevice.features[0]).to.deep.equal({
+ name: 'AirQuality - 1',
+ selector: matterExternalIdToSelector(`matter:12345:1:${AirQuality.Complete.id}`),
+ category: 'airquality-sensor',
+ type: 'level',
+ read_only: true,
+ has_feedback: true,
+ external_id: `matter:12345:1:${AirQuality.Complete.id}`,
+ min: AIR_QUALITY_LEVEL.UNKNOWN,
+ max: AIR_QUALITY_LEVEL.EXTREMELY_POOR,
+ supported_options: [
+ { value: AIR_QUALITY_LEVEL.UNKNOWN, label: 'Unknown' },
+ { value: AIR_QUALITY_LEVEL.GOOD, label: 'Good' },
+ { value: AIR_QUALITY_LEVEL.FAIR, label: 'Fair' },
+ { value: AIR_QUALITY_LEVEL.MODERATE, label: 'Moderate' },
+ { value: AIR_QUALITY_LEVEL.POOR, label: 'Poor' },
+ { value: AIR_QUALITY_LEVEL.VERY_POOR, label: 'Very poor' },
+ { value: AIR_QUALITY_LEVEL.EXTREMELY_POOR, label: 'Extremely poor' },
+ ],
+ });
+ });
+
+ it('should only expose the mandatory air quality levels when the cluster declares no feature', async () => {
+ const clusterClient = {
+ id: AirQuality.Complete.id,
+ name: 'AirQuality',
+ endpointId: 1,
+ };
+
+ const device = {
+ name: 'Air Quality Sensor',
+ number: 1,
+ getAllClusterClients: () => [clusterClient],
+ getChildEndpoints: () => [],
+ };
+
+ const gladysDevice = await convertToGladysDevice(serviceId, nodeId, device, basicInformation, '1');
+
+ expect(gladysDevice.features).to.have.lengthOf(1);
+ expect(gladysDevice.features[0]).to.deep.include({
+ category: 'airquality-sensor',
+ type: 'level',
+ min: AIR_QUALITY_LEVEL.UNKNOWN,
+ max: AIR_QUALITY_LEVEL.POOR,
+ supported_options: [
+ { value: AIR_QUALITY_LEVEL.UNKNOWN, label: 'Unknown' },
+ { value: AIR_QUALITY_LEVEL.GOOD, label: 'Good' },
+ { value: AIR_QUALITY_LEVEL.POOR, label: 'Poor' },
+ ],
+ });
+ });
+
it('should create a CO2 sensor feature for CarbonDioxideConcentrationMeasurement cluster', async () => {
const clusterClient = {
id: CarbonDioxideConcentrationMeasurement.Complete.id,
diff --git a/server/test/services/matter/lib/listenToStateChange.test.js b/server/test/services/matter/lib/listenToStateChange.test.js
index f9d459b343..b76b21c510 100644
--- a/server/test/services/matter/lib/listenToStateChange.test.js
+++ b/server/test/services/matter/lib/listenToStateChange.test.js
@@ -13,6 +13,7 @@ const {
Pm25ConcentrationMeasurement,
Pm10ConcentrationMeasurement,
TotalVolatileOrganicCompoundsConcentrationMeasurement,
+ AirQuality,
NitrogenDioxideConcentrationMeasurement,
FormaldehydeConcentrationMeasurement,
CarbonDioxideConcentrationMeasurement,
@@ -32,7 +33,7 @@ const { expect } = require('chai');
const { fake, assert } = sinon;
-const { EVENTS, STATE, BUTTON_STATUS, FAN_MODE, AC_MODE } = require('../../../../utils/constants');
+const { EVENTS, STATE, BUTTON_STATUS, FAN_MODE, AC_MODE, AIR_QUALITY_LEVEL } = require('../../../../utils/constants');
const MatterHandler = require('../../../../services/matter/lib');
@@ -311,6 +312,23 @@ describe('Matter.listenToStateChange', () => {
state: 100,
});
});
+ it('should listen to state change (AirQuality)', async () => {
+ const clusterClient = {
+ id: AirQuality.Complete.id,
+ addAirQualityAttributeListener: (callback) => {
+ callback(AIR_QUALITY_LEVEL.MODERATE);
+ },
+ };
+ const device = {
+ number: 1,
+ getClusterClientById: (id) => (id === clusterClient.id ? clusterClient : null),
+ };
+ await matterHandler.listenToStateChange(1234n, '1', device);
+ assert.calledWith(gladys.event.emit, EVENTS.DEVICE.NEW_STATE, {
+ device_feature_external_id: `matter:1234:1:${AirQuality.Complete.id}`,
+ state: AIR_QUALITY_LEVEL.MODERATE,
+ });
+ });
it('should listen to state change (TotalVolatileOrganicCompoundsConcentrationMeasurement)', async () => {
const clusterClient = {
id: TotalVolatileOrganicCompoundsConcentrationMeasurement.Complete.id,
diff --git a/server/test/services/matter/lib/matter.readInitialDeviceStates.test.js b/server/test/services/matter/lib/matter.readInitialDeviceStates.test.js
index b3a9f24372..f88efbad3d 100644
--- a/server/test/services/matter/lib/matter.readInitialDeviceStates.test.js
+++ b/server/test/services/matter/lib/matter.readInitialDeviceStates.test.js
@@ -17,6 +17,7 @@ const {
Pm25ConcentrationMeasurement,
Pm10ConcentrationMeasurement,
TotalVolatileOrganicCompoundsConcentrationMeasurement,
+ AirQuality,
NitrogenDioxideConcentrationMeasurement,
FormaldehydeConcentrationMeasurement,
CarbonDioxideConcentrationMeasurement,
@@ -32,7 +33,7 @@ const {
} = require('@matter/main/clusters');
const MatterHandler = require('../../../../services/matter/lib');
-const { EVENTS, STATE, FAN_MODE, AC_MODE } = require('../../../../utils/constants');
+const { EVENTS, STATE, FAN_MODE, AC_MODE, AIR_QUALITY_LEVEL } = require('../../../../utils/constants');
describe('Matter.readInitialDeviceStates', () => {
let matterHandler;
@@ -103,6 +104,9 @@ describe('Matter.readInitialDeviceStates', () => {
[RelativeHumidityMeasurement.Complete.id]: {
getMeasuredValueAttribute: fake.resolves(4500),
},
+ [AirQuality.Complete.id]: {
+ getAirQualityAttribute: fake.resolves(AIR_QUALITY_LEVEL.FAIR),
+ },
[Pm25ConcentrationMeasurement.Complete.id]: {
getMeasuredValueAttribute: fake.resolves(12),
},
@@ -203,6 +207,10 @@ describe('Matter.readInitialDeviceStates', () => {
device_feature_external_id: `matter:${nodeId}:${devicePath}:${CarbonDioxideConcentrationMeasurement.Complete.id}`,
state: 650,
});
+ assert.calledWith(gladys.event.emit, EVENTS.DEVICE.NEW_STATE, {
+ device_feature_external_id: `matter:${nodeId}:${devicePath}:${AirQuality.Complete.id}`,
+ state: AIR_QUALITY_LEVEL.FAIR,
+ });
assert.calledWith(gladys.event.emit, EVENTS.DEVICE.NEW_STATE, {
device_feature_external_id: `matter:${nodeId}:${devicePath}:${ElectricalPowerMeasurement.Complete.id}:power`,
state: 5,
diff --git a/server/test/services/matter/utils/airQualityMatterMapping.test.js b/server/test/services/matter/utils/airQualityMatterMapping.test.js
new file mode 100644
index 0000000000..09586337f6
--- /dev/null
+++ b/server/test/services/matter/utils/airQualityMatterMapping.test.js
@@ -0,0 +1,48 @@
+const { expect } = require('chai');
+
+const { AIR_QUALITY_LEVEL } = require('../../../../utils/constants');
+const {
+ AIR_QUALITY_LEVEL_LABELS,
+ getAirQualityLevelSupportedOptions,
+} = require('../../../../services/matter/utils/airQualityMatterMapping');
+
+describe('Matter airQualityMatterMapping', () => {
+ it('should only expose the mandatory levels when no optional feature is supported', () => {
+ expect(getAirQualityLevelSupportedOptions({})).to.deep.eq([
+ { value: AIR_QUALITY_LEVEL.UNKNOWN, label: 'Unknown' },
+ { value: AIR_QUALITY_LEVEL.GOOD, label: 'Good' },
+ { value: AIR_QUALITY_LEVEL.POOR, label: 'Poor' },
+ ]);
+ });
+
+ it('should only expose the mandatory levels when the cluster declares no feature at all', () => {
+ expect(getAirQualityLevelSupportedOptions(undefined)).to.deep.eq([
+ { value: AIR_QUALITY_LEVEL.UNKNOWN, label: 'Unknown' },
+ { value: AIR_QUALITY_LEVEL.GOOD, label: 'Good' },
+ { value: AIR_QUALITY_LEVEL.POOR, label: 'Poor' },
+ ]);
+ });
+
+ it('should add the optional levels the cluster supports, sorted by level', () => {
+ expect(getAirQualityLevelSupportedOptions({ moderate: true, extremelyPoor: true })).to.deep.eq([
+ { value: AIR_QUALITY_LEVEL.UNKNOWN, label: 'Unknown' },
+ { value: AIR_QUALITY_LEVEL.GOOD, label: 'Good' },
+ { value: AIR_QUALITY_LEVEL.MODERATE, label: 'Moderate' },
+ { value: AIR_QUALITY_LEVEL.POOR, label: 'Poor' },
+ { value: AIR_QUALITY_LEVEL.EXTREMELY_POOR, label: 'Extremely poor' },
+ ]);
+ });
+
+ it('should expose the whole scale when every optional feature is supported', () => {
+ const options = getAirQualityLevelSupportedOptions({
+ fair: true,
+ moderate: true,
+ veryPoor: true,
+ extremelyPoor: true,
+ });
+
+ expect(options).to.deep.eq(
+ Object.values(AIR_QUALITY_LEVEL).map((level) => ({ value: level, label: AIR_QUALITY_LEVEL_LABELS[level] })),
+ );
+ });
+});
diff --git a/server/utils/constants.js b/server/utils/constants.js
index a266424629..0fa2e79364 100644
--- a/server/utils/constants.js
+++ b/server/utils/constants.js
@@ -295,6 +295,23 @@ const LEVEL_MATTER_STATE = {
CRITICAL: 4,
};
+// Qualitative air quality classification, ordered from the best to the worst air. The scale is the
+// one standards use for an overall air quality verdict (Matter AirQuality cluster, and the same
+// levels in Zigbee air quality sensors): a device publishes the verdict it computes itself, not a
+// value Gladys derives from concentrations. `UNKNOWN` is what a device reports while it has no
+// verdict yet (warm-up, sensor fault): it is not a quality level, but it stays part of the value
+// range (feature `min` and `supported_options`) so scenes can match it, so a 0 does show up in
+// history charts and is to be read as "no verdict", not as the best air on the scale.
+const AIR_QUALITY_LEVEL = {
+ UNKNOWN: 0,
+ GOOD: 1,
+ FAIR: 2,
+ MODERATE: 3,
+ POOR: 4,
+ VERY_POOR: 5,
+ EXTREMELY_POOR: 6,
+};
+
const VACUUM_CLEANER_STATE = {
STOPPED: 0,
RUNNING: 1,
@@ -1224,7 +1241,13 @@ const DEVICE_FEATURE_TYPES = {
OPERATING_STATE: 'operating-state',
},
AIRQUALITY_SENSOR: {
+ // Numeric air quality index, on the open-ended scale the AQI unit defines (0-500).
AQI: 'aqi',
+ // Overall qualitative verdict the device computes itself, one of the AIR_QUALITY_LEVEL values.
+ // Boundary with `aqi`: an integration maps whichever form its device natively reports, never
+ // both for the same measurement, and the raw concentrations behind the verdict keep going to
+ // their own per-pollutant categories (`pm25-sensor`, `co2-sensor`, `voc-sensor`...).
+ LEVEL: 'level',
},
PH_SENSOR: {
DECIMAL: 'decimal',
@@ -1761,6 +1784,11 @@ const DEVICE_FEATURE_UNITS_BY_CATEGORY = {
// when the category-level list mixes units of different dimensions.
// An empty array means the feature type has no unit at all.
const DEVICE_FEATURE_UNITS_BY_CATEGORY_AND_TYPE = {
+ [DEVICE_FEATURE_CATEGORIES.AIRQUALITY_SENSOR]: {
+ [DEVICE_FEATURE_TYPES.AIRQUALITY_SENSOR.AQI]: [DEVICE_FEATURE_UNITS.AQI],
+ // The qualitative level is an enum, it carries no unit.
+ [DEVICE_FEATURE_TYPES.AIRQUALITY_SENSOR.LEVEL]: [],
+ },
[DEVICE_FEATURE_CATEGORIES.WATER_HEATER]: {
[DEVICE_FEATURE_TYPES.WATER_HEATER.BINARY]: [],
[DEVICE_FEATURE_TYPES.WATER_HEATER.MODE]: [],
@@ -2281,3 +2309,4 @@ module.exports.ENERGY_PRICE_DAY_TYPES = ENERGY_PRICE_DAY_TYPES;
module.exports.ENERGY_PRICE_DAY_TYPES_LIST = ENERGY_PRICE_DAY_TYPES_LIST;
module.exports.LEVEL_MATTER_STATE = LEVEL_MATTER_STATE;
+module.exports.AIR_QUALITY_LEVEL = AIR_QUALITY_LEVEL;