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;