Skip to content
Open
Show file tree
Hide file tree
Changes from 1 commit
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
5 changes: 3 additions & 2 deletions front/src/config/i18n/de.json
Original file line number Diff line number Diff line change
Expand Up @@ -5567,8 +5567,9 @@
"wind-setting": "Windmodus"
},
"hepa-filter-monitoring": {
"shortCategoryName": "HEPA-Filterüberwachung",
"filter-life-remaining": "Verbleibende Filterlebensdauer"
"shortCategoryName": "Filterüberwachung",
"filter-life-remaining": "Verbleibende Lebensdauer des HEPA-Filters",
"activated-carbon-filter-life-remaining": "Verbleibende Lebensdauer des Aktivkohlefilters"
},
"maintenance": {
"shortCategoryName": "Wartung",
Expand Down
5 changes: 3 additions & 2 deletions front/src/config/i18n/en.json
Original file line number Diff line number Diff line change
Expand Up @@ -5567,8 +5567,9 @@
"wind-setting": "Wind mode"
},
"hepa-filter-monitoring": {
"shortCategoryName": "HEPA filter monitoring",
"filter-life-remaining": "Filter life remaining"
"shortCategoryName": "Filter monitoring",
"filter-life-remaining": "HEPA filter life remaining",
"activated-carbon-filter-life-remaining": "Activated carbon filter life remaining"
},
"maintenance": {
"shortCategoryName": "Maintenance",
Expand Down
5 changes: 3 additions & 2 deletions front/src/config/i18n/fr.json
Original file line number Diff line number Diff line change
Expand Up @@ -5567,8 +5567,9 @@
"wind-setting": "Mode vent"
},
"hepa-filter-monitoring": {
"shortCategoryName": "Surveillance filtre HEPA",
"filter-life-remaining": "Durée de vie restante du filtre"
"shortCategoryName": "Surveillance des filtres",
"filter-life-remaining": "Durée de vie restante du filtre HEPA",
"activated-carbon-filter-life-remaining": "Durée de vie restante du filtre à charbon actif"
},
"maintenance": {
"shortCategoryName": "Maintenance",
Expand Down
10 changes: 8 additions & 2 deletions front/src/routes/integration/all/mqtt/device-page/utils.js
Original file line number Diff line number Diff line change
Expand Up @@ -201,6 +201,10 @@ const FEATURE_UNIT_BY_CATEGORY_TYPE = {
DEVICE_FEATURE_CATEGORIES.HEPA_FILTER_MONITORING,
DEVICE_FEATURE_TYPES.FILTER_MONITORING.FILTER_LIFE_REMAINING
)]: DEVICE_FEATURE_UNITS.PERCENT,
[categoryTypeKey(
DEVICE_FEATURE_CATEGORIES.HEPA_FILTER_MONITORING,
DEVICE_FEATURE_TYPES.FILTER_MONITORING.ACTIVATED_CARBON_FILTER_LIFE_REMAINING
)]: DEVICE_FEATURE_UNITS.PERCENT,
[categoryTypeKey(
DEVICE_FEATURE_CATEGORIES.MAINTENANCE,
DEVICE_FEATURE_TYPES.MAINTENANCE.LIFE_REMAINING
Expand Down Expand Up @@ -735,7 +739,8 @@ export const getFeatureDefaultValues = (category, type) => {

if (
(category === DEVICE_FEATURE_CATEGORIES.HEPA_FILTER_MONITORING &&
type === DEVICE_FEATURE_TYPES.FILTER_MONITORING.FILTER_LIFE_REMAINING) ||
(type === DEVICE_FEATURE_TYPES.FILTER_MONITORING.FILTER_LIFE_REMAINING ||
type === DEVICE_FEATURE_TYPES.FILTER_MONITORING.ACTIVATED_CARBON_FILTER_LIFE_REMAINING)) ||
(category === DEVICE_FEATURE_CATEGORIES.MAINTENANCE && type === DEVICE_FEATURE_TYPES.MAINTENANCE.LIFE_REMAINING)
) {
return applyDefaultUnit(
Expand Down Expand Up @@ -1022,7 +1027,8 @@ export const getFeaturePreviewValue = (category, type) => {

if (
(category === DEVICE_FEATURE_CATEGORIES.HEPA_FILTER_MONITORING &&
type === DEVICE_FEATURE_TYPES.FILTER_MONITORING.FILTER_LIFE_REMAINING) ||
(type === DEVICE_FEATURE_TYPES.FILTER_MONITORING.FILTER_LIFE_REMAINING ||
type === DEVICE_FEATURE_TYPES.FILTER_MONITORING.ACTIVATED_CARBON_FILTER_LIFE_REMAINING)) ||
(category === DEVICE_FEATURE_CATEGORIES.MAINTENANCE && type === DEVICE_FEATURE_TYPES.MAINTENANCE.LIFE_REMAINING)
) {
return 72;
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 @@ -592,7 +592,8 @@ export const DeviceFeatureCategoriesIcon = {
[DEVICE_FEATURE_TYPES.ELECTRICAL_VEHICLE_STATE.WINDOW_OPENED]: 'square'
},
[DEVICE_FEATURE_CATEGORIES.HEPA_FILTER_MONITORING]: {
[DEVICE_FEATURE_TYPES.FILTER_MONITORING.FILTER_LIFE_REMAINING]: 'bar-chart-2'
[DEVICE_FEATURE_TYPES.FILTER_MONITORING.FILTER_LIFE_REMAINING]: 'bar-chart-2',
[DEVICE_FEATURE_TYPES.FILTER_MONITORING.ACTIVATED_CARBON_FILTER_LIFE_REMAINING]: 'bar-chart-2'
},
[DEVICE_FEATURE_CATEGORIES.MAINTENANCE]: {
[DEVICE_FEATURE_TYPES.MAINTENANCE.LIFE_REMAINING]: 'tool'
Expand Down
2 changes: 1 addition & 1 deletion server/services/matter/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@ The **Gladys feature** column lists matching `category/type` pairs from `server/
| `AccessControl` | The Access Control Cluster exposes a data model view of a Node's Access Control List (ACL), which codifies the rules used to manage and enforce Access Control for the Node's endpoints and their associated cluster instances. | No | — | Not explicitly handled in `server/services/matter`. |
| `AccountLogin` | This cluster provides commands that facilitate user account login on a Content App or a node. | No | — | Not explicitly handled in `server/services/matter`. |
| `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`. |
| `ActivatedCarbonFilterMonitoring` | Reports the condition and remaining lifetime of an activated carbon filter. | Yes | hepa-filter-monitoring/activated-carbon-filter-life-remaining | Activated carbon filter status/lifetime. |
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Outdated
Comment thread
cursor[bot] marked this conversation as resolved.
Outdated
| `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`. |
| `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`. |
Expand Down
17 changes: 17 additions & 0 deletions server/services/matter/lib/matter.listenToStateChange.js
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ const {
ElectricalPowerMeasurement,
ElectricalEnergyMeasurement,
HepaFilterMonitoring,
ActivatedCarbonFilterMonitoring,
FanControl,
RvcOperationalState,
RvcRunMode,
Expand Down Expand Up @@ -486,6 +487,22 @@ async function listenToStateChange(nodeId, devicePath, device) {
});
}

const activatedCarbonFilterMonitoring = device.getClusterClientById(ActivatedCarbonFilterMonitoring.Complete.id);
if (activatedCarbonFilterMonitoring && !this.stateChangeListeners.has(activatedCarbonFilterMonitoring)) {
logger.debug(
`Matter: Adding state change listener for ActivatedCarbonFilterMonitoring cluster ${activatedCarbonFilterMonitoring.name}`,
);
this.stateChangeListeners.add(activatedCarbonFilterMonitoring);
// Subscribe to ActivatedCarbonFilterMonitoring attribute changes
activatedCarbonFilterMonitoring.addConditionAttributeListener((value) => {
logger.debug(`Matter: ActivatedCarbonFilterMonitoring Condition attribute changed to ${value}`);
this.gladys.event.emit(EVENTS.DEVICE.NEW_STATE, {
device_feature_external_id: `matter:${nodeId}:${devicePath}:${ActivatedCarbonFilterMonitoring.Complete.id}`,
state: value,
});
});
}

const fanControl = device.getClusterClientById(FanControl.Complete.id);
if (fanControl && !this.stateChangeListeners.has(fanControl)) {
logger.debug(`Matter: Adding state change listener for FanControl cluster ${fanControl.name}`);
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 @@ -18,6 +18,7 @@ const {
ElectricalPowerMeasurement,
ElectricalEnergyMeasurement,
HepaFilterMonitoring,
ActivatedCarbonFilterMonitoring,
FanControl,
RvcOperationalState,
RvcRunMode,
Expand Down Expand Up @@ -270,6 +271,12 @@ async function readInitialDeviceStates(nodeId, devicePath, device) {
emitState(`matter:${nodeId}:${devicePath}:${HepaFilterMonitoring.Complete.id}`, value);
}

const activatedCarbonFilterMonitoring = device.getClusterClientById(ActivatedCarbonFilterMonitoring.Complete.id);
if (activatedCarbonFilterMonitoring) {
const value = await safeReadAttribute(() => activatedCarbonFilterMonitoring.getConditionAttribute());
emitState(`matter:${nodeId}:${devicePath}:${ActivatedCarbonFilterMonitoring.Complete.id}`, value);
}

const fanControl = device.getClusterClientById(FanControl.Complete.id);
if (fanControl) {
const fanBaseExternalId = `matter:${nodeId}:${devicePath}:${FanControl.Complete.id}`;
Expand Down
13 changes: 13 additions & 0 deletions server/services/matter/utils/convertToGladysDevice.js
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ const {
ElectricalPowerMeasurement,
ElectricalEnergyMeasurement,
HepaFilterMonitoring,
ActivatedCarbonFilterMonitoring,
FanControl,
RvcOperationalState,
RvcRunMode,
Expand Down Expand Up @@ -483,6 +484,18 @@ async function convertToGladysDevice(serviceId, nodeId, device, nodeDetailDevice
min: 0,
max: 100,
});
} else if (clusterIndex === ActivatedCarbonFilterMonitoring.Complete.id) {
gladysDevice.features.push({
...commonNewFeature,
category: DEVICE_FEATURE_CATEGORIES.HEPA_FILTER_MONITORING,
type: DEVICE_FEATURE_TYPES.FILTER_MONITORING.ACTIVATED_CARBON_FILTER_LIFE_REMAINING,
Comment thread
cursor[bot] marked this conversation as resolved.
Outdated
read_only: true,
has_feedback: true,
unit: DEVICE_FEATURE_UNITS.PERCENT,
external_id: `matter:${nodeId}:${devicePath}:${clusterIndex}`,
min: 0,
max: 100,
});
} else if (clusterIndex === FanControl.Complete.id) {
const fanBaseExternalId = `matter:${nodeId}:${devicePath}:${clusterIndex}`;
const features = clusterClient.supportedFeatures || {};
Expand Down
97 changes: 97 additions & 0 deletions server/test/services/matter/lib/convertToGladysDevice.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,8 @@ const {
PowerSource,
Thermostat,
CarbonDioxideConcentrationMeasurement,
HepaFilterMonitoring,
ActivatedCarbonFilterMonitoring,
// eslint-disable-next-line import/no-unresolved
} = require('@matter/main/clusters');

Expand Down Expand Up @@ -426,6 +428,101 @@ describe('Matter.convertToGladysDevice', () => {
});
});

it('should create a filter life feature for HepaFilterMonitoring cluster', async () => {
const clusterClient = {
id: HepaFilterMonitoring.Complete.id,
name: 'HepaFilterMonitoring',
endpointId: 1,
};

const device = {
name: 'Air Purifier',
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: 'HepaFilterMonitoring - 1',
selector: gladysDevice.features[0].selector,
category: 'hepa-filter-monitoring',
type: 'filter-life-remaining',
read_only: true,
has_feedback: true,
unit: 'percent',
external_id: `matter:12345:1:${HepaFilterMonitoring.Complete.id}`,
min: 0,
max: 100,
});
});

it('should create a filter life feature for ActivatedCarbonFilterMonitoring cluster', async () => {
const clusterClient = {
id: ActivatedCarbonFilterMonitoring.Complete.id,
name: 'ActivatedCarbonFilterMonitoring',
endpointId: 1,
};

const device = {
name: 'Air Purifier',
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: 'ActivatedCarbonFilterMonitoring - 1',
selector: gladysDevice.features[0].selector,
category: 'hepa-filter-monitoring',
type: 'activated-carbon-filter-life-remaining',
read_only: true,
has_feedback: true,
unit: 'percent',
external_id: `matter:12345:1:${ActivatedCarbonFilterMonitoring.Complete.id}`,
min: 0,
max: 100,
});
});

it('should create one distinct feature per filter media when both filter clusters are present', async () => {
const hepaClusterClient = {
id: HepaFilterMonitoring.Complete.id,
name: 'HepaFilterMonitoring',
endpointId: 1,
};
const activatedCarbonClusterClient = {
id: ActivatedCarbonFilterMonitoring.Complete.id,
name: 'ActivatedCarbonFilterMonitoring',
endpointId: 1,
};

const device = {
name: 'Air Purifier',
number: 1,
getAllClusterClients: () => [hepaClusterClient, activatedCarbonClusterClient],
getChildEndpoints: () => [],
};

const gladysDevice = await convertToGladysDevice(serviceId, nodeId, device, basicInformation, '1');

expect(gladysDevice.features).to.have.lengthOf(2);
expect(gladysDevice.features.map((feature) => feature.type)).to.deep.equal([
'filter-life-remaining',
'activated-carbon-filter-life-remaining',
]);
expect(gladysDevice.features.map((feature) => feature.external_id)).to.deep.equal([
`matter:12345:1:${HepaFilterMonitoring.Complete.id}`,
`matter:12345:1:${ActivatedCarbonFilterMonitoring.Complete.id}`,
]);
expect(gladysDevice.features[0].selector).to.not.eq(gladysDevice.features[1].selector);
});

it('should create thermostat local temperature and setpoint features for Thermostat cluster', async () => {
const clusterClient = {
id: Thermostat.Complete.id,
Expand Down
18 changes: 18 additions & 0 deletions server/test/services/matter/lib/listenToStateChange.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ const {
ElectricalPowerMeasurement,
ElectricalEnergyMeasurement,
HepaFilterMonitoring,
ActivatedCarbonFilterMonitoring,
FanControl,
RvcOperationalState,
RvcRunMode,
Expand Down Expand Up @@ -771,6 +772,23 @@ describe('Matter.listenToStateChange', () => {
state: 75,
});
});
it('should listen to state change (ActivatedCarbonFilterMonitoring)', async () => {
const clusterClient = {
id: ActivatedCarbonFilterMonitoring.Complete.id,
addConditionAttributeListener: (callback) => {
callback(73); // 73% activated carbon filter life remaining
},
};
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:114',
state: 73,
});
});
it('should listen to state change (FanControl)', async () => {
const clusterClient = {
id: FanControl.Complete.id,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ const {
ElectricalPowerMeasurement,
ElectricalEnergyMeasurement,
HepaFilterMonitoring,
ActivatedCarbonFilterMonitoring,
FanControl,
RvcOperationalState,
RvcRunMode,
Expand Down Expand Up @@ -139,6 +140,9 @@ describe('Matter.readInitialDeviceStates', () => {
[HepaFilterMonitoring.Complete.id]: {
getConditionAttribute: fake.resolves(67),
},
[ActivatedCarbonFilterMonitoring.Complete.id]: {
getConditionAttribute: fake.resolves(73),
},
[FanControl.Complete.id]: {
supportedFeatures: {
multiSpeed: true,
Expand Down Expand Up @@ -219,6 +223,14 @@ describe('Matter.readInitialDeviceStates', () => {
device_feature_external_id: `matter:${nodeId}:${devicePath}:${ElectricalEnergyMeasurement.Complete.id}:energy`,
state: 3,
});
assert.calledWith(gladys.event.emit, EVENTS.DEVICE.NEW_STATE, {
device_feature_external_id: `matter:${nodeId}:${devicePath}:${HepaFilterMonitoring.Complete.id}`,
state: 67,
});
assert.calledWith(gladys.event.emit, EVENTS.DEVICE.NEW_STATE, {
device_feature_external_id: `matter:${nodeId}:${devicePath}:${ActivatedCarbonFilterMonitoring.Complete.id}`,
state: 73,
});
assert.calledWith(gladys.event.emit, EVENTS.DEVICE.NEW_STATE, {
device_feature_external_id: `${fanBaseExternalId}:mode`,
state: FAN_MODE.AUTO,
Expand Down
13 changes: 13 additions & 0 deletions server/utils/constants.js
Original file line number Diff line number Diff line change
Expand Up @@ -824,6 +824,14 @@ const DEVICE_FEATURE_CATEGORIES = {
FAN: 'fan',
GRID_SENSOR: 'grid-sensor',
HEATER: 'heater',
// Air filter monitoring, holding the remaining life of the filters of an air treatment appliance
// in percent (100 = new, 0 = to be replaced), following the Matter Resource Monitoring model.
// One type per filter media (HEPA, activated carbon...) so an appliance carrying several physical
// filters publishes one feature per filter. Boundary with `maintenance`: every consumable or wear
// part that is not an air filter (vacuum brushes, dust bags, mop pads, resin...) goes to
// `maintenance`. The value stays kept here even for appliances whose HEPA and carbon media are
// combined in a single cartridge: they simply expose the single type their protocol reports.
// The `hepa-` prefix predates activated carbon support and is kept: renaming would be breaking.
HEPA_FILTER_MONITORING: 'hepa-filter-monitoring',
HOME_OUTPUT_SENSOR: 'home-output-sensor',
HUMIDITY_SENSOR: 'humidity-sensor',
Expand Down Expand Up @@ -1334,8 +1342,13 @@ const DEVICE_FEATURE_TYPES = {
TIRE_PRESSURE: 'tire-pressure', // Tire pressure in bar (decimal - sensor)
WINDOW_OPENED: 'window-opened', // Window open state (binary - sensor)
},
// Air filter monitoring, one type per filter media so a purifier carrying several physical
// filters exposes one feature per filter instead of two indistinguishable ones. All types hold
// the same quantity: the remaining life of the filter in percent, 100 = new, 0 = to be replaced
// (Matter Resource Monitoring `Condition` attribute, whose degradation direction is "down").
FILTER_MONITORING: {
FILTER_LIFE_REMAINING: 'filter-life-remaining', // Remaining life of the HEPA filter in percent (integer - sensor)
ACTIVATED_CARBON_FILTER_LIFE_REMAINING: 'activated-carbon-filter-life-remaining', // Remaining life of the activated carbon filter in percent (integer - sensor)

@Pierre-Gilles Pierre-Gilles Aug 17, 2026

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Why not use the existing FILTER_LIFE_REMAINING ?

This feature looks super specific

Comment thread
cursor[bot] marked this conversation as resolved.
Outdated
},
MAINTENANCE: {
LIFE_REMAINING: 'life-remaining', // Remaining life of a consumable/wear part in percent (integer - sensor)
Expand Down
Loading