Skip to main content

device-type-development

Step-by-step guide for adding a new Matter device type to `examples/all-devices-app` (generic type class, simulated impl, build wiring, OOB accessors and named-pipe plumbing, CI tests, docs tables). The layout contract, PR-splitting strategy, and settled review-recurrence rules for device-type changes.

Source facts

Repository
project-chip/connectedhomeip
Last source activity
November 8, 2026 at 16:58
Detected SKILL.md language
English
Stars
8,982
Forks
2,503

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
device-type-development
description
Step-by-step guide for adding a new Matter device type to `examples/all-devices-app` (generic type class, simulated impl, build wiring, OOB accessors and named-pipe plumbing, CI tests, docs tables). The layout contract, PR-splitting strategy, and settled review-recurrence rules for device-type changes.
# Device Type Development This guide covers adding a **device type** to `examples/all-devices-app`. It complements `code-driven-cluster-development` (which covers clusters living in `src/app/clusters`) — a device type PR usually does **not** touch `src/app/clusters` unless a required cluster is still ember-based, in which case that cluster must land first (see `code-driven-cluster-migration`). --- ## Layout Contract (do not violate) Current-tree layout: ``` examples/all-devices-app/all-devices-common/device/ ├── api/ # DeviceInterface / SingleEndpoint base classes ├── capabilities/ # SHARED implementations reused by 2+ device types ONLY └── types/<device-type>/ ├── BUILD.gn # source sets: ":<device-type>", a named impl set │ # (":simulated", ":logging", or a flavor name), and │ # optional ":posix" ├── <TypeName>.h # GENERIC device type: spec-mandated bits only ├── <TypeName>.cpp ├── OOBAccessors.{h,cpp} # optional: register accessors against this device ├── <Thing>Accessor.{h,cpp} # optional: per-cluster OOB accessor, colocated │ # (soil-sensor, power-source, water-valve) ├── NamedPipeTranslators.{h,cpp} # optional: POSIX named-pipe registration (":posix") ├── impl/ # simulated example device; may import types/, never the reverse └── tests/ # unit tests for the generic type ``` > **Legacy device warning**: older device types in `device/types/` that lack an > `impl/` folder predate the current architecture. **Do not copy them** (e.g. > `chime` still lacks impl separation). Follow only newly structured exemplars: > `window-covering`, `mode-select`, `robotic-vacuum-cleaner`, > `thread-border-router`, `network-infrastructure-manager`, > `air-quality-sensor`, `closure`, `closure-panel`. When naming conventions > conflict between older and newer devices, copy the _newest_ device and name > the impl after what it simulates. Rules (the usual comments that recur in device-type code reviews — "pay attention to these"): 1. **`types/<t>/<T>.h` = generic.** Must compile for any app. Hardcode nothing the spec does not mandate: features, optional attributes, and optional clusters are all injected (`Config.features` / `OptionalAttributeSet`), configured via variadic cluster-ID templates (see `AirQualitySensor.h` for the many-optionals form; plain virtual hooks are fine for 1–2 optionals), or deferred to a `virtual RegisterOptionalClusters()` hook that only subclasses implement. The base `Register()` registers mandatory clusters, then calls the hook before `provider.AddEndpoint()`; `Unregister()` mirror-hooks accordingly after `UnregisterDescriptor()`. 2. **`impl/` is a strict one-way dependency**: `impl/` may import `types/`, `types/` must never import or construct anything from `impl/`. No simulation/json/pipe logic in the generic class. 3. **`capabilities/` is for genuinely shared, multi-device-type pieces.** When in doubt, create no capability at all — put the delegate in the device's own `impl/`. Fewer layers beats more inheritance. 4. **Prefer one `impl/<Flavored><Device>` class** implementing/owning all delegates over several delegate classes that point to each other. Cross-cluster state belongs in one object with one state machine, not booleans scattered across delegates. Composite multi-endpoint devices (closure, irrigation-system) are the exception: one parent + child parts. 5. **Dependencies are references, not nullable pointers.** Pack them in a `Context` / `Delegates` struct (see `WindowCovering.h`) and inject from factory setup. Mandatory peers (e.g. Run Mode for RVC) are references; constructor sequencing guarantees them — no runtime null checks (the Identify delegate, for example, must be set at construction). 6. **Model device state as a single enum** (`Docked`, `DockedCharging`, `Running`, `Paused`, …) with the transition diagram in a doc comment (see `SimulatedWindowCovering`). Two loosely-coupled booleans caused a real bug class in a past device type. 7. **No globals inside device-type code.** Timer delegates, storage, etc. are injected. 8. **Name the impl source set and class after the simulation** (`Simulated<T>`/`:simulated` for general simulators such as window-covering; `Logging<T>`/`:logging` for log-driven impls such as water-valve; a flavor name when multiple variants exist — `temperature-sensor:increasing`, `soil-sensor:increasing`). The flavor name beats the generic `simulated`/`logging` label. --- ## Settled Idioms (the usual review comments, in rule form) **Dependency & lifecycle** - `Context` / `Delegates` structs with **references** (see `WindowCovering.h`); "inject all the things" — concrete member objects, inherited/hardcoded delegates, and globals are all rejected. Shared app-level state (e.g. BreadCrumbTracker) lives in `DeviceFactory::Context`, not per-device duplicates. - No heap in device code: use `LazyRegisteredServerCluster<T>` instead of `unique_ptr` cluster members, and never use `std::function` timer callbacks (use `TimerDelegate` + member state). - Wrap registration in `DeviceRegistrationTransaction` + `Commit()` for partial-install rollback; guard cluster teardown with `if (mCluster.IsConstructed())` and child-part unregistration via `GetEndpointId() != kInvalidEndpointId`; capture the endpoint ID before `UnregisterDescriptor()` if teardown needs it — `SingleEndpoint::UnregisterDescriptor()` resets `mEndpointId`; `VerifyOrDie(IsConstructed())` on cluster accessors used before `Register()`; registration order matters when one cluster's `Startup()` touches another's storage. - **`DeviceRegistrationEntry` owns the public lifecycle**: callers use `entry.Register(...)` / `entry.Unregister(...)`, which run the `AfterDeviceRegistration` / `BeforeDeviceUnregistration` hook pair as `static` template capabilities (`if constexpr`-gated, fold-expression order) **while the device is still valid**. Every hook class in the tree (including no-op hooks) must implement _both_ methods — a missing method breaks other-platform builds under fold instantiation. - Inherited delegates: `impl/<Logging|Simulated><Device> : private <Cluster>Delegate, public <LeafDevice>` — delegate bases (which pass `*this`) must be listed before the device base so they are constructed first; the newest form takes `(const Context&, const <X>Config& = {})` forwarding the config as `Base(context, *this, config)`. - Attributes: `SetAttributeValue()` = set + notify atomically, skip no-op churn; for correlated clusters, sync each side explicitly on transitions (e.g. power-Off → LocalTemperature NULL + SystemMode off); spec-nullable values use `DataModel::Nullable`, not `std::optional`; never report attribute changes for state you didn't actually change (e.g. a revert path wiping a _committed_ value); simulation must read current state via cluster getters, never private shadow caches, or OOB/in-band writes are clobbered by timers. - Test-event-trigger handlers: register at device startup, unregister at shutdown — never link-time-resolved globals; handler must match endpoint ID and fall through otherwise. - Errors: `LogErrorOnFailure` / propagate — **no new `TEMPORARY_RETURN_IGNORED`** (it is a legacy transition marker; grep your diff for it before submitting — reviewers keep flagging it); no `(void)` casts to swallow `CHIP_ERROR`. **Build & naming hygiene** - Alphabetical order everywhere (`DeviceFactory.h`, `enabled_devices.*`, `oob-accessors` registration lists, `Kconfig.devices`, `targets.py` keep-sorted blocks) — order violations recur. - `BUILD.gn` depends on what it uses directly (`lib/support`, `app/persistence`), never transitive includes; `device-factory/BUILD.gn` must publicly depend on the impl source set it instantiates (`mode-select:simulated`, `temperature-sensor:increasing`). - GN deps follow the consumer: when an optional cluster moves from the generic type into `impl/`, its cluster deps move out of the base source set into the impl set with it, and the generic header's includes and `Context` members referencing it go too (reviewers will ask "do we still need these here?"). - No `Device` suffix on classes/filenames; spell device names out (no PICS acronyms); no nested-stutter types (`WaterValve::Listener`, not `WaterValve::WaterValveListener`); constants `k`-prefixed with units; doc comments on public members only in `.h`, never duplicated in `.cpp`; no word "legacy" in new code or comments. - Compile-time configurability beats build flags: template-ID-keyed `Config` structs + variadic optional clusters for many-optionals devices (`config.Get<CID>()`, see `AirQualitySensor`); named fixed-feature classes like `FullThermostat` when a cluster's C++ type encodes its feature map. Never `namespace Detail` or helpers visible through headers that `DeviceFactory.h` transitively includes (`namespace ...Internal` instead). - Name files after what they contain (a "Features" header that only held a concat helper was flagged in review). **OOB accessor contract (newest practice)** - Handle-and-finish or get out of the way: `HandleAction(CharSpan action, ByteSpan tlvData)` returns the final `CHIP_ERROR` when the accessor handles the action, `std::nullopt` when it doesn't — _never_ `CHIP_IM_GLOBAL_STATUS(UnsupportedWrite)`, which blocks registry dispatch and Pigweed fall-through. - Constructor takes `(Cluster&, EndpointId)`, possibly a convenience `(Device&)` overload; accessors are colocated with the device in `device/types/<t>/<Thing>Accessor.{h,cpp}` and compiled into the base `source_set("<t>")` depending on `oob-accessors` core (soil-sensor, power-source, water-valve) — the older centralized per-cluster targets in `oob-accessors/clusters/BUILD.gn` are historical. - Parse with `OOBDataSerializer::ParseAttributeRequest`, decode with `DataModel::Decode`, validate enums/ranges with `VerifyOrReturnError(..., CHIP_IM_GLOBAL_STATUS(ConstraintError))`; in-band/OOB coexistence is one state — track convenience state like last-open level in the accessor, not the device class. - Always ship `Test<Thing>Accessor.cpp` in `oob-accessors/tests/` using typed `OOBDataSerializer::BuildSetAttributeRequest(path, value)`; include unhandled-cluster and out-of-range cases. - `OOBAccessorHook.h` ties accessor lifetime to the device via an RAII `WithDependency(&device)` scope; `Unregister(dep)` removes only that device's accessors so process-lifetime ones survive; the Noop registry mirrors the exact API including `[[nodiscard]]`. - The DataModel fallback (what happens when no accessor handles a write) belongs in the transport caller (Pigweed Attributes service / named-pipe dispatcher), _never_ inside `InMemoryOOBAccessorRegistry` — and injected `DataModel::Provider`, no globals. **Simulation quality** - No-op delegates are rejected ("too fake"): ship real conversions/mocks with a chip-tool verification recipe (see `PlanckianColorConverter`). Timers self-reschedule and transitions are timer-driven so `Stop` is meaningful; clamp unsigned arithmetic; treat generated optionals as null (`ValueOr` path). - An impl that returns Unsupported from most delegate callbacks fails its purpose: every attribute/feature your config _enables_ needs a real delegate surface (optional setpoint-limit getters returning `UnsupportedAttribute` broke `LoadSetpoints`, `SetpointRaiseLower`, and the TC tests of that device). Likewise: rollback Unregister if `StartTimer` fails in `Register`. - Filter valid-but-unsupported enum values with `ConstraintError`, not `InvalidValue` — and don't let the config advertise a feature the filter rejects (an advertised-but-rejected mode resets to Off on startup). - **Config defaults must match `ci-pics-values`**: the yaml tests run against shared CI PICS, so a device whose `Config{}` omits features/attributes the PICS enable fails its own CI. Enable what the PICS promise, or adjust guards. - No srand-seeded "randomness" to mask CI flakes — reviewers call it out. - Device factory presets belong in the device's `impl/`, not `DeviceFactory.h`; child-only device types (`closure-panel`) are NOT independently enableable — `enabled_devices.*` lists parent-capable types only. - Delegates are implemented by the impl, never by the generic device class; remove duplicated cluster APIs from the device class — callers reach e.g. `Open()` through the `...Cluster()` getters. --- ## Step-by-Step Procedure ### Step 0 — Discover the app's own documentation first Per `AGENTS.md` guidance, read before writing: - `examples/all-devices-app/docs/supported_device_types.md` — device type table + discovery methodology (which clusters are `[O]`/optional). - `examples/all-devices-app/docs/supported_clusters.md` — which clusters are code-driven in the SDK vs ember. - `examples/all-devices-app/docs/architecture.md`, `examples/all-devices-app/docs/adding_new_device.md` (the checklist of record — add a documented step for any new wiring location), and `examples/all-devices-app/docs/design/out_of_band_control.md`. Record from your device type: its spec ID, every **server** cluster with conformance M vs O, per-cluster feature maps and optional attributes, and the split `Ready` / `Missing` against `supported_clusters.md`. If mandatory clusters are missing, the type is **Blocked** — the blocker is a cluster PR, not a device-type PR. ### Step 1 — Plan the PR series Split, in this order, each merging independently and compiling cleanly: - **PR 1 — base device type + minimal simulated impl:** generic `types/<t>/<T>.{h,cpp}` with mandatory clusters only, a minimal compilable `impl/<Simulated|Logging|Flavor><T>.{h,cpp}` satisfying mandatory cluster `Delegate &` references, `BUILD.gn`, `DeviceFactory` + all build wiring, unit tests for `<T>`, and docs rows. No OOB/named pipes or complex multi-cluster state machines yet. - **PR 2 — realistic mandatory simulation:** full timer-driven / physically plausible mandatory cluster behavior in `impl/` (if non-trivial enough to separate from PR 1). - **PR 3 — optionality:** optional clusters enabled through `RegisterOptionalClusters()` in impl (or template parameters like `AirQualitySensor`), with optional attributes/feature bits turned on. - **PR 4 — cross-cluster wiring ("if-this-then-that")** between clusters (modes drive opstate, opstate drives progress reporting) and state machines. - **PR 5 — OOB / named pipes + CI:** `<Thing>Accessor`, `OOBAccessors`, `NamedPipeTranslators`, yaml TC suites enabled, and/or `python_testing` `TC_*.py` files extended. Rationale: single-purpose PRs over a pre-agreed layout review fast; PRs that introduce a new layout or placement decision inside a large mixed diff get that design re-litigated mid-review and every fix serializes behind it. Device-type PRs that mixed all of these together took weeks; a minimal base + impl lands in days and later PRs reuse its settled vocabulary. Each follow-up PR must build alone — a stray hunk referencing an API added in the _next_ split PR breaks the intermediate builds. **Names come first:** if you are unsure about any file/class naming, raise that naming as the _first_ PR (or at least the first comment) — class/location names are the last thing to settle and force broad renames. ### Step 2 — Write the generic device type Start from the newest clean exemplar and _read that file_ rather than a code template pasted here (inline code in skills drifts out of sync with the tree): `all-devices-common/device/types/window-covering/WindowCovering.{h,cpp}`. The invariants to reproduce, all visible there: - Inherits `SingleEndpoint`; members for each mandatory cluster are `LazyRegisteredServerCluster<T>`. - Dependencies packed in a `Context` struct (references only), cluster delegates in a `Delegates` struct (references only), and spec-gated knobs in a `Config` struct (`BitFlags<Feature>`, `OptionalAttributeSet`, any spec enum defaults). - Constructor takes the three structs; `Register()`/`Unregister()` override the `DeviceInterface` lifecycle hooks with the exact ordering below; public getters return live cluster references for programmatic/OOB control; optional clusters enter through the protected `RegisterOptionalClusters()` / `UnregisterOptionalClusters()` hooks. Key lifecycle invariants in `Register()` and `Unregister()`: - **`Register(endpoint, provider, composition)`**: 1. `VerifyOrReturnError(mEndpointId == kInvalidEndpointId, CHIP_ERROR_INCORRECT_STATE);` 2. `DeviceRegistrationTransaction transaction(*this, provider);` 3. `ReturnErrorOnFailure(RegisterDescriptor(endpoint, provider, composition));` **first**. 4. `.Create(...)` and `ReturnErrorOnFailure(provider.AddCluster(...));` for each mandatory cluster. 5. `ReturnErrorOnFailure(RegisterOptionalClusters(endpoint, provider));`
View on GitHub
This SKILL.md is very large, so SkillsMP previews the first section here. View on GitHub