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
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 @@ -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/filter-life-remaining | Activated carbon filter status/lifetime. |
| `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 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
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
9 changes: 8 additions & 1 deletion 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 @@ -471,7 +472,13 @@ async function convertToGladysDevice(serviceId, nodeId, device, nodeDetailDevice
max: 1000000,
});
}
} else if (clusterIndex === HepaFilterMonitoring.Complete.id) {
} else if (
clusterIndex === HepaFilterMonitoring.Complete.id ||
clusterIndex === ActivatedCarbonFilterMonitoring.Complete.id
) {
// Both filter monitoring clusters report the same quantity, so they share the same Gladys
// type. The cluster name in the feature name and the cluster id in the external id are what
// tell the HEPA cartridge apart from the activated carbon one.
gladysDevice.features.push({
...commonNewFeature,
category: DEVICE_FEATURE_CATEGORIES.HEPA_FILTER_MONITORING,
Expand Down
101 changes: 101 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,105 @@ 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: '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 cluster 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',
'filter-life-remaining',
]);
expect(gladysDevice.features.map((feature) => feature.name)).to.deep.equal([
'HepaFilterMonitoring - 1',
'ActivatedCarbonFilterMonitoring - 1',
]);
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
15 changes: 14 additions & 1 deletion 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.
// A single `filter-life-remaining` type covers every filter media (HEPA, activated carbon...):
// an appliance carrying several physical filters publishes one feature per filter, and the
// feature name identifies which cartridge reports the value. 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 `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 only: the remaining life of an air filter in percent,
// 100 = new, 0 = to be replaced (Matter Resource Monitoring `Condition` attribute, whose
// degradation direction is "down"). Don't add a per-media type (HEPA, activated carbon...):
// the media is which cartridge reports the value, not a different measurement, so an appliance
// carrying several filters publishes one feature per filter and the feature name identifies it.
FILTER_MONITORING: {
FILTER_LIFE_REMAINING: 'filter-life-remaining', // Remaining life of the HEPA filter in percent (integer - sensor)
FILTER_LIFE_REMAINING: 'filter-life-remaining', // Remaining life of an air filter in percent (integer - sensor)
},
MAINTENANCE: {
LIFE_REMAINING: 'life-remaining', // Remaining life of a consumable/wear part in percent (integer - sensor)
Expand Down
Loading