From 6fbda7722f357805ca2e988b685f58fa4a92f0f9 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 15 Aug 2026 16:56:55 +0000 Subject: [PATCH 1/2] Matter: add support for the AirQuality cluster The Matter AirQuality cluster (0x005B) reports an overall air quality verdict as an enum (Unknown, Good, Fair, Moderate, Poor, VeryPoor, ExtremelyPoor). Gladys already handled the individual concentration clusters (PM2.5, PM10, CO2, VOC, NO2, formaldehyde) but not the overall verdict. Rather than a new protocol-named category (the existing voc-matter-index-sensor / no2-matter-index-sensor are documented as legacy exceptions in docs/specs/device-feature-categories.md), this adds a `level` type to the existing `airquality-sensor` category, next to the numeric `aqi` type. The value scale is the Matter one, which is also how Zigbee air quality sensors report an overall level. Only Unknown, Good and Poor are always part of the Matter enum, the four other levels are optional cluster features, so the device declares what it can publish through `supported_options`, like the air conditioning mode feature already does. Front side: type label and value labels in en/fr/de, icon, dashboard badge colors (same palette as the numeric air quality index) and MQTT catalog defaults. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_013yxguVaLdJ8ZKmw5x3HePT --- .../sensor-value/BadgeNumberDeviceValue.jsx | 46 ++++++++++- front/src/config/i18n/de.json | 15 +++- front/src/config/i18n/en.json | 15 +++- front/src/config/i18n/fr.json | 15 +++- .../integration/all/mqtt/device-page/utils.js | 26 ++++++- front/src/utils/consts.js | 3 +- server/services/matter/README.md | 10 +-- .../matter/lib/matter.listenToStateChange.js | 15 ++++ .../lib/matter.readInitialDeviceStates.js | 7 ++ .../matter/utils/airQualityMatterMapping.js | 50 ++++++++++++ .../matter/utils/convertToGladysDevice.js | 17 ++++ .../matter/lib/convertToGladysDevice.test.js | 78 ++++++++++++++++++- .../matter/lib/listenToStateChange.test.js | 20 ++++- .../matter.readInitialDeviceStates.test.js | 10 ++- .../utils/airQualityMatterMapping.test.js | 48 ++++++++++++ server/utils/constants.js | 27 +++++++ 16 files changed, 386 insertions(+), 16 deletions(-) create mode 100644 server/services/matter/utils/airQualityMatterMapping.js create mode 100644 server/test/services/matter/utils/airQualityMatterMapping.test.js 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..f37206e378 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'; @@ -837,6 +838,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 +876,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 +901,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..c0c07626d8 100644 --- a/server/utils/constants.js +++ b/server/utils/constants.js @@ -295,6 +295,21 @@ 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 and must not be charted as one. +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 +1239,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 +1782,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 +2307,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; From 2081e2d79f0745634db0a09c181245832e6f897c Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 15 Aug 2026 19:49:01 +0000 Subject: [PATCH 2/2] Matter: keep the air quality level feature unitless The `airquality-sensor/level` type was missing from `CATEGORIES_WITHOUT_UNIT`, so the MQTT device page fell back on the category default and created the feature with the `aqi` unit. The per-type unit list being empty then hid the unit picker, leaving no way to remove it and labelling a 0-6 enum as an AQI index in history charts. Also reword the `AIR_QUALITY_LEVEL` comment: `UNKNOWN` stays part of the value range (feature `min` and `supported_options`) so scenes can match it, so it does appear in history charts and the comment now says how to read it instead of claiming it is never charted. Autofix-Pass: 1 --- front/src/routes/integration/all/mqtt/device-page/utils.js | 2 ++ server/utils/constants.js | 4 +++- 2 files changed, 5 insertions(+), 1 deletion(-) 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 f37206e378..d749ad66a3 100644 --- a/front/src/routes/integration/all/mqtt/device-page/utils.js +++ b/front/src/routes/integration/all/mqtt/device-page/utils.js @@ -89,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), diff --git a/server/utils/constants.js b/server/utils/constants.js index c0c07626d8..0fa2e79364 100644 --- a/server/utils/constants.js +++ b/server/utils/constants.js @@ -299,7 +299,9 @@ const LEVEL_MATTER_STATE = { // 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 and must not be charted as one. +// 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,