Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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),
Expand Down Expand Up @@ -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 <RawDeviceValue {...props} />;
}
Expand All @@ -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'}`;
Expand All @@ -174,7 +212,9 @@ const BadgeNumberDeviceValue = props => {
)}
{valued && valueIsEnum && (
<span>
<Text id={`deviceFeatureValue.category.${category}.${type}.${value}`} />
<Text id={`deviceFeatureValue.category.${category}.${type}.${value}`}>
<Text id={`deviceFeatureValue.category.${category}.${type}.unknown`} fields={{ value: lastValue }} />
</Text>
</span>
)}
</span>
Expand Down
15 changes: 14 additions & 1 deletion front/src/config/i18n/de.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down Expand Up @@ -5621,7 +5633,8 @@
},
"airquality-sensor": {
"shortCategoryName": "Luftqualität",
"aqi": "Luftqualitätsindex"
"aqi": "Luftqualitätsindex",
"level": "Luftqualitätsstufe"
},
"ph-sensor": {
"shortCategoryName": "pH-Sensor",
Expand Down
15 changes: 14 additions & 1 deletion front/src/config/i18n/en.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down Expand Up @@ -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",
Expand Down
15 changes: 14 additions & 1 deletion front/src/config/i18n/fr.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down Expand Up @@ -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",
Expand Down
28 changes: 27 additions & 1 deletion front/src/routes/integration/all/mqtt/device-page/utils.js
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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),
Expand Down Expand Up @@ -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(
Comment thread
cursor[bot] marked this conversation as resolved.
{ ...defaults, min: AIR_QUALITY_LEVEL.UNKNOWN, max: AIR_QUALITY_LEVEL.EXTREMELY_POOR, read_only: true },
category,
type
);
}

Comment thread
coderabbitai[bot] marked this conversation as resolved.
if (!isSensorCategory(category)) {
return applyDefaultUnit({ ...defaults, min: 0, max: 100, read_only: false }, category, type);
}
Expand All @@ -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,
Expand All @@ -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;
Expand Down
3 changes: 2 additions & 1 deletion front/src/utils/consts.js
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down
10 changes: 5 additions & 5 deletions server/services/matter/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand All @@ -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`. |
Expand Down Expand Up @@ -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.

Expand Down
15 changes: 15 additions & 0 deletions server/services/matter/lib/matter.listenToStateChange.js
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ const {
ColorControl,
RelativeHumidityMeasurement,
Thermostat,
AirQuality,
Pm25ConcentrationMeasurement,
Pm10ConcentrationMeasurement,
TotalVolatileOrganicCompoundsConcentrationMeasurement,
Expand Down Expand Up @@ -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(
Expand Down
7 changes: 7 additions & 0 deletions server/services/matter/lib/matter.readInitialDeviceStates.js
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ const {
ColorControl,
RelativeHumidityMeasurement,
Thermostat,
AirQuality,
Pm25ConcentrationMeasurement,
Pm10ConcentrationMeasurement,
TotalVolatileOrganicCompoundsConcentrationMeasurement,
Expand Down Expand Up @@ -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());
Expand Down
50 changes: 50 additions & 0 deletions server/services/matter/utils/airQualityMatterMapping.js
Original file line number Diff line number Diff line change
@@ -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,
};
Loading
Loading