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
19 changes: 19 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,25 @@ The main work (all changes without a GitHub username in brackets in the below li
## __WORK IN PROGRESS__
-->

## __WORK IN PROGRESS__

- @matter/model
- Enhancement: `FeatureSelectionErrors()` assesses a cluster's selected features against the combinations its FeatureMap conformance disallows
- Enhancement: `FeatureSet.resolve()` resolves a feature short code, title or camelized title to a short code
- Fix: A cluster's feature table no longer selects features; a feature the specification makes unconditionally mandatory is always selected
- Fix: Feature selection records against `operationalIsSupported` so a feature's `default` conveys only the specification's fallback value
- Fix: A feature mandated by any of several alternatives, such as `DoorLock.User`, is now reported as required

- @matter/node
- Breaking: Default server exports no longer inherit the features their base implementation enables internally. `ColorControlServer`, `DoorLockServer`, `ElectricalEnergyMeasurementServer`, `LevelControlServer`, `ModeSelectServer`, `PowerSourceServer`, `PowerTopologyServer`, `SmokeCoAlarmServer`, `SwitchServer`, `ThermostatServer` and `WindowCoveringServer` now select no features. Select the features your device supports with `.with(...)`; `PowerSourceServer`, `PowerTopologyServer`, `SmokeCoAlarmServer`, `SwitchServer`, `ThermostatServer`, `WindowCoveringServer` and `ElectricalEnergyMeasurementServer` now require a selection to be added to an endpoint at all. The `DoorLockDevice`, `SpeakerDevice` and `ModeSelectDevice` device types alias these exports, so their clusters also select no features and advertise a different FeatureMap. Verify the feature set of every device you compose. Persisted cluster state resets once for affected devices because it is keyed by feature selection
- Breaking: A LongIdleTimeSupport ICD must select CheckInProtocolSupport and UserActiveModeTrigger
- Breaking: A node that accepts more than one path per invoke must select the General Diagnostics DataModelTest feature
- Enhancement: Adding a server cluster to an endpoint fails when its selected features violate the conformance of its FeatureMap
- Fix: `ClusterBehavior.with()` rejects a feature the cluster does not define

- @matter/types
- Fix: `Cluster.with()` rejects a feature the cluster does not define and returns one frozen namespace per selection

## 0.17.7 (2026-07-27)

- @matter/general
Expand Down
4 changes: 3 additions & 1 deletion docs/CLUSTER_DEFAULT_IMPLEMENTATIONS.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,8 @@ For examples for nearly all clusters check the [All Clusters Test App](../suppor
Matter.js allows the following clusters to be used directly with a default implementation for all relevant commands - or the clusters do not need any commands and so the standard feature set of Matter.js for attributes is sufficient.
All clusters contained in this list are also verified using the CI YAML project-chip certification tests.

The "Features" column lists the features the default implementation provides logic for. It does not list enabled features: your application selects the features its device supports with `ClusterServer.with("FeatureName")`, and a cluster the specification allows no choice for selects itself.

| PICS | Cluster name | Features | Matter-Version | Additional Information |
|:------------|-------------------------------------------------------------------|---------------------------|----------------|-----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|
| ACL | Access Control | None | 1.5.1 | Access Restriction List (ARL) feature is not implemented |
Expand Down Expand Up @@ -51,7 +53,7 @@ All clusters contained in this list are also verified using the CI YAML project-
| NDOCONC | Nitrogen Dioxide (NO2) Concentration Measurement | All | 1.5.1 | No command implementations needed. |
| MOD | Mode Select | OnOff | 1.5.1 | Default implementation sets the new mode when change command is used and automatically handles StartUpMode and OnMode. |
| OCC | Occupancy Sensing | None | 1.5.1 | No command implementations needed. The optional event is not implemented. Default Logic automatically fills the legacy attributes occupancySensorTypeBitmap and occupancySensorType based on the enabled features. |
| OO | OnOff | All | 1.5.1 | LevelControl-For-Lighting feature is enabled automatically in default implementation. `offWithEffect` currently ignores requested effects and turns off. `onWithRecallGlobalScene` currently ignores the global scene and turns on. No special logic is implemented for DeadFrontBehavior feature because too use-case specific but can be used and logic adjusted as needed. |
| OO | OnOff | All | 1.5.1 | Logic for the LevelControl-For-Lighting feature is implemented; select the feature to enable it. `offWithEffect` currently ignores requested effects and turns off. `onWithRecallGlobalScene` currently ignores the global scene and turns on. No special logic is implemented for DeadFrontBehavior feature because too use-case specific but can be used and logic adjusted as needed. |
| OPCREDS | Operational Credentials | None | 1.5.1 | Internal Root cluster implemented, no need to customize |
| OTAP | OTA Software Update Provider | None | 1.5.1 | Full implementation with additional UIs and components for controller, can be used by developers for their own use cases |
| OTAR | OTA Software Update Requestor | None | 1.5.1 | Basically fully functional, but applying the update needs to be implemented by the developer. |
Expand Down
50 changes: 49 additions & 1 deletion packages/model/src/common/FeatureSet.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@
* SPDX-License-Identifier: Apache-2.0
*/

import { isDeepEqual } from "@matter/general";
import { camelize, isDeepEqual } from "@matter/general";
import type { ValueModel } from "../models/index.js";

/**
Expand Down Expand Up @@ -63,6 +63,54 @@ export namespace FeatureSet {
export type Flags = Iterable<FeatureSet.Flag>;
export type Definition = Flags | { [name: string]: boolean | undefined };

/**
* A feature as named by the specification.
*/
export interface Named {
name: string;
title?: string;
}

/**
* Resolve feature names to short codes.
*
* Callers name features in several ways: the specification's short code ("LT"), its title ("Lighting") or the
* camelized title generated APIs expose ("lighting"). All resolve here so every entry point accepts the same
* vocabulary. Match is case insensitive.
*
* @returns the resolved short codes and any names that resolve to no feature
*/
export function resolve(features: readonly Named[], names: Iterable<string>) {
const byName = new Map<string, string>();
for (const feature of features) {
const title = feature.title ?? feature.name;
for (const alias of [feature.name, title, camelize(title)]) {
byName.set(alias.toLowerCase(), feature.name);
}
}

const resolved = new FeatureSet();
const unresolved = new Array<string>();

for (const name of names) {
const code = byName.get(name.toLowerCase());
if (code === undefined) {
unresolved.push(name);
} else {
resolved.add(code);
}
}

return { features: resolved, unresolved };
}

/**
* The names {@link resolve} accepts, as the specification titles them.
*/
export function titlesOf(features: readonly Named[]) {
return features.map(feature => feature.title ?? feature.name);
}

/**
* Normalize the feature map and list of supported feature names into sets of "all" and "supported" features by
* abbreviation.
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,81 @@
/**
* @license
* Copyright 2022-2026 Matter.js Authors
* SPDX-License-Identifier: Apache-2.0
*/

import { describeList, InternalError, Logger } from "@matter/general";
import { ClusterModel } from "../../models/ClusterModel.js";
import { FeatureBitmap } from "./FeatureBitmap.js";
import { IllegalFeatureCombinations } from "./IllegalFeatureCombinations.js";

const logger = Logger.get("FeatureSelectionErrors");

/**
* Assess a cluster's supported features against the combinations its FeatureMap conformance disallows.
*
* Feature selection is the application's, so these constraints are only knowable once a selection is made. We report
* against {@link IllegalFeatureCombinations} so this assessment cannot diverge from the one codegen uses to decide
* whether an application must select features itself.
*
* @param cluster the cluster whose {@link ClusterModel.supportedFeatures} to assess
* @returns one message per violated combination, empty if the selection conforms
*/
export function FeatureSelectionErrors(cluster: ClusterModel): string[] {
let illegal;
try {
({ illegal } = IllegalFeatureCombinations(cluster));
} catch (error) {
// Our ruleset does not cover every conformance the grammar allows. A shape we cannot analyze leaves the
// selection unassessed rather than preventing the cluster from operating
if (!(error instanceof InternalError)) {
throw error;
}
logger.warn(`Cannot assess feature selection for ${cluster.name}: ${error.message}`);
return [];
}

if (!illegal.length) {
return [];
}

const supported = cluster.supportedFeatures;
const titles = new Map(cluster.features.map(feature => [feature.name, feature.title ?? feature.name]));
const titleOf = (name: string) => titles.get(name) ?? name;

const errors = new Set<string>();

for (const combination of illegal) {
const names = Object.keys(combination);
if (names.some(name => supported.has(name) !== combination[name])) {
continue;
}

errors.add(describe(combination, names, titleOf));
}

return [...errors];
}

function describe(combination: FeatureBitmap, names: string[], titleOf: (name: string) => string) {
const required = names.filter(name => !combination[name]).map(titleOf);
const forbidden = names.filter(name => combination[name]).map(titleOf);

if (!required.length) {
return forbidden.length > 1
? `features ${describeList("and", ...forbidden)} cannot be selected together`
: `feature ${forbidden[0]} is not allowed`;
}

// Several required features satisfy the combination individually, so the requirement is disjunctive
const requirement =
required.length > 1
? `select at least one of ${describeList("or", ...required)}`
: `feature ${required[0]} is mandatory`;

if (!forbidden.length) {
return requirement;
}

return `${requirement} when ${describeList("and", ...forbidden)} ${forbidden.length > 1 ? "are" : "is"} selected`;
}
Original file line number Diff line number Diff line change
Expand Up @@ -206,7 +206,7 @@ function addFeatureNode(

for (const lhsFeature in lhsFeatures) {
add({
feature: false,
[feature.name]: false,
[lhsFeature]: lhsFeatures[lhsFeature],
...rhsFeature,
});
Expand All @@ -215,8 +215,11 @@ function addFeatureNode(
}

case Conformance.Operator.OR: {
// The feature is mandatory when any disjunct holds, so each disjunct without it is illegal
const features = extractDisjunctFeatures(node);
add(Object.fromEntries(Object.entries(features).map((k, v) => [k, !v])));
for (const name in features) {
add({ [name]: features[name], [feature.name]: false });
}
break;
}

Expand Down
1 change: 1 addition & 0 deletions packages/model/src/logic/cluster-variance/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
*/

export * from "./FeatureBitmap.js";
export * from "./FeatureSelectionErrors.js";
export * from "./IllegalFeatureCombinations.js";
export * from "./InferredComponents.js";
export * from "./NamedComponents.js";
Expand Down
59 changes: 37 additions & 22 deletions packages/model/src/models/ClusterModel.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,10 +4,10 @@
* SPDX-License-Identifier: Apache-2.0
*/

import type { Conformance } from "#aspects/Conformance.js";
import { Conformance } from "#aspects/Conformance.js";
import { ModelIndex } from "#logic/ModelIndex.js";
import { ModelTraversal } from "#logic/ModelTraversal.js";
import { camelize, describeList } from "@matter/general";
import { describeList } from "@matter/general";
import { Access } from "../aspects/Access.js";
import { Quality } from "../aspects/Quality.js";
import { SchemaImplementationError } from "../common/errors.js";
Expand Down Expand Up @@ -137,18 +137,47 @@ export class ClusterModel
return new FeatureSet(this.features.map(feature => feature.name));
}

/**
* Features the implementation supports.
*
* A feature's `default` conveys the specification's fallback value and never implies support.
*/
get supportedFeatures(): FeatureSet {
const supported = {} as { [name: string]: boolean | undefined };
for (const feature of this.features) {
if (feature.default) {
if (feature.effectiveIsSupported) {
supported[feature.name] = true;
}
}
return new FeatureSet(supported);
}

/**
* Features the specification mandates without condition.
*
* These are not an application choice. A server implementation supports them regardless of selection; a client
* defers to the features its peer reports.
*/
get unconditionalFeatures(): FeatureSet {
const features = new FeatureSet();
for (const feature of this.features) {
if (feature.effectiveConformance.ast.type === Conformance.Flag.Mandatory) {
features.add(feature.name);
}
}
return features;
}

set supportedFeatures(features: FeatureSet.Definition | undefined) {
const featureSet = new FeatureSet(features);
const all = this.features;
const { features: selected, unresolved } = FeatureSet.resolve(all, new FeatureSet(features));

if (unresolved.length) {
throw new SchemaImplementationError(
this,
`Cannot set unknown feature${unresolved.length > 1 ? "s" : ""} ${describeList("and", ...unresolved)}`,
);
}

let featureMap = this.featureMap;

Expand All @@ -157,18 +186,11 @@ export class ClusterModel
this.children.push(featureMap);
}

// Taking ownership of the feature map shadows the models resolved above, so resolve again
for (let feature of this.features) {
const desc = feature.title && camelize(feature.title);
let isSupported;
if (desc !== undefined && featureSet.has(desc)) {
isSupported = true;
featureSet.delete(desc);
} else if (featureSet.has(feature.name)) {
isSupported = true;
featureSet.delete(feature.name);
}
const isSupported = selected.has(feature.name);

if (!!feature.default === isSupported) {
if (feature.effectiveIsSupported === isSupported) {
continue;
}

Expand All @@ -177,14 +199,7 @@ export class ClusterModel
featureMap.children.push(feature);
}

feature.default = isSupported ? true : undefined;
}

if (featureSet.size) {
throw new SchemaImplementationError(
this,
`Cannot set unknown feature${featureSet.size > 1 ? "s" : ""} ${describeList("and", ...featureSet)}`,
);
feature.operationalIsSupported = isSupported;
}
}

Expand Down
Loading
Loading