rs_matter/dm/clusters/identify.rs
1/*
2 *
3 * Copyright (c) 2026 Project CHIP Authors
4 *
5 * Licensed under the Apache License, Version 2.0 (the "License");
6 * you may not use this file except in compliance with the License.
7 * You may obtain a copy of the License at
8 *
9 * http://www.apache.org/licenses/LICENSE-2.0
10 *
11 * Unless required by applicable law or agreed to in writing, software
12 * distributed under the License is distributed on an "AS IS" BASIS,
13 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
14 * See the License for the specific language governing permissions and
15 * limitations under the License.
16 */
17
18//! Identify cluster handler (Matter Application Cluster spec).
19//!
20//! The Identify cluster lets a controller put an endpoint into an
21//! identification state — typically blinking an LED, beeping, or playing a
22//! lighting effect — so a human can pick out which physical device they
23//! just commissioned among several visually-identical ones. It is a
24//! mandatory cluster on most application device types (e.g. On/Off Light,
25//! Dimmable Light, Color Temperature Light), see Matter Device Library
26//! for the On/Off Light requirements.
27//!
28//! [`IdentifyHandler`] is generic over an [`IdentifyHooks`] hardware-hook
29//! trait. The library handler owns all the boring bookkeeping —
30//! `IdentifyTime` storage, the deadline-driven countdown, attribute-change
31//! notifications — and dispatches a single sync [`IdentifyAction`]
32//! callback to the application whenever the identification state
33//! transitions. Applications with real hardware just implement
34//! [`IdentifyHooks::identify`] (and optionally
35//! [`IdentifyHooks::identify_type`]) and let the library handle the rest;
36//! applications with no hardware can use the default `()` impl and ship
37//! `IdentifyHandler::new(dataver)` without further ceremony.
38
39use core::cell::Cell;
40use core::pin::pin;
41
42use embassy_futures::select::{select, Either};
43use embassy_time::{Duration, Instant, Timer};
44
45use crate::dm::types::EndptId;
46use crate::dm::{Cluster, Dataver, HandlerContext, InvokeContext, ReadContext, WriteContext};
47use crate::error::Error;
48use crate::utils::sync::blocking::Mutex;
49use crate::utils::sync::Notification;
50use crate::with;
51
52pub use crate::dm::clusters::decl::identify::*;
53
54/// Cluster metadata exposed by [`IdentifyHandler`] regardless of hooks.
55///
56/// Equivalent to `<IdentifyHandler<H> as ClusterHandler>::CLUSTER` for
57/// any `H: IdentifyHooks`, exposed here as a free constant so callers
58/// don't have to spell out the generic parameter when they just want the
59/// cluster ID for an `EpClMatcher` or a `clusters!(...)` literal.
60pub const CLUSTER: Cluster<'static> = FULL_CLUSTER.with_attrs(with!(required));
61
62/// The kind of identification action requested by a controller, dispatched
63/// by [`IdentifyHandler`] to the application's [`IdentifyHooks::identify`]
64/// method. Hooks see exactly one of these per state transition.
65#[derive(Copy, Clone, Debug, Eq, PartialEq, Hash)]
66#[cfg_attr(feature = "defmt", derive(crate::reexport::defmt::Format))]
67pub enum IdentifyAction {
68 /// `IdentifyTime` write (or `Identify` command) with a non-zero
69 /// duration: start (or re-arm) identifying for the supplied seconds.
70 /// The handler tracks the deadline and will dispatch a follow-up
71 /// [`IdentifyAction::Cancel`] action when the timer expires.
72 Time(u16),
73 /// `TriggerEffect` command: trigger a named effect with the supplied
74 /// variant. The handler does not track effect duration — applications
75 /// own the effect lifecycle from this point. The `StopEffect` /
76 /// `FinishEffect` effect identifiers arrive here as
77 /// [`IdentifyAction::Effect`] just like every other effect, so the
78 /// application can dispatch them to the appropriate hardware sequence.
79 Effect(EffectIdentifierEnum, EffectVariantEnum),
80 /// Stop any in-progress identification. Dispatched on:
81 /// - `IdentifyTime` write (or `Identify` command) with value `0`,
82 /// - the countdown deadline being reached,
83 ///
84 /// **not** dispatched on a re-arm: the application instead receives a
85 /// fresh [`IdentifyAction::Time`] with the new duration and can decide
86 /// whether to keep its current visual pattern running or restart.
87 Cancel,
88}
89
90/// Application-level hooks for the Identify cluster.
91///
92/// Implementations override this trait to drive their hardware (LED,
93/// buzzer, display, …) in response to identify requests from a Matter
94/// controller. The default `()` implementation is a no-op and is suitable
95/// for headless test fixtures or applications that observe identification
96/// state via attribute subscriptions instead.
97pub trait IdentifyHooks {
98 /// Return the kind of identification mechanism this endpoint provides.
99 /// Reported as the `IdentifyType` attribute (per App Cluster spec).
100 fn identify_type(&self) -> IdentifyTypeEnum;
101
102 /// Drive the application's identify hardware in response to a state
103 /// transition.
104 fn identify(&self, action: IdentifyAction);
105}
106
107impl<T> IdentifyHooks for &T
108where
109 T: IdentifyHooks,
110{
111 fn identify_type(&self) -> IdentifyTypeEnum {
112 (*self).identify_type()
113 }
114
115 fn identify(&self, action: IdentifyAction) {
116 (*self).identify(action)
117 }
118}
119
120impl IdentifyHooks for () {
121 fn identify_type(&self) -> IdentifyTypeEnum {
122 IdentifyTypeEnum::None
123 }
124
125 fn identify(&self, action: IdentifyAction) {
126 let _ = action;
127 }
128}
129
130/// Read-only view over an endpoint's identification state, for sibling
131/// clusters whose behavior depends on it — notably the Groups cluster,
132/// whose `AddGroupIfIdentifying` command only takes effect while the
133/// endpoint is identifying (App Cluster spec).
134///
135/// Implemented by [`IdentifyHandler`]; couple it to the sibling handler
136/// serving the *same* endpoint (identification state is per-endpoint).
137pub trait IdentifyStatus {
138 /// Whether the endpoint is currently identifying (`IdentifyTime > 0`).
139 fn is_identifying(&self) -> bool;
140}
141
142impl<T> IdentifyStatus for &T
143where
144 T: IdentifyStatus + ?Sized,
145{
146 fn is_identifying(&self) -> bool {
147 (*self).is_identifying()
148 }
149}
150
151/// The captured identify session. Reads of `IdentifyTime` compute
152/// `duration.saturating_sub(elapsed)` from this on-demand; the run task
153/// uses `endpoint_id` to target its `notify_attr_changed` at the right
154/// path when the deadline expires.
155#[derive(Copy, Clone, Debug, Eq, PartialEq)]
156#[cfg_attr(feature = "defmt", derive(crate::reexport::defmt::Format))]
157struct Session {
158 /// The endpoint that requested the identify — captured at write/invoke
159 /// time from the surrounding `OperationContext`, since the
160 /// `HandlerContext` available in the run task does not carry it.
161 endpoint_id: EndptId,
162 /// The originally-requested duration in seconds. Together with `start`
163 /// drives the on-demand "remaining seconds" computation.
164 duration: u16,
165 /// The `Instant` at which the request landed.
166 start: Instant,
167}
168
169/// The handler for the Identify Matter cluster.
170///
171/// Per-endpoint instance: each endpoint that advertises the Identify
172/// cluster must own a separate `IdentifyHandler` so countdown state and
173/// attribute reports stay segregated. The endpoint ID is captured *lazily*
174/// from the first write/command that lands on this instance — no
175/// constructor argument required, and the same handler type can therefore
176/// serve any endpoint without per-endpoint specialization.
177///
178/// State model: rather than physically decrement an `IdentifyTime` counter
179/// at 1 Hz, the handler captures the originally-requested
180/// `(endpoint_id, duration, start_instant)` when an `Identify` command (or
181/// `IdentifyTime` write) arrives, and computes the *remaining* seconds
182/// on-demand whenever the framework reads the attribute. This is
183/// observably equivalent to a physical countdown per Matter App Cluster
184/// spec (which constrains the attribute's observable value, not
185/// the implementation's internal storage), but it avoids 60 wakeups for
186/// a 60-second identify — the [run task](Handler::run) only schedules a
187/// single `Timer::at(deadline)` per identify cycle, fires the final-zero
188/// `notify_attr_changed`, and parks. On battery-powered targets that's the
189/// difference between a measurable wake-and-radio-on hit per second and a
190/// non-event.
191///
192/// Concurrency: the session cell is wrapped in a blocking [`Mutex`] so the
193/// handler is sound under a future work-stealing executor configuration of
194/// rs-matter. Lock holds are bounded to a single `Cell::get` / `Cell::set`
195/// each — the lock is *never* held across `.await` points.
196///
197/// Dataver is bumped automatically by the framework after each write/invoke
198/// (via the cluster handler chain's `bump_dataver(MatchContext)`), and
199/// again whenever a `notify_attr_changed` is dispatched. This handler
200/// therefore never calls `self.dataver.changed()` directly — it only
201/// signals attribute-changed notifications, and the framework takes care
202/// of dataver progression.
203///
204/// The handler implements the (sync) `ClusterHandler` trait — which keeps
205/// the read/write/invoke surface as cheap sync calls (smaller footprint
206/// than `ClusterAsyncHandler`'s all-async state machines) — and provides
207/// the deadline-timer task via `ClusterHandler::run`. Adapt it to the
208/// generic `rs-matter` `Handler` trait with [`IdentifyHandler::adapt`].
209pub struct IdentifyHandler<H = ()> {
210 dataver: Dataver,
211 /// `Some(session)` while identifying, `None` when idle. Reads of
212 /// `IdentifyTime` compute `duration.saturating_sub(elapsed)` from the
213 /// captured `(endpoint_id, duration, start_instant)` on-demand.
214 session: Mutex<Cell<Option<Session>>>,
215 /// Wakes the run loop on writes/commands so it can re-arm its
216 /// `Timer::at(deadline)` against the new session, instead of letting
217 /// the previous deadline complete with stale parameters.
218 state_change: Notification,
219 hooks: H,
220}
221
222impl IdentifyHandler<()> {
223 /// Creates a new `IdentifyHandler` with the no-hardware default
224 /// `()` hooks. Suitable for headless test fixtures.
225 ///
226 /// The endpoint ID is *not* a constructor argument: it is captured at
227 /// the first write/command from the surrounding `OperationContext`.
228 pub const fn new(dataver: Dataver) -> Self {
229 Self::new_with(dataver, ())
230 }
231}
232
233impl<H> IdentifyHandler<H>
234where
235 H: IdentifyHooks,
236{
237 /// Creates a new `IdentifyHandler` with application-supplied hooks.
238 pub const fn new_with(dataver: Dataver, hooks: H) -> Self {
239 Self {
240 dataver,
241 session: Mutex::new(Cell::new(None)),
242 state_change: Notification::new(),
243 hooks,
244 }
245 }
246
247 /// Compute the current `IdentifyTime` value from the captured session,
248 /// saturating at zero if the deadline has already passed (the
249 /// background `run` task may not yet have observed expiry).
250 fn remaining(&self) -> u16 {
251 let Some(Session {
252 duration, start, ..
253 }) = self.session.lock(|cell| cell.get())
254 else {
255 return 0;
256 };
257 let elapsed = start.elapsed().as_secs();
258 // `duration as u64` is in [0, u16::MAX]; `saturating_sub` returns a
259 // value in [0, lhs]; therefore the result is in [0, u16::MAX] and
260 // the narrowing cast back to `u16` is lossless.
261 (duration as u64).saturating_sub(elapsed) as u16
262 }
263
264 /// Adapt the handler instance to the generic `rs-matter` `Handler` trait.
265 pub const fn adapt(self) -> HandlerAdaptor<Self> {
266 HandlerAdaptor(self)
267 }
268
269 /// Capture a new identify session (or clear the existing one),
270 /// dispatch the matching [`IdentifyAction`] to the hooks, and wake the
271 /// run loop so it can re-arm its deadline timer. Caller is responsible
272 /// for the subsequent attribute-changed notification — the
273 /// WriteContext path uses `ctx.notify_changed()` and the InvokeContext
274 /// path uses `ctx.notify_own_attr_changed(...)`.
275 fn set_identify_time_internal(&self, endpoint_id: EndptId, value: u16) {
276 self.session.lock(|cell| {
277 if value == 0 {
278 cell.set(None);
279 } else {
280 cell.set(Some(Session {
281 endpoint_id,
282 duration: value,
283 start: Instant::now(),
284 }));
285 }
286 });
287 self.state_change.notify();
288 self.hooks.identify(if value == 0 {
289 IdentifyAction::Cancel
290 } else {
291 IdentifyAction::Time(value)
292 });
293 }
294}
295
296impl<H> IdentifyStatus for IdentifyHandler<H>
297where
298 H: IdentifyHooks,
299{
300 fn is_identifying(&self) -> bool {
301 self.remaining() > 0
302 }
303}
304
305impl<H> ClusterHandler for IdentifyHandler<H>
306where
307 H: IdentifyHooks,
308{
309 // No optional features supported; default cluster metadata covers the
310 // mandatory `IdentifyTime` / `IdentifyType` attributes and the
311 // mandatory `Identify` / `TriggerEffect` commands. Same as the
312 // module-level `identify::CLUSTER` constant — the duplication exists
313 // because trait-impl associated consts can't reference free items
314 // by short path inside the impl body without trips through name
315 // resolution that depend on glob re-export ordering.
316 const CLUSTER: Cluster<'static> = FULL_CLUSTER.with_attrs(with!(required));
317
318 fn dataver(&self) -> u32 {
319 self.dataver.get()
320 }
321
322 fn dataver_changed(&self) {
323 self.dataver.changed();
324 }
325
326 fn identify_time(&self, _ctx: impl ReadContext) -> Result<u16, Error> {
327 Ok(self.remaining())
328 }
329
330 fn identify_type(&self, _ctx: impl ReadContext) -> Result<IdentifyTypeEnum, Error> {
331 Ok(self.hooks.identify_type())
332 }
333
334 fn set_identify_time(&self, ctx: impl WriteContext, value: u16) -> Result<(), Error> {
335 self.set_identify_time_internal(ctx.attr().endpoint_id, value);
336 // The shortcut for "the attribute the framework just wrote": fans
337 // out a `notify_attr_changed` for `IdentifyTime`, which
338 // simultaneously triggers the `bump_dataver(MatchContext)` chain
339 // and notifies any subscribers.
340 ctx.notify_changed();
341 Ok(())
342 }
343
344 fn handle_identify(
345 &self,
346 ctx: impl InvokeContext,
347 request: IdentifyRequest<'_>,
348 ) -> Result<(), Error> {
349 let time = request.identify_time()?;
350 self.set_identify_time_internal(ctx.cmd().endpoint_id, time);
351 // The Identify command mutates an attribute that lives on the
352 // *same* cluster as the command (App Cluster), so use the
353 // `OwnAttrChangeNotifier` shortcut to notify against
354 // `(ctx.cmd().endpoint_id, ctx.cmd().cluster_id, IdentifyTime)`.
355 ctx.notify_own_attr_changed(AttributeId::IdentifyTime as _);
356 Ok(())
357 }
358
359 fn handle_trigger_effect(
360 &self,
361 _ctx: impl InvokeContext,
362 request: TriggerEffectRequest<'_>,
363 ) -> Result<(), Error> {
364 let effect = request.effect_identifier()?;
365 let variant = request.effect_variant()?;
366 self.hooks.identify(IdentifyAction::Effect(effect, variant));
367 Ok(())
368 }
369
370 async fn run(&self, ctx: impl HandlerContext) -> Result<(), Error> {
371 loop {
372 let Some(Session {
373 endpoint_id,
374 duration,
375 start,
376 }) = self.session.lock(|cell| cell.get())
377 else {
378 // Idle: wait for a write/command to start a session.
379 self.state_change.wait().await;
380 continue;
381 };
382
383 // Compute the absolute deadline once and race a single timer
384 // against the wakeup signal — no per-second polling, and no
385 // accumulating sub-second drift across multiple ticks.
386 let deadline = start + Duration::from_secs(duration as u64);
387
388 match select(Timer::at(deadline), pin!(self.state_change.wait())).await {
389 Either::First(_) => {
390 // Deadline reached: clear the session so subsequent
391 // reads of `IdentifyTime` return 0, dispatch the
392 // application-visible `Cancel` action, and notify
393 // subscribers of the final-zero transition (Q-quality
394 // reportable per App Cluster spec). The
395 // framework auto-bumps the cluster dataver through
396 // the `bump_dataver` chain as a side effect.
397 self.session.lock(|cell| cell.set(None));
398 self.hooks.identify(IdentifyAction::Cancel);
399 ctx.notify_attr_changed(
400 endpoint_id,
401 <Self as ClusterHandler>::CLUSTER.id,
402 AttributeId::IdentifyTime as _,
403 );
404 }
405 Either::Second(_) => {
406 // Re-armed via write/command (or cancelled with `0`);
407 // loop and re-read the session.
408 }
409 }
410 }
411 }
412}