- 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