src/app/clusters) using the DefaultServerCluster base class (code-driven data model approach), as opposed to the legacy ZAP/Ember codegen approach.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.
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.
Build system rules:
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).<ClusterName>Cluster.h/cpp, test files) go in BUILD.gn.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.src/app/clusters/BUILD.gn.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.<ClusterName>Cluster.h)#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:
DefaultServerCluster. Pass { endpointId, ClusterId } to the base constructor.OptionalAttributeSet as a using alias so callers can refer to it via FooCluster::OptionalAttributeSet.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.Config object into separate member variables in the cluster class. This allows marking immutable fields as const and prevents accidental runtime modification.VerifyOrDie (programming errors that indicate a logic bug at call site, not a recoverable runtime error).protected or private members.<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.
ReadAttributeDataModel::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; } }
Protocols::InteractionModel::Status::UnsupportedAttribute directly in the default case. Do not call DefaultServerCluster::ReadAttribute — it has no base behavior for read operations.ReadAttribute is only called for paths that are in the Attributes() list; returning UnsupportedAttribute for anything unrecognised is the correct and consistent pattern.ClusterRevision and FeatureMap explicitly.Attributes() list. The framework pre-filters requests based on the supported attributes list.UnsupportedAttribute inside attribute switch handling. Existent path checks ensure those code lines would never be used.AttributesCHIP_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); }
AttributeListBuilder from <app/server-cluster/AttributeListBuilder.h>.kMandatoryMetadata is typically defined in <clusters/<ClusterName>/Metadata.h> (generated).OptionalAttributeSet so optional attributes are only included when enabled.Use the inherited helpers to update values; they handle checking for changes and notifying subscribers automatically:
// 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.
Return CHIP_IM_GLOBAL_STATUS(ConstraintError) (not a VerifyOrDie) for out-of-range values coming from the application at runtime:
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.
Override WriteAttribute only when the cluster has spec-defined writable attributes. Use AttributeValueDecoder to decode the incoming TLV:
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.
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; } }
InvokeCommandThe 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.
CHIP_NO_ERROR (or Protocols::InteractionModel::Status::Success), the framework will automatically add a Success status response.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.
handler->AddResponse(...) or handler->AddStatus(...).CHIP_NO_ERROR) will cause the framework to try to add another status, resulting in a bug (dual response encoding).Typical Patterns:
Command with data response:
FooResponse::Type response; // fill response... handler->AddResponse(request.path, response); return std::nullopt; // Required because we used handler->AddResponse
Command with success status (no data):
// Do the work... return CHIP_NO_ERROR; // Framework will automatically call AddStatus(Success)
Note: return Protocols::InteractionModel::Status::Success; is also valid and equivalent.
Command with error status:
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)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)); }
// 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.h/cpp (or equivalent files in the codegen/ subdirectory) is the only place where Ember/ZAP APIs are allowed. Its responsibilities are:
LazyRegisteredServerCluster<FooCluster> instances (never heap-allocate).Config structs.CodegenClusterIntegration::RegisterServer.FindClusterOnEndpoint() and optional convenience setters.// CodegenIntegration.cpp #include <app/clusters/<name>-server/CodegenIntegration.h> #include <app/clusters/<name>-server/FooCluster.h> #include <app/static-cluster-config/Foo.h> #include <data-model-providers/codegen/ClusterIntegration.h> #include <data-model-providers/codegen/CodegenDataModelProvider.h> namespace { constexpr size_t kFixedCount = Foo::StaticApplicationConfig::kFixedClusterConfig.size(); constexpr size_t kMaxCount = kFixedCount + CHIP_DEVICE_CONFIG_DYNAMIC_ENDPOINT_COUNT; LazyRegisteredServerCluster<FooCluster> gServers[kMaxCount]; class IntegrationDelegate : public CodegenClusterIntegration::Delegate { public: ServerClusterRegistration & CreateRegistration(EndpointId endpointId, unsigned clusterInstanceIndex, uint32_t optionalAttributeBits, uint32_t featureMap) override { FooCluster::Config config; config.optionalAttributes = FooCluster::OptionalAttributeSet(optionalAttributeBits); // Read defaults from Ember store. Tolerate failure (use neutral defaults). if (Foo::Attributes::SomeAttr::Get(endpointId, &config.someAttr) != Protocols::InteractionModel::Status::Success) { config.someAttr = kSomeDefaultAttrValue; } gServers[clusterInstanceIndex].Create(endpointId, config); return gServers[clusterInstanceIndex].Registration(); } ServerClusterInterface * FindRegistration(unsigned index) override { VerifyOrReturnValue(gServers[index].IsConstructed(), nullptr); return &gServers[index].Cluster(); } void ReleaseRegistration(unsigned index) override { gServers[index].Destroy(); } }; } // namespace void MatterFooClusterInitCallback(EndpointId endpointId) { // Note: integration delegate is only used for lookups and it is OK // for it to live on the stack. Typical pattern is to have // this on the stack for all init/lookup/shutdown. IntegrationDelegate delegate; CodegenClusterIntegration::RegisterServer( { .endpointId = endpointId, .clusterId = Foo::Id, .fixedClusterInstanceCount = kFixedCount, .maxClusterInstanceCount = kMaxCount, .fetchFeatureMap = false, .fetchOptionalAttributes = true }, delegate); } void MatterFooClusterShutdownCallback(EndpointId endpointId, MatterClusterShutdownType shutdownType) { IntegrationDelegate delegate; CodegenClusterIntegration::UnregisterServer( { .endpointId = endpointId, .clusterId = Foo::Id, .fixedClusterInstanceCount = kFixedCount, .maxClusterInstanceCount = kMaxCount }, delegate, shutdownType); } namespace chip::app::Clusters::Foo { FooCluster * FindClusterOnEndpoint(EndpointId endpointId) { IntegrationDelegate delegate; return static_cast<FooCluster *>( CodegenClusterIntegration::FindClusterOnEndpoint( { .endpointId = endpointId, .clusterId = Foo::Id, .fixedClusterInstanceCount = kFixedCount, .maxClusterInstanceCount = kMaxCount }, delegate)); } // Optional convenience helper (common pattern): CHIP_ERROR SetSomeValue(EndpointId endpointId, int16_t value) { auto * cluster = FindClusterOnEndpoint(endpointId); VerifyOrReturnError(cluster != nullptr, CHIP_ERROR_NOT_FOUND); return cluster->SetSomeValue(value); } } // namespace chip::app::Clusters::Foo
Key rules for CodegenIntegration:
MatterFooPluginServerInitCallback / ShutdownCallback stubs unless they were generated by ZAP — only stubs that ZAP declares.Server::GetInstance().GetCASESessionManager()) must be null-checked before use via VerifyOrDie or VerifyOrReturnError.For code-driven-only applications that never use ZAP:
RegisteredServerCluster<FooCluster> gCluster(endpointId, config); CodegenDataModelProvider::Instance().Registry().Register(gCluster.Registration());
Tests live in tests/Test<ClusterName>Cluster.cpp and use the Pigweed/GTest framework.
#include <pw_unit_test/framework.h> #include <app/clusters/<name>-server/FooCluster.h> #include <app/server-cluster/testing/AttributeTesting.h> #include <app/server-cluster/testing/ClusterTester.h> #include <app/server-cluster/testing/TestServerClusterContext.h> namespace { using namespace chip; using namespace chip::app; using namespace chip::app::Clusters; using namespace chip::Testing; // Subclass to expose protected methods for testing class TestableFooCluster : public FooCluster { public: using FooCluster::FooCluster; using FooCluster::SomeProtectedMethod; }; struct TestFooCluster : public ::testing::Test { static void SetUpTestSuite() { ASSERT_EQ(chip::Platform::MemoryInit(), CHIP_NO_ERROR); } static void TearDownTestSuite() { chip::Platform::MemoryShutdown(); } TestServerClusterContext testContext; }; } // namespace TEST_F(TestFooCluster, AttributeList) { FooCluster cluster(kRootEndpointId); ASSERT_EQ(cluster.Startup(testContext.Get()), CHIP_NO_ERROR); ReadOnlyBufferBuilder<DataModel::AttributeEntry> attrs; ASSERT_EQ(cluster.Attributes(ConcreteClusterPath(kRootEndpointId, Foo::Id), attrs), CHIP_NO_ERROR); // verify expected attribute set ... cluster.Shutdown(ClusterShutdownType::kClusterShutdown); } TEST_F(TestFooCluster, ReadAttributes) { FooCluster cluster(kRootEndpointId); ASSERT_EQ(cluster.Startup(testContext.Get()), CHIP_NO_ERROR); ClusterTester tester(cluster); // Read mandatory attributes uint16_t revision{}; ASSERT_EQ(tester.ReadAttribute(Foo::Attributes::ClusterRevision::Id, revision), CHIP_NO_ERROR); cluster.Shutdown(ClusterShutdownType::kClusterShutdown); }
Attributes() only when enabled.CHIP_NO_ERROR for valid values.CHIP_IM_GLOBAL_STATUS(ConstraintError) for out-of-range values (including boundary values).0xFFFF for uint16) is rejected when written via the data model.Startup / Shutdown cycle works correctly.Testable* subclass when needed.sleep() calls — time-dependent behavior uses a mock clock instead.| Location | Ember / ZAP APIs allowed? |
|---|---|
<ClusterName>Cluster.h/cpp | No — never |
CodegenIntegration.h/cpp | Yes — exclusively here |
| Tests | No — use TestServerClusterContext |
Forbidden in cluster core code: EmberAfStatus, emberAfContainsServer, emberAfReadAttribute, emberAfWriteAttribute, ZAP-generated accessor functions (Foo::Attributes::Bar::Get/Set).
README.md file explaining the upgrade steps in the cluster folder.README.md every time is encouraged to explain the API and usage further.README.md next to the cluster files. See src/app/clusters/actions-server/README.md or src/app/clusters/air-quality-server/README.md for good examples.These are patterns that reviewers have flagged repeatedly — avoid them:
.h file must appear in a build file.CodegenIntegration.cpp.VerifyOrDie / null checks on singleton pointers — e.g. Server::GetInstance().GetCASESessionManager() may return null.min > max in ZAP, the cluster should handle this safely (e.g. by nulling the range) rather than crashing.MinMeasuredValue, MaxMeasuredValue, MeasurementUnit) must not have setters in the cluster or the delegate. They should be read on-demand from the delegate to save RAM and code size.TestServerClusterContext) rather than accessing Server::GetInstance().LogErrorOnFailure omitted on fire-and-forget calls — use LogErrorOnFailure(cluster->SetValue(...)) rather than silently ignoring errors from setters.AddStatus — Prefer returning the status directly from InvokeCommand for simple status returns (no data payload), letting the framework handle AddStatus automatically.Server::GetInstance() and InteractionModelEngine::GetInstance(), are very large and difficult to mock or use in tests. Review whether smaller, more focused objects can be used or if additional decoupling is possible. Example considerations:Server object, inject the specific objects needed by the cluster (e.g., FabricTable and EndpointTable).InteractionModelEngine::GetInstance()->GetDataModelProvider(), use the DataModel::Provider that is injected into the cluster context.Server or InteractionModelEngine, consider providing a delegate member and implementing the complex logic in CodegenIntegration.h/cpp when the goal is to avoid direct coupling to Server / InteractionModelEngine in the cluster itself when only a small subset of their functionality is needed.using DataModel::X aliases in headers. Exception: within a class body, using Feature = SomeConcreteCluster::Feature is acceptable (and useful) for base-cluster type aliasing so that codegen-derived types are accessible through the base.void OnFooChanged(BarCluster & cluster, Foo newValue)).mEndpointId. Use mPath.mEndpointId (inherited from DefaultServerCluster) directly.9999), but hex is better for bitmasks, nullable sentinels (e.g., 0xFFFE, 0xFF), and range boundaries that are naturally expressed in hex (e.g., 0x3FFF, 0x7FFF). Do not mechanically convert hex to decimal just to avoid hex.ReadAttribute / WriteAttribute / InvokeCommand — Do not add checks to verify that the incoming path is valid before dispatching. The API contract guarantees that these methods are only called for paths that appear in Attributes() / AcceptedCommands(); adding redundant guards wastes flash.Study these clusters to understand specific implementation patterns:
| Pattern | Reference Cluster | PR |
|---|---|---|
| Simple Measurement | relative-humidity-measurement-server | #71424 |
| Command-heavy + Delegate | actions-server | #43471 |
| Multi-instance | closure-dimension-server | #43720 |
| Singleton (Node-scoped) | basic-information | #40422 |
| Runtime-only (no defaults) | flow-measurement-server | #71552 |
| Identify/Timer-driven | identify-server | #41232 |
| Writable scalar + features | switch-server | #42968 |
src/app/server-cluster/DefaultServerCluster.hsrc/app/server-cluster/ServerClusterInterface.hsrc/app/server-cluster/testing/src/data-model-providers/codegen/ClusterIntegration.hsrc/app/clusters/air-quality-server/src/app/clusters/on-off-server/src/app/clusters/actions-server/src/app/clusters/flow-measurement-server/docs/guides/migrating_ember_cluster_to_code_driven.mddocs/guides/writing_clusters.md