Skip to main content

code-driven-cluster-development

Guidelines for implementing or migrating Matter server clusters (code that resides in `src/app/clusters`) using the DefaultServerCluster base class (code-driven data model approach), as opposed to the legacy ZAP/Ember codegen approach.

Source facts

Repository
project-chip/connectedhomeip
Last source activity
April 28, 2026 at 15:13
Detected SKILL.md language
English
Stars
8,958
Forks
2,479

Install options

The review-first prompt is selected by default. You can switch to a direct command or download a local copy.

Review the source files

Read SKILL.md and any companion files shown by SkillsMP before deciding whether to install.

Showing SKILL.md

SKILL.md
Source instructions · Read-only preview
name
code-driven-cluster-development
description
Guidelines for implementing or migrating Matter server clusters (code that resides in `src/app/clusters`) using the DefaultServerCluster base class (code-driven data model approach), as opposed to the legacy ZAP/Ember codegen approach.
# Code-Driven Cluster Development ## What Is a Code-Driven Cluster? A _code-driven_ cluster is a `ServerClusterInterface` implementation that lives in `src/app/clusters/<cluster-folder>/` and extends `DefaultServerCluster`. It stores its own attribute state in C++ member variables instead of relying on the Ember attribute RAM store. The framework calls the cluster's virtual methods (`ReadAttribute`, `WriteAttribute`, `InvokeCommand`, …) directly; ZAP-generated attribute accessors (`emberAfReadAttribute` etc.) must not be used inside the cluster class itself. Note that the `<cluster-folder>` naming is not standardized, but often starts with the cluster name. It is a mapping defined in `src/app/zap_cluster_list.json` and it is often (but not always) `<cluster-name>-server`. --- ## Directory / File Layout A common pattern for code-driven cluster directory layout is: ``` src/app/clusters/<cluster-name>-server/ ├── <ClusterName>Cluster.h # Core class (extends DefaultServerCluster) ├── <ClusterName>Cluster.cpp # Core class implementation ├── CodegenIntegration.h # App-specific Bridge: ZAP ↔ code-driven cluster ├── CodegenIntegration.cpp # App-specific ZAP callbacks + FindClusterOnEndpoint() ├── BUILD.gn # Core files (does NOT include CodegenIntegration) ├── app_config_dependent_sources.cmake # Application code-generation dependencies ├── app_config_dependent_sources.gni # Application code-generation dependencies └── tests/ ├── BUILD.gn └── Test<ClusterName>Cluster.cpp ``` _Alternative: Legacy-Preserving Layout_ Some clusters (e.g., `on-off-server`) use a layout that keeps the legacy Ember/ZAP implementation in a `codegen/` subdirectory while placing the new code-driven implementation in the root. - **Not Typical:** This approach is not typical and is generally discouraged as it maintains two parallel implementation paths for the same cluster, increasing the burden on maintenance, testing, and validation. - **Last Resort:** This is only done as a last resort when complex cluster inter-dependencies make a full migration difficult without incurring significant code complexity or unacceptable resource (Flash/RAM) overhead. **Build system rules:** - Codegen integration files (whether `CodegenIntegration.cpp` or files in `codegen/`) go in `app_config_dependent_sources.cmake` and `app_config_dependent_sources.gni` (these are the codegen-dependent files). - All other files (`<ClusterName>Cluster.h/cpp`, test files) go in `BUILD.gn`. - **Sources must belong to a single build target**: Files referenced in `BUILD.gn` and `app_config_dependent_sources.*` must be mutually exclusive. - `app_config_dependent_sources.cmake` and `app_config_dependent_sources.gni` must not contain non-codegen files. - The cluster should be added to the cluster list compiled in `src/app/clusters/BUILD.gn`. - Every source file must appear somewhere: either `BUILD.gn` (if no App-specific dependencies) or `app_config_dependent_sources.*` if depending on application ZAP configuration. Unlisted headers or cpp files are a review red flag. --- ## Core Cluster Class ### Header (`<ClusterName>Cluster.h`) ```cpp #pragma once #include <app/server-cluster/DefaultServerCluster.h> #include <app/server-cluster/OptionalAttributeSet.h> #include <clusters/<ClusterName>/Attributes.h> #include <clusters/<ClusterName>/Metadata.h> namespace chip::app::Clusters { class FooCluster : public DefaultServerCluster { public: // Optional attributes are tracked as a compile-time bitset. using OptionalAttributeSet = app::OptionalAttributeSet< Foo::Attributes::SomeOptional::Id, Foo::Attributes::AnotherOptional::Id>; // Use a Config/StartupConfiguration class (or struct for simple cases) for // constructor arguments that may be optional or have defaults. Use a class // with private members and builder-style .WithXxx() setters to prevent // misconfiguration in non-trivial cases. class Config { public: Config & WithMinValue(DataModel::Nullable<int16_t> min) { mMinValue = min; return *this; } Config & WithMaxValue(DataModel::Nullable<int16_t> max) { mMaxValue = max; return *this; } Config & WithOptionalAttributes(OptionalAttributeSet attrs) { mOptionalAttributes = attrs; return *this; } private: friend class FooCluster; DataModel::Nullable<int16_t> mMinValue{}; DataModel::Nullable<int16_t> mMaxValue{}; OptionalAttributeSet mOptionalAttributes{}; }; FooCluster(EndpointId endpointId, const Config & config = {}); // --- ServerClusterInterface overrides --- DataModel::ActionReturnStatus ReadAttribute(const DataModel::ReadAttributeRequest & request, AttributeValueEncoder & encoder) override; CHIP_ERROR Attributes(const ConcreteClusterPath & path, ReadOnlyBufferBuilder<DataModel::AttributeEntry> & builder) override; // Application-facing API CHIP_ERROR SetMeasuredValue(DataModel::Nullable<int16_t> value); DataModel::Nullable<int16_t> GetMeasuredValue() const { return mMeasuredValue; } protected: const BitFlags<Foo::Feature> mFeatureMap; OptionalAttributeSet mOptionalAttributeSet; DataModel::Nullable<int16_t> mMeasuredValue{}; // ... other member variables }; } // namespace chip::app::Clusters ``` Key points: - Inherit from `DefaultServerCluster`. Pass `{ endpointId, ClusterId }` to the base constructor. - Declare `OptionalAttributeSet` as a `using` alias so callers can refer to it via `FooCluster::OptionalAttributeSet`. - **Use a `Config` type:** For constructor arguments that may be optional or have defaults. Prefer a `class` with private members and builder-style `.WithXxx()` setters in non-trivial cases, and use a `struct` only for simple passive configuration bundles. - **Store Separate Variables:** Extract fields from the `Config` object into separate member variables in the cluster class. This allows marking immutable fields as `const` and prevents accidental runtime modification. - Validate constructor arguments with `VerifyOrDie` (programming errors that indicate a logic bug at call site, not a recoverable runtime error). - Expose application-facing setters/getters; keep attribute storage in `protected` or `private` members. ### Implementation (`<ClusterName>Cluster.cpp`) **When to call the base class**: You MUST call the base class from `Startup()` and `Shutdown()`. Do NOT call the base class from `ReadAttribute`, `WriteAttribute`, or `InvokeCommand` — there is no base-class behavior for these methods; return `UnsupportedAttribute` / `UnsupportedCommand` directly in the `default` case instead. **Only override `Startup`/`Shutdown` when custom code is needed** (e.g. reading persisted state on startup, registering a timer on startup and cancelling it on shutdown). If your override would only call the base, omit it entirely — a `Startup` that just calls `DefaultServerCluster::Startup` is dead weight. #### `ReadAttribute` ```cpp DataModel::ActionReturnStatus FooCluster::ReadAttribute( const DataModel::ReadAttributeRequest & request, AttributeValueEncoder & encoder) { using namespace Foo::Attributes; switch (request.path.mAttributeId) { case ClusterRevision::Id: return encoder.Encode(Foo::kRevision); case FeatureMap::Id: return encoder.Encode(static_cast<uint32_t>(mFeatureMap.Raw())); case MeasuredValue::Id: return encoder.Encode(mMeasuredValue); // ... other attributes default: return Protocols::InteractionModel::Status::UnsupportedAttribute; } } ``` - **Return `Protocols::InteractionModel::Status::UnsupportedAttribute` directly in the `default` case. Do not call `DefaultServerCluster::ReadAttribute` — it has no base behavior for read operations.** - The framework pre-filters requests so `ReadAttribute` is only called for paths that are in the `Attributes()` list; returning `UnsupportedAttribute` for anything unrecognised is the correct and consistent pattern. - Always encode/handle `ClusterRevision` and `FeatureMap` explicitly. - **Do not add path-validity checks** before the switch — they add code size and are redundant because the framework guarantees the path exists. - **Do not add feature-flag checks inside the switch cases** for optional attributes if those attributes are already conditionally included in the `Attributes()` list. The framework pre-filters requests based on the supported attributes list. - Do not add returning `UnsupportedAttribute` inside attribute switch handling. Existent path checks ensure those code lines would never be used. #### `Attributes` ```cpp CHIP_ERROR FooCluster::Attributes( const ConcreteClusterPath & path, ReadOnlyBufferBuilder<DataModel::AttributeEntry> & builder) { AttributeListBuilder listBuilder(builder); const DataModel::AttributeEntry optionalAttrs[] = { Foo::Attributes::SomeOptional::kMetadataEntry, }; return listBuilder.Append(Span(Foo::Attributes::kMandatoryMetadata), Span(optionalAttrs), mOptionalAttributeSet); } ``` - Use `AttributeListBuilder` from `<app/server-cluster/AttributeListBuilder.h>`. - `kMandatoryMetadata` is typically defined in `<clusters/<ClusterName>/Metadata.h>` (generated). - Pass the `OptionalAttributeSet` so optional attributes are only included when enabled. #### Attribute mutation helpers Use the inherited helpers to update values; they handle checking for changes and notifying subscribers automatically: ```cpp // Updates the value AND notifies subscribers automatically: SetAttributeValue(mSomeField, newValue, Foo::Attributes::SomeField::Id); // For nullable attributes: SetAttributeValue(mNullableField, DataModel::NullNullable, Foo::Attributes::NullableField::Id); ``` `SetAttributeValue` returns `true` if the value actually changed (and thus a notification was sent). `NotifyAttributeChanged` should only be used directly for manually-complex cases (e.g. updating a list member) where it increments the data version and notifies the IM engine. #### Spec constraint validation Return `CHIP_IM_GLOBAL_STATUS(ConstraintError)` (not a `VerifyOrDie`) for out-of-range values coming from the application at runtime: ```cpp CHIP_ERROR FooCluster::SetMeasuredValue(DataModel::Nullable<int16_t> value) { if (!value.IsNull()) { VerifyOrReturnError(value.Value() >= kMinAllowed && value.Value() <= kMaxAllowed, CHIP_IM_GLOBAL_STATUS(ConstraintError)); } SetAttributeValue(mMeasuredValue, value, Foo::Attributes::MeasuredValue::Id); return CHIP_NO_ERROR; } ``` Use `VerifyOrDie` in the constructor for invariants that must hold at construction time (programming errors), and `VerifyOrReturnError` for runtime checks. #### Writable attributes Override `WriteAttribute` only when the cluster has spec-defined writable attributes. Use `AttributeValueDecoder` to decode the incoming TLV: ```cpp DataModel::ActionReturnStatus FooCluster::WriteAttribute( const DataModel::WriteAttributeRequest & request, AttributeValueDecoder & decoder) { using namespace Foo::Attributes; switch (request.path.mAttributeId) { case WritableAttr::Id: { uint16_t value{}; ReturnErrorOnFailure(decoder.Decode(value)); return SetWritableAttr(value); } default: return Protocols::InteractionModel::Status::UnsupportedAttribute; } } ``` Return `Protocols::InteractionModel::Status::UnsupportedAttribute` directly in the `default` case. Do not delegate to `DefaultServerCluster::WriteAttribute` — there is no base-class behavior for write operations. #### Commands ```cpp std::optional<DataModel::ActionReturnStatus> FooCluster::InvokeCommand( const DataModel::InvokeRequest & request, chip::TLV::TLVReader & input_arguments, CommandHandler * handler) { using namespace Foo::Commands; switch (request.path.mCommandId) { case DoSomething::Id: { DoSomething::DecodableType req; ReturnErrorOnFailure(DataModel::Decode(input_arguments, req)); return HandleDoSomething(req, handler); } default: return Protocols::InteractionModel::Status::UnsupportedCommand; } } ``` ##### Return Codes for `InvokeCommand` The return type of `InvokeCommand` is `std::optional<DataModel::ActionReturnStatus>`. Understanding what to return is critical to avoid encoding duplicate responses: - **Any return except `std::nullopt` implies an automatic call to `handler->AddStatus`.** - If you return `CHIP_NO_ERROR` (or `Protocols::InteractionModel::Status::Success`), the framework will automatically add a Success status response. - If you return an error (e.g., `CHIP_ERROR_INVALID_ARGUMENT` or a specific IM status), the framework will automatically add the corresponding error status. - **If you manually add a response or status to the handler, you MUST return `std::nullopt`.** - This applies if you call `handler->AddResponse(...)` or `handler->AddStatus(...)`. - Returning anything else (even `CHIP_NO_ERROR`) will cause the framework to try to add another status, resulting in a bug (dual response encoding). **Typical Patterns:** 1. **Command with data response:** ```cpp FooResponse::Type response; // fill response... handler->AddResponse(request.path, response); return std::nullopt; // Required because we used handler->AddResponse ``` 2. **Command with success status (no data):** ```cpp // Do the work... return CHIP_NO_ERROR; // Framework will automatically call AddStatus(Success) ``` _Note: `return Protocols::InteractionModel::Status::Success;` is also valid and equivalent._ 3. **Command with error status:** ```cpp if (error) { return Protocols::InteractionModel::Status::ConstraintError; // Framework will AddStatus } ``` **Common Anti-Patterns (Bugs):** - `handler->AddResponse(path, response); return CHIP_NO_ERROR;` (Bug: encodes response AND success status) - `handler->AddStatus(path, Status::Success); return CHIP_NO_ERROR;` (Bug: encodes success status twice) ```cpp CHIP_ERROR FooCluster::AcceptedCommands( const ConcreteClusterPath & path, ReadOnlyBufferBuilder<DataModel::AcceptedCommandEntry> & builder) { static constexpr DataModel::AcceptedCommandEntry kCommands[] = { Foo::Commands::DoSomething::kMetadataEntry, }; return builder.ReferenceExisting(Span(kCommands)); } ``` #### Events ```cpp // Emit a spec-defined event using the cluster context: Foo::Events::StateChanged::Type event{ /* fields */ }; mContext->interactionContext.eventsGenerator.GenerateEvent(event, mPath.mEndpointId); ``` Override `EventInfo` only when non-default read privileges are needed. --- ## CodegenIntegration Layer `CodegenIntegration.h/cpp` (or equivalent files in the `codegen/` subdirectory) is the **only** place where Ember/ZAP APIs are allowed. Its responsibilities are: 1. Declare a file-scope array of `LazyRegisteredServerCluster<FooCluster>` instances (never heap-allocate). 2. Read ZAP attribute store defaults and construct `Config` structs. 3. Register/unregister clusters via `CodegenClusterIntegration::RegisterServer`. 4. Implement `FindClusterOnEndpoint()` and optional convenience setters. 5. Provide empty stubs for legacy plugin callbacks. ### Typical pattern ```cpp // CodegenIntegration.cpp
View on GitHub
This SKILL.md is very large, so SkillsMP previews the first section here. View on GitHub