Skip to main content

rs_matter/dm/clusters/
scenes.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//! Scenes Management cluster handler.
19//!
20//! A scene is a named snapshot of a chosen subset of cluster
21//! attributes on one endpoint, recallable on demand. Scene capture
22//! and apply talk to scene-aware clusters via the
23//! [`SceneClusterHandler`] trait, which the cluster's normal handler
24//! type implements directly — `&on_off_handler` doubles as both a
25//! data-model chain entry and a scenes-registry entry.
26//!
27//! [`ScenesState`] holds the per-device scene table and per-fabric
28//! `CurrentScene` bookkeeping; the table is persisted as a single
29//! TLV blob under [`SCENES_KEY`] on every successful mutation, and
30//! re-hydrated on startup via [`ScenesState::load_persist`].
31//!
32//! The `SceneNames` feature is not supported — scene names sent on
33//! the wire are accepted and discarded.
34
35use core::future::{ready, Future};
36use core::num::NonZeroU8;
37
38use crate::dm::{
39    ArrayAttributeRead, AttrId, Cluster, ClusterId, Dataver, EndptId, HandlerContext,
40    InvokeContext, ReadContext, SceneId,
41};
42use crate::error::{Error, ErrorCode};
43use crate::persist::{KvBlobStore, Persist};
44use crate::tlv::{
45    FromTLV, Nullable, OptionalBuilder, TLVArray, TLVBuilder, TLVBuilderParent, TLVElement,
46    TLVSequence, TLVTag, TLVWrite, TLVWriteParent, ToTLV, TLV,
47};
48use crate::utils::cell::RefCell;
49use crate::utils::init::{init, Init};
50use crate::utils::storage::{Vec, WriteBuf};
51use crate::utils::sync::blocking::Mutex;
52
53pub use crate::dm::clusters::decl::scenes_management::*;
54pub use crate::persist::SCENES_KEY;
55
56// IM status codes used by Scenes Management response structs and
57// command-level `Err(...)` returns.
58const SC_NOT_FOUND: u8 = 0x8B;
59const SC_INSUFFICIENT_SPACE: u8 = 0x89;
60const SC_INVALID_COMMAND: u8 = 0x85;
61const SC_CONSTRAINT_ERROR: u8 = 0x87;
62
63/// Reserved (invalid) `SceneID` value per Matter Core Spec.
64const RESERVED_SCENE_ID: SceneId = 0xFF;
65
66/// `SceneID` 0 is reserved for the Global Scene; never valid in
67/// add/view/remove/store/recall/copy.
68const GLOBAL_SCENE_ID: SceneId = 0;
69
70/// Maximum legal `AddScene.TransitionTime` in milliseconds
71/// (60 000 seconds / 1000 minutes per Matter Core Spec).
72const MAX_TRANSITION_TIME_MS: u32 = 60_000_000;
73
74/// Default max length of the serialized `ExtensionFieldSetStructs`
75/// payload on a single scene record. ColorControl scenes are the
76/// largest realistic case at ~100 B; OnOff + LevelControl scenes are
77/// ~16 B. Bumpable via the `M` const generic on [`ScenesState`] /
78/// [`ScenesHandler`]; total RAM cost is `N * M`.
79pub const MAX_EXT_FIELDS_LEN: usize = 128;
80
81/// Per-cluster scene capture + apply trait. Implemented directly on
82/// the cluster's handler type (e.g. `OnOffHandler`) so the same
83/// `&handler` the application registers in the data-model chain can
84/// also be registered in the scenes registry — no separate wrapper,
85/// no IM round-trip, no TLV serde.
86///
87/// Back-direction (a scenable attribute mutated, so `SceneValid` may
88/// need to flip) goes through [`SceneInvalidator`], implemented by
89/// [`ScenesState`].
90pub trait SceneClusterHandler {
91    /// The Matter cluster ID this impl handles.
92    const CLUSTER_ID: ClusterId;
93
94    /// Endpoint this handler instance is installed on. Used to skip
95    /// clusters not on the `StoreScene` / `RecallScene` target endpoint.
96    fn endpoint_id(&self) -> EndptId;
97
98    /// True if `attribute_id` is a scenable attribute of this cluster
99    /// per the Matter Core Spec. `AddScene` rejects EFS payloads that
100    /// reference non-scenable attributes.
101    fn is_scenable_attribute(_attribute_id: AttrId) -> bool {
102        false
103    }
104
105    /// Emit AVP entries for this cluster's scenable state into
106    /// `avp_array`. Use [`AttributeValuePairStructArrayBuilder::push_u8`]
107    /// / [`AttributeValuePairStructArrayBuilder::push_u16`] for a
108    /// one-line per-attribute API.
109    fn capture<P: TLVBuilderParent>(
110        &self,
111        avp_array: AttributeValuePairStructArrayBuilder<P>,
112    ) -> Result<AttributeValuePairStructArrayBuilder<P>, Error>;
113
114    /// Apply captured AVPs to the cluster's internal state. Async
115    /// because some clusters (LevelControl) kick off transition
116    /// tasks; sync-only impls can return [`core::future::ready`].
117    ///
118    /// # Arguments
119    /// - `ctx` — [`HandlerContext`] for subscriber notification
120    ///   ([`crate::dm::AttrChangeNotifier::notify_attr_changed`]) and
121    ///   persistence ([`HandlerContext::kv`]). Impls MUST NOT call
122    ///   `ctx.handler()` from inside `apply` — recursion-limit
123    ///   pathology, by design.
124    /// - `avp_list` — the captured scenable AVPs from `AddScene` /
125    ///   `StoreScene`.
126    /// - `transition_time_ms` — effective transition time
127    ///   (`RecallScene` request override, falling back to the stored
128    ///   per-scene value).
129    fn apply<C: HandlerContext>(
130        &self,
131        ctx: &C,
132        avp_list: &TLVArray<'_, AttributeValuePairStruct<'_>>,
133        transition_time_ms: u32,
134    ) -> impl Future<Output = Result<(), Error>>;
135}
136
137/// Lets the application pass `&handler` into the scenes registry
138/// without moving it (the same `&handler` is also kept in the
139/// data-model chain). Delegates every method through the reference.
140impl<T: SceneClusterHandler + ?Sized> SceneClusterHandler for &T {
141    const CLUSTER_ID: ClusterId = T::CLUSTER_ID;
142
143    fn endpoint_id(&self) -> EndptId {
144        T::endpoint_id(*self)
145    }
146
147    fn is_scenable_attribute(attribute_id: AttrId) -> bool {
148        T::is_scenable_attribute(attribute_id)
149    }
150
151    fn capture<P: TLVBuilderParent>(
152        &self,
153        avp_array: AttributeValuePairStructArrayBuilder<P>,
154    ) -> Result<AttributeValuePairStructArrayBuilder<P>, Error> {
155        T::capture(*self, avp_array)
156    }
157
158    async fn apply<C: HandlerContext>(
159        &self,
160        ctx: &C,
161        avp_list: &TLVArray<'_, AttributeValuePairStruct<'_>>,
162        transition_time_ms: u32,
163    ) -> Result<(), Error> {
164        T::apply(*self, ctx, avp_list, transition_time_ms).await
165    }
166}
167
168/// Tuple-recursive composition of [`SceneClusterHandler`]s. Mirrors
169/// [`crate::dm::ChainedHandler`]: terminated by `()`, one cluster
170/// registers as `(impl, ())`, multiple as `(a, (b, (c, ())))`.
171pub trait SceneClusters {
172    /// Emit one EFS struct per registered cluster whose
173    /// `endpoint_id()` matches `endpoint_id`. EFS structs are written
174    /// directly into the parent without an outer array wrapper; the
175    /// caller is responsible for the trailing array terminator.
176    fn capture<P: TLVBuilderParent>(&self, endpoint_id: EndptId, parent: P) -> Result<P, Error>;
177
178    /// `Some(true)` if `cluster_id` is registered and `attribute_id`
179    /// is scenable on it; `Some(false)` if registered but
180    /// non-scenable (`AddScene` returns `INVALID_COMMAND`); `None`
181    /// if `cluster_id` is not registered (lenient — store the bytes,
182    /// silently skip on recall; matches chip's firmware-downgrade
183    /// behaviour).
184    fn check_scenable(&self, cluster_id: ClusterId, attribute_id: AttrId) -> Option<bool>;
185
186    /// Find the registered cluster matching `(cluster_id, endpoint_id)`
187    /// and let it apply `avp_list`. Returns `Ok(true)` if handled,
188    /// `Ok(false)` if no registered cluster matches.
189    fn apply<C: HandlerContext>(
190        &self,
191        ctx: &C,
192        endpoint_id: EndptId,
193        cluster_id: ClusterId,
194        avp_list: &TLVArray<'_, AttributeValuePairStruct<'_>>,
195        transition_time_ms: u32,
196    ) -> impl Future<Output = Result<bool, Error>>;
197}
198
199impl SceneClusters for () {
200    fn capture<P: TLVBuilderParent>(&self, _endpoint_id: EndptId, parent: P) -> Result<P, Error> {
201        Ok(parent)
202    }
203
204    fn check_scenable(&self, _cluster_id: ClusterId, _attribute_id: AttrId) -> Option<bool> {
205        None
206    }
207
208    fn apply<C: HandlerContext>(
209        &self,
210        _ctx: &C,
211        _endpoint_id: EndptId,
212        _cluster_id: ClusterId,
213        _avp_list: &TLVArray<'_, AttributeValuePairStruct<'_>>,
214        _transition_time_ms: u32,
215    ) -> impl Future<Output = Result<bool, Error>> {
216        ready(Ok(false))
217    }
218}
219
220impl<H, T> SceneClusters for (H, T)
221where
222    H: SceneClusterHandler,
223    T: SceneClusters,
224{
225    fn check_scenable(&self, cluster_id: ClusterId, attribute_id: AttrId) -> Option<bool> {
226        if cluster_id == H::CLUSTER_ID {
227            Some(H::is_scenable_attribute(attribute_id))
228        } else {
229            self.1.check_scenable(cluster_id, attribute_id)
230        }
231    }
232
233    fn capture<P: TLVBuilderParent>(&self, endpoint_id: EndptId, parent: P) -> Result<P, Error> {
234        let parent = if self.0.endpoint_id() == endpoint_id {
235            let efs = ExtensionFieldSetStructBuilder::new(parent, &TLVTag::Anonymous)?;
236            let efs = efs.cluster_id(H::CLUSTER_ID)?;
237            let avp_array = efs.attribute_value_list()?;
238            let avp_array = self.0.capture(avp_array)?;
239            let efs = avp_array.end()?;
240            efs.end()?
241        } else {
242            parent
243        };
244        self.1.capture(endpoint_id, parent)
245    }
246
247    async fn apply<C: HandlerContext>(
248        &self,
249        ctx: &C,
250        endpoint_id: EndptId,
251        cluster_id: ClusterId,
252        avp_list: &TLVArray<'_, AttributeValuePairStruct<'_>>,
253        transition_time_ms: u32,
254    ) -> Result<bool, Error> {
255        if H::CLUSTER_ID == cluster_id && self.0.endpoint_id() == endpoint_id {
256            self.0.apply(ctx, avp_list, transition_time_ms).await?;
257            Ok(true)
258        } else {
259            self.1
260                .apply(ctx, endpoint_id, cluster_id, avp_list, transition_time_ms)
261                .await
262        }
263    }
264}
265
266/// Ergonomics shims on the codegen'd AVP array builder so `capture`
267/// impls can write `avp_array.push_u8(attr_id, v)?` instead of
268/// spelling out the codegen builder's 9-state push chain.
269impl<P> AttributeValuePairStructArrayBuilder<P>
270where
271    P: TLVBuilderParent,
272{
273    /// Push one AVP element with a `valueUnsigned8` value.
274    pub fn push_u8(self, attr_id: AttrId, value: u8) -> Result<Self, Error> {
275        self.push()?
276            .attribute_id(attr_id)?
277            .value_unsigned_8(Some(value))?
278            .value_signed_8(None)?
279            .value_unsigned_16(None)?
280            .value_signed_16(None)?
281            .value_unsigned_32(None)?
282            .value_signed_32(None)?
283            .value_unsigned_64(None)?
284            .value_signed_64(None)?
285            .end()
286    }
287
288    /// Push one AVP element with a `valueUnsigned16` value.
289    pub fn push_u16(self, attr_id: AttrId, value: u16) -> Result<Self, Error> {
290        self.push()?
291            .attribute_id(attr_id)?
292            .value_unsigned_8(None)?
293            .value_signed_8(None)?
294            .value_unsigned_16(Some(value))?
295            .value_signed_16(None)?
296            .value_unsigned_32(None)?
297            .value_signed_32(None)?
298            .value_unsigned_64(None)?
299            .value_signed_64(None)?
300            .end()
301    }
302}
303
304/// One scene record. Holds the metadata (fabric / endpoint / group
305/// / scene / transition) plus the wire-form `ExtensionFieldSetStructs`
306/// blob captured on `AddScene` / `StoreScene` and replayed on
307/// `ViewScene` / `RecallScene` / `CopyScene`. `M` is the per-scene
308/// blob capacity — see [`MAX_EXT_FIELDS_LEN`].
309#[derive(Debug)]
310#[cfg_attr(feature = "defmt", derive(defmt::Format))]
311pub struct SceneEntry<const M: usize = MAX_EXT_FIELDS_LEN> {
312    fab_idx: NonZeroU8,
313    endpoint_id: EndptId,
314    group_id: u16,
315    scene_id: SceneId,
316    /// Transition time in milliseconds (1..=`MAX_TRANSITION_TIME_MS`).
317    transition_time: u32,
318    /// EFS array contents (between the array-control byte and the
319    /// terminator — what [`crate::tlv::TLVElement::raw_value`]
320    /// returns). Spliced back at the response tag by `ViewScene` /
321    /// `CopyScene`. Empty ⇒ no captured fields.
322    extension_fields: Vec<u8, M>,
323}
324
325impl<const M: usize> SceneEntry<M> {
326    fn matches(
327        &self,
328        fab_idx: NonZeroU8,
329        endpoint_id: EndptId,
330        group_id: u16,
331        scene_id: SceneId,
332    ) -> bool {
333        self.fab_idx == fab_idx
334            && self.endpoint_id == endpoint_id
335            && self.group_id == group_id
336            && self.scene_id == scene_id
337    }
338
339    /// In-place initializer that avoids the `M`-byte stack copy a
340    /// by-value `SceneEntry` would otherwise incur. The `extension_fields`
341    /// `Vec` is initialized empty; the caller fills it in place via
342    /// [`super::ScenesHandler::upsert_scene`]'s closure.
343    fn init(
344        fab_idx: NonZeroU8,
345        endpoint_id: EndptId,
346        group_id: u16,
347        scene_id: SceneId,
348        transition_time: u32,
349    ) -> impl Init<Self> {
350        init!(Self {
351            fab_idx,
352            endpoint_id,
353            group_id,
354            scene_id,
355            transition_time,
356            extension_fields <- Vec::init(),
357        })
358    }
359}
360
361/// Per-fabric "last recalled scene" pointer backing
362/// `FabricSceneInfo.CurrentScene` / `CurrentGroup` / `SceneValid`.
363///
364/// The slot persists once a fabric has interacted with scenes — so
365/// `FabricSceneInfo` keeps emitting a row for it even after the only
366/// scene is removed — and `valid` carries `SceneValid` directly.
367/// `endpoint_id` lets [`SceneInvalidator`] flip `valid → false`
368/// per-endpoint without touching other endpoints' recalled scenes.
369#[derive(Debug, Clone, Copy, FromTLV, ToTLV)]
370#[cfg_attr(feature = "defmt", derive(defmt::Format))]
371struct CurrentScene {
372    fab_idx: NonZeroU8,
373    endpoint_id: EndptId,
374    group_id: u16,
375    scene_id: SceneId,
376    valid: bool,
377}
378
379/// All mutable Scenes state, held behind a single mutex inside
380/// [`ScenesState`].
381struct ScenesStateInner<const N: usize, const M: usize = MAX_EXT_FIELDS_LEN> {
382    /// Scene table keyed by `(fab_idx, endpoint_id, group_id, scene_id)`.
383    table: Vec<SceneEntry<M>, N>,
384    /// One slot per fabric that has touched scenes.
385    current_per_fabric: Vec<CurrentScene, N>,
386    /// Bumped on every state mutation that affects `FabricSceneInfo`.
387    info_dataver: u32,
388}
389
390impl<const N: usize, const M: usize> ScenesStateInner<N, M> {
391    const fn new() -> Self {
392        Self {
393            table: Vec::new(),
394            current_per_fabric: Vec::new(),
395            info_dataver: 0,
396        }
397    }
398
399    fn init() -> impl Init<Self> {
400        init!(Self {
401            table <- Vec::init(),
402            current_per_fabric <- Vec::init(),
403            info_dataver: 0,
404        })
405    }
406
407    fn bump_info_dataver(&mut self) {
408        self.info_dataver = self.info_dataver.wrapping_add(1);
409    }
410}
411
412/// Caller-owned per-device Scenes state — the scene table plus
413/// per-fabric `CurrentScene` bookkeeping. Shared across all endpoints
414/// exposing the cluster.
415///
416/// Const generics:
417/// - `N` — total scene-table capacity (rows across all fabrics +
418///   endpoints).
419/// - `M` — per-scene EFS blob capacity in bytes. Bump it when
420///   wiring ColorControl into a multi-feature deployment whose
421///   captured EFS exceeds [`MAX_EXT_FIELDS_LEN`]. Total static RAM
422///   is roughly `N * (M + overhead)`.
423pub struct ScenesState<const N: usize, const M: usize = MAX_EXT_FIELDS_LEN> {
424    inner: Mutex<RefCell<ScenesStateInner<N, M>>>,
425}
426
427impl<const N: usize, const M: usize> ScenesState<N, M> {
428    pub const fn new() -> Self {
429        Self {
430            inner: Mutex::new(RefCell::new(ScenesStateInner::new())),
431        }
432    }
433
434    /// In-place initializer.
435    pub fn init() -> impl Init<Self> {
436        init!(Self {
437            inner <- Mutex::init(RefCell::init(ScenesStateInner::init())),
438        })
439    }
440
441    /// Take the lock and run `f` against the mutable inner state.
442    fn with<F, R>(&self, f: F) -> R
443    where
444        F: FnOnce(&mut ScenesStateInner<N, M>) -> R,
445    {
446        self.inner.lock(|cell| {
447            let mut inner = cell.borrow_mut();
448            f(&mut inner)
449        })
450    }
451}
452
453impl<const N: usize, const M: usize> Default for ScenesState<N, M> {
454    fn default() -> Self {
455        Self::new()
456    }
457}
458
459/// Notified by scene-aware cluster handlers when a scenable
460/// attribute on an endpoint changes outside a scene recall. Per
461/// Matter Core Spec, such a mutation invalidates `SceneValid` for
462/// every fabric whose recalled scene lives on that endpoint.
463///
464/// [`ScenesState`] implements this trait. Wire the impl into a
465/// scene-aware cluster handler via its `with_scene_invalidator`
466/// builder; the handler then calls
467/// [`Self::scenable_attribute_changed`] from every command-driven
468/// mutation site (scene-driven mutations skip the call so SceneValid
469/// stays true through the recall).
470///
471/// Implementations MUST be cheap and re-entrant — they run inline on
472/// the command-handler path.
473pub trait SceneInvalidator {
474    /// Flip `SceneValid → false` for every recalled scene on
475    /// `endpoint_id`, across all fabrics. No-op when no fabric has a
476    /// scene recalled there.
477    fn scenable_attribute_changed(&self, endpoint_id: EndptId);
478}
479
480impl<T: SceneInvalidator + ?Sized> SceneInvalidator for &T {
481    fn scenable_attribute_changed(&self, endpoint_id: EndptId) {
482        (**self).scenable_attribute_changed(endpoint_id);
483    }
484}
485
486impl<const N: usize, const M: usize> SceneInvalidator for ScenesState<N, M> {
487    fn scenable_attribute_changed(&self, endpoint_id: EndptId) {
488        self.with(|inner| {
489            let mut bumped = false;
490            for c in inner.current_per_fabric.iter_mut() {
491                if c.valid && c.endpoint_id == endpoint_id {
492                    c.valid = false;
493                    bumped = true;
494                }
495            }
496            if bumped {
497                inner.bump_info_dataver();
498            }
499        });
500    }
501}
502
503// TLV round-trip used by the persistence layer. The whole
504// `ScenesStateInner` is persisted as a single TLV struct under
505// `SCENES_KEY`. `info_dataver` is not persisted (the public `Dataver`
506// is re-randomized at boot anyway).
507//
508// Hand-rolled because the inner types are const-generic and the
509// derive macro doesn't yet support that. The on-disk shape is
510// private to this module and only needs to round-trip across
511// successive runs of the same firmware.
512
513impl<const M: usize> ToTLV for SceneEntry<M> {
514    fn to_tlv<W: TLVWrite>(&self, tag: &TLVTag, mut tw: W) -> Result<(), Error> {
515        tw.start_struct(tag)?;
516        self.fab_idx.to_tlv(&TLVTag::Context(0), &mut tw)?;
517        self.endpoint_id.to_tlv(&TLVTag::Context(1), &mut tw)?;
518        self.group_id.to_tlv(&TLVTag::Context(2), &mut tw)?;
519        self.scene_id.to_tlv(&TLVTag::Context(3), &mut tw)?;
520        self.transition_time.to_tlv(&TLVTag::Context(4), &mut tw)?;
521        // EFS bytes go on the wire as one octet string — not an
522        // array-of-u8 (which is what the blanket `Vec<u8, M>: ToTLV`
523        // would emit).
524        tw.str(&TLVTag::Context(5), &self.extension_fields)?;
525        tw.end_container()
526    }
527
528    fn tlv_iter(&self, _tag: TLVTag) -> impl Iterator<Item = Result<TLV<'_>, Error>> {
529        // Persistence goes through `to_tlv`; this is just here to
530        // satisfy the trait bound.
531        core::iter::empty()
532    }
533}
534
535impl<'a, const M: usize> FromTLV<'a> for SceneEntry<M> {
536    fn from_tlv(element: &TLVElement<'a>) -> Result<Self, Error> {
537        let s = element.structure()?;
538        let mut extension_fields = Vec::<u8, M>::new();
539        extension_fields
540            .extend_from_slice(s.ctx(5)?.str()?)
541            .map_err(|_| ErrorCode::NoSpace)?;
542        Ok(Self {
543            fab_idx: NonZeroU8::from_tlv(&s.ctx(0)?)?,
544            endpoint_id: EndptId::from_tlv(&s.ctx(1)?)?,
545            group_id: u16::from_tlv(&s.ctx(2)?)?,
546            scene_id: SceneId::from_tlv(&s.ctx(3)?)?,
547            transition_time: u32::from_tlv(&s.ctx(4)?)?,
548            extension_fields,
549        })
550    }
551}
552
553impl<const N: usize, const M: usize> ToTLV for ScenesStateInner<N, M> {
554    fn to_tlv<W: TLVWrite>(&self, tag: &TLVTag, mut tw: W) -> Result<(), Error> {
555        tw.start_struct(tag)?;
556        self.table.to_tlv(&TLVTag::Context(0), &mut tw)?;
557        self.current_per_fabric
558            .to_tlv(&TLVTag::Context(1), &mut tw)?;
559        tw.end_container()
560    }
561
562    fn tlv_iter(&self, _tag: TLVTag) -> impl Iterator<Item = Result<TLV<'_>, Error>> {
563        core::iter::empty()
564    }
565}
566
567impl<'a, const N: usize, const M: usize> FromTLV<'a> for ScenesStateInner<N, M> {
568    fn from_tlv(element: &TLVElement<'a>) -> Result<Self, Error> {
569        let s = element.structure()?;
570        Ok(Self {
571            table: Vec::<SceneEntry<M>, N>::from_tlv(&s.ctx(0)?)?,
572            current_per_fabric: Vec::<CurrentScene, N>::from_tlv(&s.ctx(1)?)?,
573            info_dataver: 0,
574        })
575    }
576}
577
578impl<const N: usize, const M: usize> ScenesState<N, M> {
579    /// Re-hydrate the scene table and per-fabric `CurrentScene`
580    /// bookkeeping from `store` under [`SCENES_KEY`]. Call once at
581    /// application startup, before exposing the data model to
582    /// commissioners. A missing key (first boot or cleared
583    /// persistence) leaves the registry empty.
584    pub async fn load_persist<S: KvBlobStore>(
585        &self,
586        mut store: S,
587        buf: &mut [u8],
588    ) -> Result<(), Error> {
589        let Some(data) = store.load(SCENES_KEY, buf)? else {
590            // Reset to empty so a `load_persist` after a key
591            // `remove` is deterministic.
592            self.with(|inner| {
593                inner.table.clear();
594                inner.current_per_fabric.clear();
595            });
596            return Ok(());
597        };
598
599        let loaded = ScenesStateInner::<N, M>::from_tlv(&TLVElement::new(data))?;
600        let entries = loaded.table.len();
601
602        self.with(|inner| {
603            inner.table = loaded.table;
604            inner.current_per_fabric = loaded.current_per_fabric;
605            inner.bump_info_dataver();
606        });
607
608        info!("Loaded Scenes state from storage ({} entries)", entries);
609
610        Ok(())
611    }
612
613    /// Persist the current state under [`SCENES_KEY`]. Called from
614    /// every mutating handler path after the in-memory change is
615    /// committed.
616    fn store_persist<C: HandlerContext>(&self, ctx: &C) -> Result<(), Error> {
617        let mut persist = Persist::new(ctx.kv());
618
619        self.inner.lock(|cell| {
620            let inner = cell.borrow();
621            persist.store_tlv(SCENES_KEY, &*inner)
622        })?;
623
624        persist.run()
625    }
626}
627
628/// Scenes Management cluster handler.
629///
630/// Generic over a tuple-recursive registry `R: SceneClusters` of the
631/// scene-aware cluster handlers that participate in scene capture /
632/// recall on this device:
633///
634/// ```ignore
635/// let scenes = ScenesHandler::new(
636///     dataver,
637///     &scenes_state,
638///     (&on_off_handler, (&level_control_handler, ())),
639/// );
640/// ```
641///
642/// The default `R = ()` builds a Scenes handler with no scene-aware
643/// clusters — useful for testing the table-management commands in
644/// isolation. `M` matches the same const generic on [`ScenesState`].
645pub struct ScenesHandler<'a, const N: usize, R = (), const M: usize = MAX_EXT_FIELDS_LEN>
646where
647    R: SceneClusters,
648{
649    dataver: Dataver,
650    state: &'a ScenesState<N, M>,
651    clusters: R,
652}
653
654impl<'a, const N: usize, R, const M: usize> ScenesHandler<'a, N, R, M>
655where
656    R: SceneClusters,
657{
658    pub const fn new(dataver: Dataver, state: &'a ScenesState<N, M>, clusters: R) -> Self {
659        Self {
660            dataver,
661            state,
662            clusters,
663        }
664    }
665
666    pub const fn adapt(self) -> HandlerAsyncAdaptor<Self> {
667        HandlerAsyncAdaptor(self)
668    }
669
670    fn fab_idx<C: InvokeContext>(ctx: &C) -> Result<NonZeroU8, Error> {
671        ctx.exchange().accessor()?.fab_idx()
672    }
673
674    /// Per-fabric `RemainingCapacity` for `GetSceneMembership` and
675    /// `FabricSceneInfo`. Formula matches chip's reference:
676    /// `(N - 1) / 2 - scenes_in_fab`, clamped by the total free
677    /// slots across all fabrics, then clamped to `u8`.
678    fn remaining_capacity_for_fab(inner: &ScenesStateInner<N, M>, fab_idx: NonZeroU8) -> u8 {
679        let per_fab_budget = N.saturating_sub(1) / 2;
680        let used = inner.table.iter().filter(|e| e.fab_idx == fab_idx).count();
681        let per_fab_remaining = per_fab_budget.saturating_sub(used);
682        let global_remaining = N.saturating_sub(inner.table.len());
683        per_fab_remaining.min(global_remaining).min(0xFF) as u8
684    }
685
686    /// `true` if `group_id` is present in the Groups cluster's Group
687    /// Table for `(fab_idx, endpoint_id)`. `group_id == 0` ("no
688    /// group") is always valid. Group-aware Scenes commands return
689    /// `INVALID_COMMAND` on `false`.
690    fn group_in_table<C: InvokeContext>(
691        ctx: &C,
692        fab_idx: NonZeroU8,
693        endpoint_id: EndptId,
694        group_id: u16,
695    ) -> Result<bool, Error> {
696        if group_id == 0 {
697            return Ok(true);
698        }
699        ctx.exchange().with_state(|state| {
700            let fabric = state.fabrics.fabric(fab_idx)?;
701            Ok(fabric
702                .groups()
703                .get(group_id)
704                .map(|g| g.endpoints.contains(&endpoint_id))
705                .unwrap_or(false))
706        })
707    }
708
709    /// Stamp `(endpoint, group, scene)` as the recalled scene for
710    /// `fab_idx` with `SceneValid = true`. Bumps `FabricSceneInfo`
711    /// dataver. Operates on already-locked inner state.
712    fn remember_current(
713        inner: &mut ScenesStateInner<N, M>,
714        fab_idx: NonZeroU8,
715        endpoint_id: EndptId,
716        group_id: u16,
717        scene_id: SceneId,
718    ) {
719        if let Some(slot) = inner
720            .current_per_fabric
721            .iter_mut()
722            .find(|c| c.fab_idx == fab_idx)
723        {
724            slot.endpoint_id = endpoint_id;
725            slot.group_id = group_id;
726            slot.scene_id = scene_id;
727            slot.valid = true;
728        } else {
729            // Best-effort push; if the slab is full we silently stop
730            // tracking CurrentScene for this fabric (the spec permits
731            // SceneValid=false in such cases).
732            let _ = inner.current_per_fabric.push(CurrentScene {
733                fab_idx,
734                endpoint_id,
735                group_id,
736                scene_id,
737                valid: true,
738            });
739        }
740        inner.bump_info_dataver();
741    }
742
743    /// Flip `SceneValid → false` for `fab_idx` only when the
744    /// recalled scene's `(group, scene)` matches the operation's
745    /// target — i.e. an `AddScene` / `StoreScene` / `RemoveScene` /
746    /// single-target `CopyScene` that actually touches the recalled
747    /// scene. Other-scene operations leave `SceneValid` alone, per
748    /// Matter Core Spec. Operates on already-locked inner state.
749    fn invalidate_current_if_match_scene(
750        inner: &mut ScenesStateInner<N, M>,
751        fab_idx: NonZeroU8,
752        group_id: u16,
753        scene_id: SceneId,
754    ) {
755        let mut bumped = false;
756        for c in inner.current_per_fabric.iter_mut() {
757            if c.valid && c.fab_idx == fab_idx && c.group_id == group_id && c.scene_id == scene_id {
758                c.valid = false;
759                bumped = true;
760            }
761        }
762        if bumped {
763            inner.bump_info_dataver();
764        }
765    }
766
767    /// Flip `SceneValid → false` for `fab_idx` when the recalled
768    /// scene's group matches the operation's group — used by
769    /// `RemoveAllScenes` and `COPY_ALL` `CopyScene`. The slot keeps
770    /// `CurrentScene` / `CurrentGroup` populated so the fabric stays
771    /// "known" in `FabricSceneInfo`.
772    fn invalidate_current_if_match_group(
773        inner: &mut ScenesStateInner<N, M>,
774        fab_idx: NonZeroU8,
775        group_id: u16,
776    ) {
777        let mut bumped = false;
778        for c in inner.current_per_fabric.iter_mut() {
779            if c.valid && c.fab_idx == fab_idx && c.group_id == group_id {
780                c.valid = false;
781                bumped = true;
782            }
783        }
784        if bumped {
785            inner.bump_info_dataver();
786        }
787    }
788
789    /// Body of `CopyScene` against an already-locked
790    /// [`ScenesStateInner`]. Returns the IM status code (0 on
791    /// success). In-place index walk: pushes destination rows go to
792    /// `group_to`, never match the `group_from` filter, so the loop
793    /// converges. Worst case is O(N²) on the inner `position` lookup,
794    /// which is fine for the small `N` this cluster carries.
795    #[allow(clippy::too_many_arguments)]
796    fn copy_scenes_inner(
797        inner: &mut ScenesStateInner<N, M>,
798        fab_idx: NonZeroU8,
799        endpoint_id: EndptId,
800        group_from: u16,
801        scene_from: SceneId,
802        group_to: u16,
803        scene_to: SceneId,
804        copy_all: bool,
805    ) -> u8 {
806        // Per-fab capacity gate up front: at-cap rejects the copy
807        // even when the destination already exists and would
808        // otherwise be a no-growth overwrite. Matches chip's
809        // reference.
810        if Self::remaining_capacity_for_fab(inner, fab_idx) == 0 {
811            return SC_INSUFFICIENT_SPACE;
812        }
813
814        let mut found_source = false;
815        let mut idx = 0;
816        while idx < inner.table.len() {
817            let src = &inner.table[idx];
818            let src_matches = src.fab_idx == fab_idx
819                && src.endpoint_id == endpoint_id
820                && src.group_id == group_from
821                && (copy_all || src.scene_id == scene_from);
822            if src_matches {
823                found_source = true;
824                // Clone the source row's scalars + EFS blob so the
825                // table can be re-borrowed mutably for the upsert.
826                let src_scene_id = src.scene_id;
827                let src_transition_time = src.transition_time;
828                let src_extension_fields = src.extension_fields.clone();
829                let target_scene_id = if copy_all { src_scene_id } else { scene_to };
830
831                // Upsert into (fab, ep, group_to, target_scene_id).
832                if let Some(pos) = inner
833                    .table
834                    .iter()
835                    .position(|e| e.matches(fab_idx, endpoint_id, group_to, target_scene_id))
836                {
837                    inner.table[pos].transition_time = src_transition_time;
838                    inner.table[pos].extension_fields = src_extension_fields;
839                } else {
840                    // Re-check per-fab capacity for each new push —
841                    // earlier pushes in this loop may have exhausted
842                    // the fabric's budget.
843                    if Self::remaining_capacity_for_fab(inner, fab_idx) == 0 {
844                        return SC_INSUFFICIENT_SPACE;
845                    }
846
847                    if inner
848                        .table
849                        .push(SceneEntry {
850                            fab_idx,
851                            endpoint_id,
852                            group_id: group_to,
853                            scene_id: target_scene_id,
854                            transition_time: src_transition_time,
855                            extension_fields: src_extension_fields,
856                        })
857                        .is_err()
858                    {
859                        return SC_INSUFFICIENT_SPACE;
860                    }
861                }
862
863                // Single-scene mode copies exactly one entry.
864                if !copy_all {
865                    break;
866                }
867            }
868            idx += 1;
869        }
870
871        if !found_source {
872            return SC_NOT_FOUND;
873        }
874
875        // Invalidate `CurrentScene` only if the copy actually
876        // touched the recalled scene.
877        if copy_all {
878            Self::invalidate_current_if_match_group(inner, fab_idx, group_to);
879        } else {
880            Self::invalidate_current_if_match_scene(inner, fab_idx, group_to, scene_to);
881        }
882        0
883    }
884
885    // Handler bodies. The `ClusterAsyncHandler` impl below wraps
886    // these in `fn -> impl Future { ready(self.foo(...)) }` to keep
887    // the real logic synchronous — saves the `async fn` state-machine
888    // codegen, matters on flash-constrained targets. `store_scene`
889    // is the exception (cross-cluster attribute reads need `.await`).
890
891    fn read_fabric_scene_info<P: TLVBuilderParent>(
892        &self,
893        ctx: &impl ReadContext,
894        builder: ArrayAttributeRead<SceneInfoStructArrayBuilder<P>, SceneInfoStructBuilder<P>>,
895    ) -> Result<P, Error> {
896        let endpoint_id = ctx.attr().endpoint_id;
897        let accessor_fab_idx = ctx.exchange().accessor()?.fab_idx()?;
898
899        // Snapshot the relevant scalars under a single lock, then
900        // build the response outside the lock. A fabric gets a row
901        // once it has at least one scene OR has ever recalled one
902        // (the `current_per_fabric` slot persists past invalidation
903        // so the row stays present after the last scene is removed).
904        let (has_state, scene_count, cur_group, cur_scene, valid, remaining) =
905            self.state.with(|inner| {
906                let count = inner
907                    .table
908                    .iter()
909                    .filter(|e| e.fab_idx == accessor_fab_idx && e.endpoint_id == endpoint_id)
910                    .count();
911                let current = inner
912                    .current_per_fabric
913                    .iter()
914                    .find(|c| c.fab_idx == accessor_fab_idx)
915                    .copied();
916                let has_state = count > 0 || current.is_some();
917                // `CurrentScene` / `CurrentGroup` are always
918                // populated when a row is emitted — 0 when the
919                // fabric has never recalled a scene.
920                let (g, s, v) = match current {
921                    Some(c) => (Some(c.group_id), Some(c.scene_id), c.valid),
922                    None => (Some(0u16), Some(0u8), false),
923                };
924                let rem = Self::remaining_capacity_for_fab(inner, accessor_fab_idx);
925                (has_state, count.min(0xFF) as u8, g, s, v, rem)
926            });
927
928        match builder {
929            ArrayAttributeRead::ReadAll(arr) => {
930                if !has_state {
931                    return arr.end();
932                }
933
934                let arr = arr
935                    .push()?
936                    .scene_count(scene_count)?
937                    .current_scene(cur_scene)?
938                    .current_group(cur_group)?
939                    .scene_valid(Some(valid))?
940                    .remaining_capacity(remaining)?
941                    .fabric_index(Some(accessor_fab_idx.get()))?
942                    .end()?;
943
944                arr.end()
945            }
946            ArrayAttributeRead::ReadNone(arr) => arr.end(),
947            ArrayAttributeRead::ReadOne(_idx, _entry) => {
948                // Indexed single-row reads aren't useful here (we only
949                // emit one row); reject as not found.
950                Err(ErrorCode::AttributeNotFound.into())
951            }
952        }
953    }
954
955    fn add_scene<P: TLVBuilderParent>(
956        &self,
957        ctx: &impl InvokeContext,
958        request: &AddSceneRequest<'_>,
959        response: AddSceneResponseBuilder<P>,
960    ) -> Result<P, Error> {
961        let fab_idx = Self::fab_idx(ctx)?;
962        let endpoint_id = ctx.cmd().endpoint_id;
963        let group_id = request.group_id()?;
964        let scene_id = request.scene_id()?;
965        let transition_time = request.transition_time()?;
966
967        // Bad request shape (reserved scene id or oversized
968        // transition) takes precedence over the group-table check.
969        if scene_id == GLOBAL_SCENE_ID
970            || scene_id == RESERVED_SCENE_ID
971            || transition_time > MAX_TRANSITION_TIME_MS
972        {
973            return response
974                .status(SC_CONSTRAINT_ERROR)?
975                .group_id(group_id)?
976                .scene_id(scene_id)?
977                .end();
978        }
979
980        if !Self::group_in_table(ctx, fab_idx, endpoint_id, group_id)? {
981            return response
982                .status(SC_INVALID_COMMAND)?
983                .group_id(group_id)?
984                .scene_id(scene_id)?
985                .end();
986        }
987
988        // EFS array payload — stored as the array's value bytes
989        // (between control byte and terminator) so `ViewScene` /
990        // `CopyScene` can splice it back at the response tag. A
991        // missing field is treated as empty. Scene names are
992        // accepted on the wire but not stored.
993        let efs_array_opt = request.extension_field_set_structs().ok();
994        let raw = match efs_array_opt {
995            Some(ref array) => array.element().raw_value()?,
996            None => &[],
997        };
998
999        // Every AVP referencing a registered cluster must be
1000        // scenable on that cluster — otherwise `INVALID_COMMAND`.
1001        // Unregistered clusters are lenient: store the bytes,
1002        // silently skip on recall (matches chip on firmware
1003        // downgrade).
1004        if let Some(ref efs_array) = efs_array_opt {
1005            for efs in efs_array.iter() {
1006                let efs = efs?;
1007                let cid = efs.cluster_id()?;
1008                for avp in efs.attribute_value_list()?.iter() {
1009                    let avp = avp?;
1010                    let aid = avp.attribute_id()?;
1011                    if let Some(false) = self.clusters.check_scenable(cid, aid) {
1012                        return response
1013                            .status(SC_INVALID_COMMAND)?
1014                            .group_id(group_id)?
1015                            .scene_id(scene_id)?
1016                            .end();
1017                    }
1018                }
1019            }
1020        }
1021
1022        // An oversized EFS payload is a per-scene capacity failure,
1023        // surfaced via `SC_INSUFFICIENT_SPACE` (not a transaction error).
1024        if raw.len() > M {
1025            return response
1026                .status(SC_INSUFFICIENT_SPACE)?
1027                .group_id(group_id)?
1028                .scene_id(scene_id)?
1029                .end();
1030        }
1031
1032        let status_code = self.state.with(|inner| {
1033            Self::upsert_scene(
1034                inner,
1035                fab_idx,
1036                endpoint_id,
1037                group_id,
1038                scene_id,
1039                transition_time,
1040                |ext_fields| {
1041                    if !raw.is_empty() {
1042                        ext_fields
1043                            .extend_from_slice(raw)
1044                            .map_err(|_| ErrorCode::NoSpace)?;
1045                    }
1046                    Ok(())
1047                },
1048            )
1049        })?;
1050
1051        if status_code == 0 {
1052            self.state.store_persist(ctx)?;
1053            ctx.notify_own_attr_changed(AttributeId::FabricSceneInfo as _);
1054        }
1055
1056        response
1057            .status(status_code)?
1058            .group_id(group_id)?
1059            .scene_id(scene_id)?
1060            .end()
1061    }
1062
1063    /// Insert (or replace) one scene record. `fill` populates the
1064    /// slot's `extension_fields` `Vec` directly (avoiding an
1065    /// intermediate stack copy of up to `M` bytes). Returns `Ok(0)`
1066    /// on success, `Ok(SC_INSUFFICIENT_SPACE)` when a *new* record
1067    /// would overflow `N`. Errors from `fill` propagate.
1068    ///
1069    /// On the replace-existing path the previous `extension_fields`
1070    /// are cleared before `fill` runs — a `fill` failure leaves the
1071    /// slot with an empty blob (acceptable; in-tree callers use
1072    /// `extend_from_slice` which is all-or-nothing).
1073    fn upsert_scene<F>(
1074        inner: &mut ScenesStateInner<N, M>,
1075        fab_idx: NonZeroU8,
1076        endpoint_id: EndptId,
1077        group_id: u16,
1078        scene_id: SceneId,
1079        transition_time: u32,
1080        fill: F,
1081    ) -> Result<u8, Error>
1082    where
1083        F: FnOnce(&mut Vec<u8, M>) -> Result<(), Error>,
1084    {
1085        if let Some(pos) = inner
1086            .table
1087            .iter()
1088            .position(|e| e.matches(fab_idx, endpoint_id, group_id, scene_id))
1089        {
1090            // Mutate the existing slot in place.
1091            inner.table[pos].transition_time = transition_time;
1092            inner.table[pos].extension_fields.clear();
1093            fill(&mut inner.table[pos].extension_fields)?;
1094            Self::invalidate_current_if_match_scene(inner, fab_idx, group_id, scene_id);
1095            Ok(0)
1096        } else if inner.table.len() >= N {
1097            Ok(SC_INSUFFICIENT_SPACE)
1098        } else {
1099            // `push_init_unchecked` only panics when full, and the
1100            // `else if` above just checked `len < N`.
1101            inner
1102                .table
1103                .push_init_unchecked(SceneEntry::init(
1104                    fab_idx,
1105                    endpoint_id,
1106                    group_id,
1107                    scene_id,
1108                    transition_time,
1109                ))
1110                .unwrap();
1111            let pos = inner.table.len() - 1;
1112            if let Err(e) = fill(&mut inner.table[pos].extension_fields) {
1113                let _ = inner.table.pop();
1114                return Err(e);
1115            }
1116            Self::invalidate_current_if_match_scene(inner, fab_idx, group_id, scene_id);
1117            Ok(0)
1118        }
1119    }
1120
1121    fn view_scene<P: TLVBuilderParent>(
1122        &self,
1123        ctx: &impl InvokeContext,
1124        request: &ViewSceneRequest<'_>,
1125        response: ViewSceneResponseBuilder<P>,
1126    ) -> Result<P, Error> {
1127        let fab_idx = Self::fab_idx(ctx)?;
1128        let endpoint_id = ctx.cmd().endpoint_id;
1129        let group_id = request.group_id()?;
1130        let scene_id = request.scene_id()?;
1131
1132        // `SceneID = 0x00` (Global Scene) and `0xFF` are reserved.
1133        if scene_id == GLOBAL_SCENE_ID || scene_id == RESERVED_SCENE_ID {
1134            return response
1135                .status(SC_CONSTRAINT_ERROR)?
1136                .group_id(group_id)?
1137                .scene_id(scene_id)?
1138                .transition_time(None)?
1139                .scene_name(None)?
1140                .extension_field_set_structs()?
1141                .none()
1142                .end();
1143        }
1144
1145        if !Self::group_in_table(ctx, fab_idx, endpoint_id, group_id)? {
1146            return response
1147                .status(SC_INVALID_COMMAND)?
1148                .group_id(group_id)?
1149                .scene_id(scene_id)?
1150                .transition_time(None)?
1151                .scene_name(None)?
1152                .extension_field_set_structs()?
1153                .none()
1154                .end();
1155        }
1156
1157        // Build the response inside the lock so the stored
1158        // `extension_fields` slice can be spliced without cloning.
1159        // The builder chain is sync; holding the mutex is fine.
1160        self.state.with(|inner| -> Result<P, Error> {
1161            let entry = inner
1162                .table
1163                .iter()
1164                .find(|e| e.matches(fab_idx, endpoint_id, group_id, scene_id));
1165
1166            let Some(e) = entry else {
1167                return response
1168                    .status(SC_NOT_FOUND)?
1169                    .group_id(group_id)?
1170                    .scene_id(scene_id)?
1171                    .transition_time(None)?
1172                    .scene_name(None)?
1173                    .extension_field_set_structs()?
1174                    .none()
1175                    .end();
1176            };
1177
1178            let opt = response
1179                .status(0)?
1180                .group_id(group_id)?
1181                .scene_id(scene_id)?
1182                .transition_time(Some(e.transition_time))?
1183                .scene_name(Some(""))?
1184                .extension_field_set_structs()?;
1185
1186            Self::write_blob_or_none(opt, &e.extension_fields)?.end()
1187        })
1188    }
1189
1190    /// Splice the stored EFS blob into the response at context tag 5
1191    /// (the `ExtensionFieldSetStructs` field). The blob is the array
1192    /// container's value bytes (contents + terminator). Empty blob
1193    /// ⇒ skip the field via `OptionalBuilder::none`.
1194    fn write_blob_or_none<P, Q>(mut opt: OptionalBuilder<P, Q>, blob: &[u8]) -> Result<P, Error>
1195    where
1196        P: TLVBuilderParent,
1197        Q: TLVBuilder<P>,
1198    {
1199        if !blob.is_empty() {
1200            let writer = opt.writer();
1201            writer.start_array(&TLVTag::Context(5))?;
1202            writer.write_raw_data(blob.iter().copied())?;
1203        }
1204        Ok(opt.none())
1205    }
1206
1207    fn remove_scene<P: TLVBuilderParent>(
1208        &self,
1209        ctx: &impl InvokeContext,
1210        request: &RemoveSceneRequest<'_>,
1211        response: RemoveSceneResponseBuilder<P>,
1212    ) -> Result<P, Error> {
1213        let fab_idx = Self::fab_idx(ctx)?;
1214        let endpoint_id = ctx.cmd().endpoint_id;
1215        let group_id = request.group_id()?;
1216        let scene_id = request.scene_id()?;
1217
1218        // `SceneID = 0x00` (Global Scene) and `0xFF` are reserved.
1219        if scene_id == GLOBAL_SCENE_ID || scene_id == RESERVED_SCENE_ID {
1220            return response
1221                .status(SC_CONSTRAINT_ERROR)?
1222                .group_id(group_id)?
1223                .scene_id(scene_id)?
1224                .end();
1225        }
1226
1227        if !Self::group_in_table(ctx, fab_idx, endpoint_id, group_id)? {
1228            return response
1229                .status(SC_INVALID_COMMAND)?
1230                .group_id(group_id)?
1231                .scene_id(scene_id)?
1232                .end();
1233        }
1234
1235        let status: u8 = self.state.with(|inner| {
1236            if let Some(pos) = inner
1237                .table
1238                .iter()
1239                .position(|e| e.matches(fab_idx, endpoint_id, group_id, scene_id))
1240            {
1241                inner.table.swap_remove(pos);
1242                Self::invalidate_current_if_match_scene(inner, fab_idx, group_id, scene_id);
1243                0
1244            } else {
1245                SC_NOT_FOUND
1246            }
1247        });
1248
1249        if status == 0 {
1250            self.state.store_persist(ctx)?;
1251            ctx.notify_own_attr_changed(AttributeId::FabricSceneInfo as _);
1252        }
1253
1254        response
1255            .status(status)?
1256            .group_id(group_id)?
1257            .scene_id(scene_id)?
1258            .end()
1259    }
1260
1261    fn remove_all_scenes<P: TLVBuilderParent>(
1262        &self,
1263        ctx: &impl InvokeContext,
1264        request: &RemoveAllScenesRequest<'_>,
1265        response: RemoveAllScenesResponseBuilder<P>,
1266    ) -> Result<P, Error> {
1267        let fab_idx = Self::fab_idx(ctx)?;
1268        let endpoint_id = ctx.cmd().endpoint_id;
1269        let group_id = request.group_id()?;
1270
1271        if !Self::group_in_table(ctx, fab_idx, endpoint_id, group_id)? {
1272            return response
1273                .status(SC_INVALID_COMMAND)?
1274                .group_id(group_id)?
1275                .end();
1276        }
1277
1278        let removed = self.state.with(|inner| {
1279            let before = inner.table.len();
1280            inner.table.retain(|e| {
1281                !(e.fab_idx == fab_idx && e.endpoint_id == endpoint_id && e.group_id == group_id)
1282            });
1283            let changed = before != inner.table.len();
1284            if changed {
1285                Self::invalidate_current_if_match_group(inner, fab_idx, group_id);
1286            }
1287            changed
1288        });
1289
1290        if removed {
1291            self.state.store_persist(ctx)?;
1292            ctx.notify_own_attr_changed(AttributeId::FabricSceneInfo as _);
1293        }
1294
1295        response.status(0)?.group_id(group_id)?.end()
1296    }
1297
1298    /// `StoreScene` capture + commit. Walks the
1299    /// [`SceneClusters`] registry, builds an EFS blob on a stack
1300    /// buffer, and upserts the result into the table.
1301    async fn store_scene<P: TLVBuilderParent>(
1302        &self,
1303        ctx: &impl InvokeContext,
1304        request: &StoreSceneRequest<'_>,
1305        response: StoreSceneResponseBuilder<P>,
1306    ) -> Result<P, Error> {
1307        let fab_idx = Self::fab_idx(ctx)?;
1308        let endpoint_id = ctx.cmd().endpoint_id;
1309        let group_id = request.group_id()?;
1310        let scene_id = request.scene_id()?;
1311
1312        // `SceneID = 0x00` (Global Scene) and `0xFF` are reserved.
1313        if scene_id == GLOBAL_SCENE_ID || scene_id == RESERVED_SCENE_ID {
1314            return response
1315                .status(SC_CONSTRAINT_ERROR)?
1316                .group_id(group_id)?
1317                .scene_id(scene_id)?
1318                .end();
1319        }
1320
1321        if !Self::group_in_table(ctx, fab_idx, endpoint_id, group_id)? {
1322            return response
1323                .status(SC_INVALID_COMMAND)?
1324                .group_id(group_id)?
1325                .scene_id(scene_id)?
1326                .end();
1327        }
1328
1329        // Capture each scene-aware cluster's EFS struct into the
1330        // scratch buffer. `SceneClusters::capture` writes each struct
1331        // directly (no outer `start_array` byte); we append the
1332        // trailing array terminator ourselves so the result matches
1333        // `SceneEntry::extension_fields`'s "contents + 0x18" shape.
1334        let mut scratch = [0u8; M];
1335        let total_len = {
1336            let mut wb = WriteBuf::new(&mut scratch);
1337            let parent = TLVWriteParent::new("StoreScene EFS", &mut wb);
1338            let _ = self.clusters.capture(endpoint_id, parent)?;
1339            wb.end_container()?;
1340            wb.get_tail()
1341        };
1342        let stored_bytes = &scratch[..total_len];
1343
1344        // Reuse the prior record's transition_time when overwriting;
1345        // 0 for a fresh record (spec leaves it implementation-defined).
1346        let prior_tt = self.state.with(|inner| {
1347            inner
1348                .table
1349                .iter()
1350                .find(|e| e.matches(fab_idx, endpoint_id, group_id, scene_id))
1351                .map(|e| e.transition_time)
1352        });
1353        let transition_time = prior_tt.unwrap_or(0);
1354
1355        let status_code = self.state.with(|inner| {
1356            let status = Self::upsert_scene(
1357                inner,
1358                fab_idx,
1359                endpoint_id,
1360                group_id,
1361                scene_id,
1362                transition_time,
1363                |ext_fields| {
1364                    if !stored_bytes.is_empty() {
1365                        ext_fields
1366                            .extend_from_slice(stored_bytes)
1367                            .map_err(|_| ErrorCode::NoSpace)?;
1368                    }
1369                    Ok(())
1370                },
1371            )?;
1372            // The stored scene by definition matches current state,
1373            // so promote `(group, scene)` to the recalled scene with
1374            // `SceneValid=true` — overriding any invalidation
1375            // `upsert_scene` may have just performed on a re-store
1376            // of the previously-recalled entry.
1377            if status == 0 {
1378                Self::remember_current(inner, fab_idx, endpoint_id, group_id, scene_id);
1379            }
1380            Ok::<_, Error>(status)
1381        })?;
1382
1383        if status_code == 0 {
1384            self.state.store_persist(ctx)?;
1385            ctx.notify_own_attr_changed(AttributeId::FabricSceneInfo as _);
1386        }
1387
1388        response
1389            .status(status_code)?
1390            .group_id(group_id)?
1391            .scene_id(scene_id)?
1392            .end()
1393    }
1394
1395    /// `RecallScene` parse + apply: snapshot the stored EFS under
1396    /// the mutex, drop the mutex, walk the EFS blob and let the
1397    /// cluster registry apply each entry, then commit `CurrentScene`
1398    /// for this fabric.
1399    async fn recall_scene(
1400        &self,
1401        ctx: &impl InvokeContext,
1402        request: &RecallSceneRequest<'_>,
1403    ) -> Result<(), Error> {
1404        let fab_idx = Self::fab_idx(ctx)?;
1405        let endpoint_id = ctx.cmd().endpoint_id;
1406        let group_id = request.group_id()?;
1407        let scene_id = request.scene_id()?;
1408
1409        // `RecallScene` has no response struct (returns `()`), so
1410        // the spec status comes out as an IM-level
1411        // `CommandStatusIB.status` via `Err(ErrorCode::*)`. The
1412        // `ErrorCode → IMStatusCode` map in `im.rs` produces the
1413        // right wire codes; `set_cluster_status` would wrap as
1414        // `FAILURE` and chip-tool's certification suites reject that
1415        // shape.
1416
1417        // `SceneID = 0x00` (Global Scene) and `0xFF` are reserved.
1418        if scene_id == GLOBAL_SCENE_ID || scene_id == RESERVED_SCENE_ID {
1419            return Err(ErrorCode::ConstraintError.into());
1420        }
1421
1422        if !Self::group_in_table(ctx, fab_idx, endpoint_id, group_id)? {
1423            return Err(ErrorCode::InvalidCommand.into());
1424        }
1425
1426        // The request's optional+nullable `transition_time` override
1427        // wins when present; otherwise fall back to the stored value.
1428        let override_tt_ms: Option<u32> = request.transition_time()?.and_then(|n| n.into_option());
1429
1430        // Copy the stored EFS blob into a stack buffer under the
1431        // lock, then drop the lock so cross-cluster work below
1432        // doesn't run with it held. `TLVSequence` walks the stored
1433        // "EFS structs + 0x18 terminator" shape directly — no need
1434        // to re-attach the missing `start_array(Anonymous)` byte.
1435        let mut blob = [0u8; M];
1436        let (blob_len, stored_tt_ms) = self.state.with(|inner| -> Result<_, Error> {
1437            let Some(e) = inner
1438                .table
1439                .iter()
1440                .find(|e| e.matches(fab_idx, endpoint_id, group_id, scene_id))
1441            else {
1442                return Ok((None, None));
1443            };
1444            let len = e.extension_fields.len();
1445            blob[..len].copy_from_slice(&e.extension_fields);
1446            Ok((Some(len), Some(e.transition_time)))
1447        })?;
1448        let (Some(blob_len), Some(stored_tt_ms)) = (blob_len, stored_tt_ms) else {
1449            return Err(ErrorCode::NotFound.into());
1450        };
1451
1452        let effective_tt_ms = override_tt_ms.unwrap_or(stored_tt_ms);
1453
1454        for efs_element in TLVSequence(&blob[..blob_len]).iter() {
1455            let efs = ExtensionFieldSetStruct::new(efs_element?);
1456            let cluster_id = efs.cluster_id()?;
1457            let avp_list = efs.attribute_value_list()?;
1458            // Unknown cluster IDs (firmware downgrade that dropped a
1459            // scenable cluster) are silently skipped by `apply`.
1460            let _ = self
1461                .clusters
1462                .apply(ctx, endpoint_id, cluster_id, &avp_list, effective_tt_ms)
1463                .await?;
1464        }
1465
1466        self.state
1467            .with(|inner| Self::remember_current(inner, fab_idx, endpoint_id, group_id, scene_id));
1468
1469        self.state.store_persist(ctx)?;
1470        ctx.notify_own_attr_changed(AttributeId::FabricSceneInfo as _);
1471        Ok(())
1472    }
1473
1474    fn get_scene_membership<P: TLVBuilderParent>(
1475        &self,
1476        ctx: &impl InvokeContext,
1477        request: &GetSceneMembershipRequest<'_>,
1478        response: GetSceneMembershipResponseBuilder<P>,
1479    ) -> Result<P, Error> {
1480        let fab_idx = Self::fab_idx(ctx)?;
1481        let endpoint_id = ctx.cmd().endpoint_id;
1482        let group_id = request.group_id()?;
1483
1484        // Reject unknown group with `INVALID_COMMAND`; spec allows
1485        // `null` for `Capacity` on this failure path.
1486        if !Self::group_in_table(ctx, fab_idx, endpoint_id, group_id)? {
1487            return response
1488                .status(SC_INVALID_COMMAND)?
1489                .capacity(Nullable::none())?
1490                .group_id(group_id)?
1491                .scene_list()?
1492                .none()
1493                .end();
1494        }
1495
1496        // Build the response inside the lock so scene IDs can be
1497        // streamed directly without snapshotting into a stack `Vec`.
1498        // `SceneList` is always emitted on the success path (empty
1499        // when the group has no scenes on this endpoint).
1500        self.state.with(|inner| -> Result<P, Error> {
1501            let remaining = Self::remaining_capacity_for_fab(inner, fab_idx);
1502
1503            let resp = response
1504                .status(0)?
1505                .capacity(Nullable::some(remaining))?
1506                .group_id(group_id)?;
1507
1508            let list = resp.scene_list()?.some()?;
1509            let list = inner
1510                .table
1511                .iter()
1512                .filter(|e| {
1513                    e.fab_idx == fab_idx && e.endpoint_id == endpoint_id && e.group_id == group_id
1514                })
1515                .try_fold(list, |list, e| list.push(&e.scene_id))?;
1516            list.end()?.end()
1517        })
1518    }
1519
1520    fn copy_scene<P: TLVBuilderParent>(
1521        &self,
1522        ctx: &impl InvokeContext,
1523        request: &CopySceneRequest<'_>,
1524        response: CopySceneResponseBuilder<P>,
1525    ) -> Result<P, Error> {
1526        let fab_idx = Self::fab_idx(ctx)?;
1527        let endpoint_id = ctx.cmd().endpoint_id;
1528        let mode = request.mode()?;
1529        let group_from = request.group_identifier_from()?;
1530        let scene_from = request.scene_identifier_from()?;
1531        let group_to = request.group_identifier_to()?;
1532        let scene_to = request.scene_identifier_to()?;
1533
1534        // `CopyModeBitmap` bit 0 = COPY_ALL_SCENES (From/To
1535        // SceneIDs are ignored in this mode).
1536        let copy_all = (mode.bits() & 0x01) != 0;
1537
1538        // Reserved `SceneID`s (Global Scene `0x00`, `0xFF`) are only
1539        // invalid in single-scene mode (COPY_ALL ignores those fields).
1540        if !copy_all
1541            && (scene_from == GLOBAL_SCENE_ID
1542                || scene_from == RESERVED_SCENE_ID
1543                || scene_to == GLOBAL_SCENE_ID
1544                || scene_to == RESERVED_SCENE_ID)
1545        {
1546            return response
1547                .status(SC_CONSTRAINT_ERROR)?
1548                .group_identifier_from(group_from)?
1549                .scene_identifier_from(scene_from)?
1550                .end();
1551        }
1552
1553        if !Self::group_in_table(ctx, fab_idx, endpoint_id, group_from)?
1554            || !Self::group_in_table(ctx, fab_idx, endpoint_id, group_to)?
1555        {
1556            return response
1557                .status(SC_INVALID_COMMAND)?
1558                .group_identifier_from(group_from)?
1559                .scene_identifier_from(scene_from)?
1560                .end();
1561        }
1562
1563        let status = self.state.with(|inner| {
1564            Self::copy_scenes_inner(
1565                inner,
1566                fab_idx,
1567                endpoint_id,
1568                group_from,
1569                scene_from,
1570                group_to,
1571                scene_to,
1572                copy_all,
1573            )
1574        });
1575
1576        if status == 0 {
1577            self.state.store_persist(ctx)?;
1578            ctx.notify_own_attr_changed(AttributeId::FabricSceneInfo as _);
1579        }
1580
1581        response
1582            .status(status)?
1583            .group_identifier_from(group_from)?
1584            .scene_identifier_from(scene_from)?
1585            .end()
1586    }
1587}
1588
1589impl<const N: usize, R, const M: usize> ClusterAsyncHandler for ScenesHandler<'_, N, R, M>
1590where
1591    R: SceneClusters,
1592{
1593    const CLUSTER: Cluster<'static> = FULL_CLUSTER;
1594
1595    fn dataver(&self) -> u32 {
1596        self.dataver.get()
1597    }
1598
1599    fn dataver_changed(&self) {
1600        self.dataver.changed();
1601    }
1602
1603    fn scene_table_size(&self, _ctx: impl ReadContext) -> impl Future<Output = Result<u16, Error>> {
1604        ready(Ok(N as u16))
1605    }
1606
1607    fn fabric_scene_info<P: TLVBuilderParent>(
1608        &self,
1609        ctx: impl ReadContext,
1610        builder: ArrayAttributeRead<SceneInfoStructArrayBuilder<P>, SceneInfoStructBuilder<P>>,
1611    ) -> impl Future<Output = Result<P, Error>> {
1612        ready(self.read_fabric_scene_info(&ctx, builder))
1613    }
1614
1615    fn handle_add_scene<P: TLVBuilderParent>(
1616        &self,
1617        ctx: impl InvokeContext,
1618        request: AddSceneRequest<'_>,
1619        response: AddSceneResponseBuilder<P>,
1620    ) -> impl Future<Output = Result<P, Error>> {
1621        ready(self.add_scene(&ctx, &request, response))
1622    }
1623
1624    fn handle_view_scene<P: TLVBuilderParent>(
1625        &self,
1626        ctx: impl InvokeContext,
1627        request: ViewSceneRequest<'_>,
1628        response: ViewSceneResponseBuilder<P>,
1629    ) -> impl Future<Output = Result<P, Error>> {
1630        ready(self.view_scene(&ctx, &request, response))
1631    }
1632
1633    fn handle_remove_scene<P: TLVBuilderParent>(
1634        &self,
1635        ctx: impl InvokeContext,
1636        request: RemoveSceneRequest<'_>,
1637        response: RemoveSceneResponseBuilder<P>,
1638    ) -> impl Future<Output = Result<P, Error>> {
1639        ready(self.remove_scene(&ctx, &request, response))
1640    }
1641
1642    fn handle_remove_all_scenes<P: TLVBuilderParent>(
1643        &self,
1644        ctx: impl InvokeContext,
1645        request: RemoveAllScenesRequest<'_>,
1646        response: RemoveAllScenesResponseBuilder<P>,
1647    ) -> impl Future<Output = Result<P, Error>> {
1648        ready(self.remove_all_scenes(&ctx, &request, response))
1649    }
1650
1651    async fn handle_store_scene<P: TLVBuilderParent>(
1652        &self,
1653        ctx: impl InvokeContext,
1654        request: StoreSceneRequest<'_>,
1655        response: StoreSceneResponseBuilder<P>,
1656    ) -> Result<P, Error> {
1657        self.store_scene(&ctx, &request, response).await
1658    }
1659
1660    async fn handle_recall_scene(
1661        &self,
1662        ctx: impl InvokeContext,
1663        request: RecallSceneRequest<'_>,
1664    ) -> Result<(), Error> {
1665        self.recall_scene(&ctx, &request).await
1666    }
1667
1668    fn handle_get_scene_membership<P: TLVBuilderParent>(
1669        &self,
1670        ctx: impl InvokeContext,
1671        request: GetSceneMembershipRequest<'_>,
1672        response: GetSceneMembershipResponseBuilder<P>,
1673    ) -> impl Future<Output = Result<P, Error>> {
1674        ready(self.get_scene_membership(&ctx, &request, response))
1675    }
1676
1677    fn handle_copy_scene<P: TLVBuilderParent>(
1678        &self,
1679        ctx: impl InvokeContext,
1680        request: CopySceneRequest<'_>,
1681        response: CopySceneResponseBuilder<P>,
1682    ) -> impl Future<Output = Result<P, Error>> {
1683        ready(self.copy_scene(&ctx, &request, response))
1684    }
1685}
1686
1687#[cfg(test)]
1688mod tests {
1689    //! Unit tests for the Scenes Management internals — primarily
1690    //! [`ScenesHandler::copy_scenes_inner`] (in-place upsert loop
1691    //! over a shared table) and the `CurrentScene` invalidation
1692    //! rules. Tests operate on [`ScenesStateInner`] directly, no
1693    //! `Matter` / `InvokeContext` setup needed.
1694
1695    use super::*;
1696
1697    fn fab(n: u8) -> NonZeroU8 {
1698        NonZeroU8::new(n).unwrap()
1699    }
1700
1701    fn entry(
1702        fab_idx: NonZeroU8,
1703        endpoint_id: EndptId,
1704        group_id: u16,
1705        scene_id: SceneId,
1706        transition_time: u32,
1707    ) -> SceneEntry {
1708        SceneEntry {
1709            fab_idx,
1710            endpoint_id,
1711            group_id,
1712            scene_id,
1713            transition_time,
1714            extension_fields: Vec::new(),
1715        }
1716    }
1717
1718    /// Variant of [`entry`] that stamps an arbitrary EFS blob.
1719    fn entry_with_blob(
1720        fab_idx: NonZeroU8,
1721        endpoint_id: EndptId,
1722        group_id: u16,
1723        scene_id: SceneId,
1724        transition_time: u32,
1725        blob: &[u8],
1726    ) -> SceneEntry {
1727        let mut ext: Vec<u8, MAX_EXT_FIELDS_LEN> = Vec::new();
1728        ext.extend_from_slice(blob)
1729            .expect("blob too large for test");
1730        SceneEntry {
1731            fab_idx,
1732            endpoint_id,
1733            group_id,
1734            scene_id,
1735            transition_time,
1736            extension_fields: ext,
1737        }
1738    }
1739
1740    fn push<const N: usize>(inner: &mut ScenesStateInner<N>, e: SceneEntry) {
1741        inner.table.push(e).expect("test table overflow");
1742    }
1743
1744    /// Count entries in `inner.table` matching the given filter.
1745    fn count<const N: usize>(
1746        inner: &ScenesStateInner<N>,
1747        fab_idx: NonZeroU8,
1748        ep: EndptId,
1749        group: u16,
1750    ) -> usize {
1751        inner
1752            .table
1753            .iter()
1754            .filter(|e| e.fab_idx == fab_idx && e.endpoint_id == ep && e.group_id == group)
1755            .count()
1756    }
1757
1758    fn find_tt<const N: usize>(
1759        inner: &ScenesStateInner<N>,
1760        fab_idx: NonZeroU8,
1761        ep: EndptId,
1762        group: u16,
1763        scene: SceneId,
1764    ) -> Option<u32> {
1765        inner
1766            .table
1767            .iter()
1768            .find(|e| e.matches(fab_idx, ep, group, scene))
1769            .map(|e| e.transition_time)
1770    }
1771
1772    /// Helper: look up the extension-fields blob for one entry.
1773    fn find_blob<const N: usize>(
1774        inner: &ScenesStateInner<N>,
1775        fab_idx: NonZeroU8,
1776        ep: EndptId,
1777        group: u16,
1778        scene: SceneId,
1779    ) -> Option<&[u8]> {
1780        inner
1781            .table
1782            .iter()
1783            .find(|e| e.matches(fab_idx, ep, group, scene))
1784            .map(|e| e.extension_fields.as_slice())
1785    }
1786
1787    // ---- extension-fields blob preservation ----
1788
1789    #[test]
1790    fn copy_single_scene_preserves_extension_fields_blob() {
1791        // Source carries an opaque blob; the copy must replicate the
1792        // bytes byte-for-byte at the destination row.
1793        let blob = &[0xDE, 0xAD, 0xBE, 0xEF, 0x18];
1794        let mut inner = ScenesStateInner::<8>::new();
1795        push(&mut inner, entry_with_blob(fab(1), 1, 10, 5, 100, blob));
1796
1797        let status =
1798            ScenesHandler::<8>::copy_scenes_inner(&mut inner, fab(1), 1, 10, 5, 20, 7, false);
1799        assert_eq!(status, 0);
1800
1801        assert_eq!(find_blob(&inner, fab(1), 1, 20, 7), Some(&blob[..]));
1802        // Source row keeps its blob too.
1803        assert_eq!(find_blob(&inner, fab(1), 1, 10, 5), Some(&blob[..]));
1804    }
1805
1806    #[test]
1807    fn copy_all_preserves_each_source_blob() {
1808        // `N=16` so per-fab cap `(N-1)/2 = 7` comfortably absorbs the
1809        // 2-source + 2-copy = 4 rows for fab(1).
1810        let blob_a = &[0xAA, 0xBB, 0x18];
1811        let blob_b = &[0xCC, 0x18];
1812        let mut inner = ScenesStateInner::<16>::new();
1813        push(&mut inner, entry_with_blob(fab(1), 1, 10, 1, 100, blob_a));
1814        push(&mut inner, entry_with_blob(fab(1), 1, 10, 2, 200, blob_b));
1815
1816        let status =
1817            ScenesHandler::<16>::copy_scenes_inner(&mut inner, fab(1), 1, 10, 0, 20, 0, true);
1818        assert_eq!(status, 0);
1819
1820        assert_eq!(find_blob(&inner, fab(1), 1, 20, 1), Some(&blob_a[..]));
1821        assert_eq!(find_blob(&inner, fab(1), 1, 20, 2), Some(&blob_b[..]));
1822    }
1823
1824    #[test]
1825    fn copy_overwrites_existing_dest_blob() {
1826        let old_blob = &[0x11, 0x18];
1827        let new_blob = &[0x22, 0x33, 0x18];
1828        let mut inner = ScenesStateInner::<8>::new();
1829        push(&mut inner, entry_with_blob(fab(1), 1, 10, 5, 100, new_blob));
1830        push(&mut inner, entry_with_blob(fab(1), 1, 20, 7, 999, old_blob));
1831
1832        let status =
1833            ScenesHandler::<8>::copy_scenes_inner(&mut inner, fab(1), 1, 10, 5, 20, 7, false);
1834        assert_eq!(status, 0);
1835
1836        // Dest row's blob got replaced with the source's blob (not
1837        // appended to / mixed with the old).
1838        assert_eq!(find_blob(&inner, fab(1), 1, 20, 7), Some(&new_blob[..]));
1839    }
1840
1841    // ---- copy_scenes_inner: specific-scene mode ----
1842
1843    #[test]
1844    fn copy_single_scene_to_new_dest() {
1845        let mut inner = ScenesStateInner::<8>::new();
1846        push(&mut inner, entry(fab(1), 1, 10, 5, 100));
1847
1848        let status = ScenesHandler::<8>::copy_scenes_inner(
1849            &mut inner,
1850            fab(1),
1851            1,
1852            /*from*/ 10,
1853            5,
1854            /*to*/ 20,
1855            7,
1856            /*copy_all*/ false,
1857        );
1858
1859        assert_eq!(status, 0);
1860        // Source still there.
1861        assert_eq!(find_tt(&inner, fab(1), 1, 10, 5), Some(100));
1862        // Dest got a new entry with the source's transition_time but
1863        // the requested target scene_id.
1864        assert_eq!(find_tt(&inner, fab(1), 1, 20, 7), Some(100));
1865        assert_eq!(inner.table.len(), 2);
1866    }
1867
1868    #[test]
1869    fn copy_single_scene_replaces_existing_dest() {
1870        let mut inner = ScenesStateInner::<8>::new();
1871        push(&mut inner, entry(fab(1), 1, 10, 5, 100));
1872        push(&mut inner, entry(fab(1), 1, 20, 7, 999));
1873
1874        let status =
1875            ScenesHandler::<8>::copy_scenes_inner(&mut inner, fab(1), 1, 10, 5, 20, 7, false);
1876
1877        assert_eq!(status, 0);
1878        // Dest's transition_time was overwritten — no new row pushed.
1879        assert_eq!(find_tt(&inner, fab(1), 1, 20, 7), Some(100));
1880        assert_eq!(inner.table.len(), 2);
1881    }
1882
1883    #[test]
1884    fn copy_single_scene_missing_source_returns_not_found() {
1885        let mut inner = ScenesStateInner::<8>::new();
1886        push(&mut inner, entry(fab(1), 1, 10, 5, 100));
1887
1888        let status = ScenesHandler::<8>::copy_scenes_inner(
1889            &mut inner,
1890            fab(1),
1891            1,
1892            /*from*/ 99, // group doesn't exist
1893            5,
1894            20,
1895            7,
1896            false,
1897        );
1898
1899        assert_eq!(status, SC_NOT_FOUND);
1900        // No side effects on the table.
1901        assert_eq!(inner.table.len(), 1);
1902        assert_eq!(find_tt(&inner, fab(1), 1, 20, 7), None);
1903    }
1904
1905    // ---- copy_scenes_inner: copy-all mode ----
1906
1907    #[test]
1908    fn copy_all_copies_every_source_scene() {
1909        // `N=16` so per-fab cap `(N-1)/2 = 7` comfortably absorbs the
1910        // 3-source + 3-copy = 6 rows for fab(1).
1911        let mut inner = ScenesStateInner::<16>::new();
1912        push(&mut inner, entry(fab(1), 1, 10, 1, 100));
1913        push(&mut inner, entry(fab(1), 1, 10, 2, 200));
1914        push(&mut inner, entry(fab(1), 1, 10, 3, 300));
1915
1916        let status = ScenesHandler::<16>::copy_scenes_inner(
1917            &mut inner,
1918            fab(1),
1919            1,
1920            10,
1921            /*scene_from*/ 0, // ignored in copy_all
1922            20,
1923            /*scene_to*/ 0, // ignored in copy_all
1924            true,
1925        );
1926
1927        assert_eq!(status, 0);
1928        // All three source scene IDs replicated under group 20 with
1929        // the same scene IDs and transition_times.
1930        assert_eq!(count(&inner, fab(1), 1, 20), 3);
1931        assert_eq!(find_tt(&inner, fab(1), 1, 20, 1), Some(100));
1932        assert_eq!(find_tt(&inner, fab(1), 1, 20, 2), Some(200));
1933        assert_eq!(find_tt(&inner, fab(1), 1, 20, 3), Some(300));
1934        // Sources untouched.
1935        assert_eq!(count(&inner, fab(1), 1, 10), 3);
1936    }
1937
1938    #[test]
1939    fn copy_all_to_same_group_is_noop() {
1940        // Edge case: group_from == group_to. Each "copy" lands on the
1941        // existing source row → in-place replace of transition_time
1942        // with itself. Loop must terminate (pushes never occur) and
1943        // not infinite-loop on newly-pushed rows.
1944        let mut inner = ScenesStateInner::<8>::new();
1945        push(&mut inner, entry(fab(1), 1, 10, 1, 100));
1946        push(&mut inner, entry(fab(1), 1, 10, 2, 200));
1947
1948        let status = ScenesHandler::<8>::copy_scenes_inner(
1949            &mut inner,
1950            fab(1),
1951            1,
1952            /*from*/ 10,
1953            0,
1954            /*to*/ 10, // SAME as from
1955            0,
1956            true,
1957        );
1958
1959        assert_eq!(status, 0);
1960        assert_eq!(inner.table.len(), 2);
1961    }
1962
1963    #[test]
1964    fn copy_all_missing_source_returns_not_found() {
1965        let mut inner = ScenesStateInner::<8>::new();
1966        // Some unrelated scenes — should not interfere.
1967        push(&mut inner, entry(fab(1), 1, 99, 1, 100));
1968
1969        let status = ScenesHandler::<8>::copy_scenes_inner(
1970            &mut inner,
1971            fab(1),
1972            1,
1973            10, // empty group
1974            0,
1975            20,
1976            0,
1977            true,
1978        );
1979
1980        assert_eq!(status, SC_NOT_FOUND);
1981        assert_eq!(inner.table.len(), 1);
1982        assert_eq!(count(&inner, fab(1), 1, 20), 0);
1983    }
1984
1985    #[test]
1986    fn copy_all_capacity_exhaustion_returns_insufficient_space() {
1987        // N=3 capacity. Fill with 3 scenes in group 10. Copying all
1988        // to a new group 20 needs 3 more slots → fail mid-copy.
1989        let mut inner = ScenesStateInner::<3>::new();
1990        inner.table.push(entry(fab(1), 1, 10, 1, 100)).unwrap();
1991        inner.table.push(entry(fab(1), 1, 10, 2, 200)).unwrap();
1992        inner.table.push(entry(fab(1), 1, 10, 3, 300)).unwrap();
1993
1994        let status =
1995            ScenesHandler::<3>::copy_scenes_inner(&mut inner, fab(1), 1, 10, 0, 20, 0, true);
1996
1997        assert_eq!(status, SC_INSUFFICIENT_SPACE);
1998        // Table is at capacity, partial copies are NOT rolled back
1999        // (matches the original Vec-based implementation's behaviour);
2000        // just assert we didn't lose the sources.
2001        assert_eq!(inner.table.len(), 3);
2002    }
2003
2004    // ---- isolation: don't touch other fabrics or endpoints ----
2005
2006    #[test]
2007    fn copy_does_not_cross_fabric_boundary() {
2008        let mut inner = ScenesStateInner::<8>::new();
2009        push(&mut inner, entry(fab(1), 1, 10, 5, 100));
2010
2011        let status = ScenesHandler::<8>::copy_scenes_inner(
2012            &mut inner,
2013            fab(2), // different fabric
2014            1,
2015            10,
2016            5,
2017            20,
2018            7,
2019            false,
2020        );
2021
2022        assert_eq!(status, SC_NOT_FOUND);
2023        // fab(2) didn't gain a row.
2024        assert_eq!(count(&inner, fab(2), 1, 20), 0);
2025        // fab(1)'s row is untouched.
2026        assert_eq!(find_tt(&inner, fab(1), 1, 10, 5), Some(100));
2027    }
2028
2029    #[test]
2030    fn copy_does_not_cross_endpoint_boundary() {
2031        let mut inner = ScenesStateInner::<8>::new();
2032        push(&mut inner, entry(fab(1), 1, 10, 5, 100));
2033
2034        let status = ScenesHandler::<8>::copy_scenes_inner(
2035            &mut inner,
2036            fab(1),
2037            2, // different endpoint
2038            10,
2039            5,
2040            20,
2041            7,
2042            false,
2043        );
2044
2045        assert_eq!(status, SC_NOT_FOUND);
2046        assert_eq!(count(&inner, fab(1), 2, 20), 0);
2047    }
2048
2049    // ---- side effect: SceneValid invalidation ----
2050
2051    #[test]
2052    fn successful_copy_invalidates_current_scene_on_match() {
2053        let mut inner = ScenesStateInner::<8>::new();
2054        push(&mut inner, entry(fab(1), 1, 10, 5, 100));
2055        // Stamp "current scene" at the copy's TARGET (20, 7). After
2056        // the copy overwrites that slot, `SceneValid` MUST become
2057        // false because the recalled-scene data just changed
2058        // underneath the recall.
2059        ScenesHandler::<8>::remember_current(&mut inner, fab(1), 1, 20, 7);
2060        assert_eq!(inner.current_per_fabric.len(), 1);
2061
2062        let status =
2063            ScenesHandler::<8>::copy_scenes_inner(&mut inner, fab(1), 1, 10, 5, 20, 7, false);
2064
2065        assert_eq!(status, 0);
2066        // The slot persists (so `FabricSceneInfo` keeps emitting a row
2067        // for this fabric) but `valid` flips to false.
2068        let slot = inner
2069            .current_per_fabric
2070            .iter()
2071            .find(|c| c.fab_idx == fab(1))
2072            .expect("slot kept");
2073        assert!(!slot.valid);
2074    }
2075
2076    #[test]
2077    fn successful_copy_preserves_current_scene_when_target_doesnt_match() {
2078        let mut inner = ScenesStateInner::<8>::new();
2079        push(&mut inner, entry(fab(1), 1, 10, 5, 100));
2080        // Stamp "current scene" at (99, 99) — disjoint from the
2081        // copy's target (20, 7). Per Matter spec,
2082        // `SceneValid` must be preserved when the copy doesn't touch
2083        // the currently-recalled scene.
2084        ScenesHandler::<8>::remember_current(&mut inner, fab(1), 1, 99, 99);
2085
2086        let status =
2087            ScenesHandler::<8>::copy_scenes_inner(&mut inner, fab(1), 1, 10, 5, 20, 7, false);
2088
2089        assert_eq!(status, 0);
2090        assert_eq!(inner.current_per_fabric.len(), 1);
2091        assert_eq!(inner.current_per_fabric[0].group_id, 99);
2092        assert_eq!(inner.current_per_fabric[0].scene_id, 99);
2093        assert!(inner.current_per_fabric[0].valid);
2094    }
2095
2096    #[test]
2097    fn failed_copy_does_not_invalidate_current_scene() {
2098        let mut inner = ScenesStateInner::<8>::new();
2099        ScenesHandler::<8>::remember_current(&mut inner, fab(1), 1, 99, 99);
2100        let dv_before = inner.info_dataver;
2101
2102        let status = ScenesHandler::<8>::copy_scenes_inner(
2103            &mut inner,
2104            fab(1),
2105            1,
2106            10, // empty group
2107            5,
2108            20,
2109            7,
2110            false,
2111        );
2112
2113        assert_eq!(status, SC_NOT_FOUND);
2114        // current_per_fabric untouched.
2115        assert_eq!(inner.current_per_fabric.len(), 1);
2116        assert_eq!(inner.current_per_fabric[0].fab_idx, fab(1));
2117        assert_eq!(inner.current_per_fabric[0].group_id, 99);
2118        assert_eq!(inner.current_per_fabric[0].scene_id, 99);
2119        // info_dataver not bumped on failure.
2120        assert_eq!(inner.info_dataver, dv_before);
2121    }
2122
2123    // ---- remember_current / invalidate_current helpers ----
2124
2125    #[test]
2126    fn remember_current_replaces_existing_slot_in_place() {
2127        let mut inner = ScenesStateInner::<8>::new();
2128        ScenesHandler::<8>::remember_current(&mut inner, fab(1), 1, 10, 1);
2129        ScenesHandler::<8>::remember_current(&mut inner, fab(1), 1, 20, 2);
2130
2131        // Same fabric ⇒ slot is updated, not duplicated.
2132        assert_eq!(inner.current_per_fabric.len(), 1);
2133        assert_eq!(inner.current_per_fabric[0].group_id, 20);
2134        assert_eq!(inner.current_per_fabric[0].scene_id, 2);
2135    }
2136
2137    #[test]
2138    fn remember_current_keeps_fabrics_independent() {
2139        let mut inner = ScenesStateInner::<8>::new();
2140        ScenesHandler::<8>::remember_current(&mut inner, fab(1), 1, 10, 1);
2141        ScenesHandler::<8>::remember_current(&mut inner, fab(2), 1, 20, 2);
2142
2143        assert_eq!(inner.current_per_fabric.len(), 2);
2144    }
2145
2146    #[test]
2147    fn invalidate_match_scene_only_clears_exact_match() {
2148        let mut inner = ScenesStateInner::<8>::new();
2149        ScenesHandler::<8>::remember_current(&mut inner, fab(1), 1, 10, 1);
2150        ScenesHandler::<8>::remember_current(&mut inner, fab(2), 1, 20, 2);
2151
2152        // Non-matching (group, scene) leaves the entry alone — this is
2153        // the spec-preserve-SceneValid path used by `AddScene` /
2154        // `RemoveScene` / `CopyScene` when they target a non-current
2155        // scene.
2156        ScenesHandler::<8>::invalidate_current_if_match_scene(&mut inner, fab(1), 99, 99);
2157        assert_eq!(inner.current_per_fabric.len(), 2);
2158        assert!(inner.current_per_fabric.iter().all(|c| c.valid));
2159
2160        // Matching (group, scene) on fab(1) flips just fab(1)'s valid
2161        // bit — entries always persist so `FabricSceneInfo` still
2162        // emits a row for the fabric.
2163        ScenesHandler::<8>::invalidate_current_if_match_scene(&mut inner, fab(1), 10, 1);
2164        assert_eq!(inner.current_per_fabric.len(), 2);
2165        let f1 = inner
2166            .current_per_fabric
2167            .iter()
2168            .find(|c| c.fab_idx == fab(1))
2169            .unwrap();
2170        assert!(!f1.valid);
2171        let f2 = inner
2172            .current_per_fabric
2173            .iter()
2174            .find(|c| c.fab_idx == fab(2))
2175            .unwrap();
2176        assert!(f2.valid);
2177    }
2178
2179    #[test]
2180    fn invalidate_match_group_clears_any_scene_in_group() {
2181        let mut inner = ScenesStateInner::<8>::new();
2182        ScenesHandler::<8>::remember_current(&mut inner, fab(1), 1, 10, 7);
2183        ScenesHandler::<8>::remember_current(&mut inner, fab(2), 1, 20, 2);
2184
2185        // Wrong group: no-op.
2186        ScenesHandler::<8>::invalidate_current_if_match_group(&mut inner, fab(1), 99);
2187        assert!(inner.current_per_fabric.iter().all(|c| c.valid));
2188
2189        // Right group on fab(1), regardless of scene id, flips fab(1)'s
2190        // valid bit — exercising the `RemoveAllScenes(group)` /
2191        // `CopyScene COPY_ALL` path.
2192        ScenesHandler::<8>::invalidate_current_if_match_group(&mut inner, fab(1), 10);
2193        let f1 = inner
2194            .current_per_fabric
2195            .iter()
2196            .find(|c| c.fab_idx == fab(1))
2197            .unwrap();
2198        assert!(!f1.valid);
2199        let f2 = inner
2200            .current_per_fabric
2201            .iter()
2202            .find(|c| c.fab_idx == fab(2))
2203            .unwrap();
2204        assert!(f2.valid);
2205    }
2206
2207    // ---- AddScene / StoreScene shared `upsert_scene` path ----
2208
2209    /// Fill closure that copies a fixed slice into the slot Vec.
2210    fn fill_with<'a>(blob: &'a [u8]) -> impl FnOnce(&mut Vec<u8, 128>) -> Result<(), Error> + 'a {
2211        move |ext| {
2212            ext.extend_from_slice(blob)
2213                .map_err(|_| ErrorCode::NoSpace.into())
2214        }
2215    }
2216
2217    #[test]
2218    fn upsert_inserts_new_record_with_status_zero() {
2219        let mut inner = ScenesStateInner::<8>::new();
2220        let status = ScenesHandler::<8>::upsert_scene(
2221            &mut inner,
2222            fab(1),
2223            1,
2224            10,
2225            5,
2226            100,
2227            fill_with(&[0xAA, 0x18]),
2228        )
2229        .unwrap();
2230
2231        assert_eq!(status, 0);
2232        assert_eq!(inner.table.len(), 1);
2233        assert_eq!(find_tt(&inner, fab(1), 1, 10, 5), Some(100));
2234        assert_eq!(find_blob(&inner, fab(1), 1, 10, 5), Some(&[0xAA, 0x18][..]));
2235    }
2236
2237    #[test]
2238    fn upsert_replaces_existing_record_in_place_no_growth() {
2239        let mut inner = ScenesStateInner::<8>::new();
2240        push(
2241            &mut inner,
2242            entry_with_blob(fab(1), 1, 10, 5, 100, &[0xAA, 0x18]),
2243        );
2244
2245        let status = ScenesHandler::<8>::upsert_scene(
2246            &mut inner,
2247            fab(1),
2248            1,
2249            10,
2250            5,
2251            999,
2252            fill_with(&[0xBB, 0xCC, 0x18]),
2253        )
2254        .unwrap();
2255
2256        assert_eq!(status, 0);
2257        assert_eq!(inner.table.len(), 1, "replace must not grow the table");
2258        assert_eq!(find_tt(&inner, fab(1), 1, 10, 5), Some(999));
2259        assert_eq!(
2260            find_blob(&inner, fab(1), 1, 10, 5),
2261            Some(&[0xBB, 0xCC, 0x18][..])
2262        );
2263    }
2264
2265    #[test]
2266    fn upsert_returns_insufficient_space_when_table_is_full() {
2267        // Fill the table to capacity, then try to insert a NEW key.
2268        let mut inner = ScenesStateInner::<3>::new();
2269        inner.table.push(entry(fab(1), 1, 10, 1, 100)).unwrap();
2270        inner.table.push(entry(fab(1), 1, 10, 2, 100)).unwrap();
2271        inner.table.push(entry(fab(1), 1, 10, 3, 100)).unwrap();
2272
2273        let status = ScenesHandler::<3>::upsert_scene(
2274            &mut inner,
2275            fab(1),
2276            1,
2277            10,
2278            99, // new scene_id
2279            200,
2280            fill_with(&[0x18]),
2281        )
2282        .unwrap();
2283
2284        assert_eq!(status, SC_INSUFFICIENT_SPACE);
2285        assert_eq!(inner.table.len(), 3, "table size unchanged on rejection");
2286    }
2287
2288    #[test]
2289    fn upsert_replace_at_full_capacity_still_succeeds() {
2290        // Replacing an EXISTING entry doesn't need a new slot, so it
2291        // should succeed even when the table is at capacity.
2292        let mut inner = ScenesStateInner::<3>::new();
2293        inner.table.push(entry(fab(1), 1, 10, 1, 100)).unwrap();
2294        inner.table.push(entry(fab(1), 1, 10, 2, 100)).unwrap();
2295        inner.table.push(entry(fab(1), 1, 10, 3, 100)).unwrap();
2296
2297        let status = ScenesHandler::<3>::upsert_scene(
2298            &mut inner,
2299            fab(1),
2300            1,
2301            10,
2302            2, // existing scene_id
2303            999,
2304            fill_with(&[0x18]),
2305        )
2306        .unwrap();
2307
2308        assert_eq!(status, 0);
2309        assert_eq!(inner.table.len(), 3);
2310        assert_eq!(find_tt(&inner, fab(1), 1, 10, 2), Some(999));
2311    }
2312
2313    #[test]
2314    fn upsert_invalidates_current_scene_when_upsert_targets_it() {
2315        // Per Matter App Cluster spec, `SceneValid` is only
2316        // invalidated when the upsert (`AddScene` / `StoreScene`)
2317        // overwrites the currently-recalled scene. Stamp the current
2318        // scene at the same `(group, scene)` the upsert targets and
2319        // verify it gets dropped.
2320        let mut inner = ScenesStateInner::<8>::new();
2321        ScenesHandler::<8>::remember_current(&mut inner, fab(1), 1, 10, 5);
2322
2323        let _ =
2324            ScenesHandler::<8>::upsert_scene(&mut inner, fab(1), 1, 10, 5, 100, fill_with(&[0x18]))
2325                .unwrap();
2326
2327        // The slot stays — the fabric is still "known" to the cluster
2328        // — but `valid` flips false.
2329        let f1 = inner
2330            .current_per_fabric
2331            .iter()
2332            .find(|c| c.fab_idx == fab(1))
2333            .expect("slot kept");
2334        assert!(!f1.valid);
2335    }
2336
2337    #[test]
2338    fn upsert_preserves_current_scene_when_upsert_targets_a_different_scene() {
2339        // Non-matching upsert MUST leave `SceneValid` intact.
2340        let mut inner = ScenesStateInner::<8>::new();
2341        ScenesHandler::<8>::remember_current(&mut inner, fab(1), 1, 1, 1);
2342
2343        let _ =
2344            // Upsert in a *different* group/scene than the current one.
2345            ScenesHandler::<8>::upsert_scene(&mut inner, fab(1), 1, 2, 1, 100, fill_with(&[0x18]))
2346                .unwrap();
2347
2348        assert_eq!(inner.current_per_fabric.len(), 1);
2349        assert_eq!(inner.current_per_fabric[0].group_id, 1);
2350        assert_eq!(inner.current_per_fabric[0].scene_id, 1);
2351        assert!(inner.current_per_fabric[0].valid);
2352    }
2353
2354    #[test]
2355    fn upsert_keeps_other_fabrics_current_scene_intact() {
2356        let mut inner = ScenesStateInner::<8>::new();
2357        // Both fabrics have a current scene matching what we're about
2358        // to upsert in fab(1) — only fab(1)'s entry should drop.
2359        ScenesHandler::<8>::remember_current(&mut inner, fab(1), 1, 10, 5);
2360        ScenesHandler::<8>::remember_current(&mut inner, fab(2), 1, 10, 5);
2361
2362        let _ =
2363            ScenesHandler::<8>::upsert_scene(&mut inner, fab(1), 1, 10, 5, 100, fill_with(&[0x18]))
2364                .unwrap();
2365
2366        // fab(1) is invalidated (valid=false) but the slot stays.
2367        // fab(2) is untouched.
2368        let f1 = inner
2369            .current_per_fabric
2370            .iter()
2371            .find(|c| c.fab_idx == fab(1))
2372            .expect("fab(1) slot kept");
2373        assert!(!f1.valid);
2374        let f2 = inner
2375            .current_per_fabric
2376            .iter()
2377            .find(|c| c.fab_idx == fab(2))
2378            .expect("fab(2) slot kept");
2379        assert!(f2.valid, "fab(2)'s CurrentScene must not be touched");
2380    }
2381
2382    #[test]
2383    fn upsert_at_full_capacity_does_not_invalidate_current() {
2384        // When the new-entry path errors with SC_INSUFFICIENT_SPACE,
2385        // the table state is unchanged — CurrentScene must stay too.
2386        let mut inner = ScenesStateInner::<3>::new();
2387        inner.table.push(entry(fab(1), 1, 10, 1, 100)).unwrap();
2388        inner.table.push(entry(fab(1), 1, 10, 2, 100)).unwrap();
2389        inner.table.push(entry(fab(1), 1, 10, 3, 100)).unwrap();
2390        ScenesHandler::<3>::remember_current(&mut inner, fab(1), 1, 99, 99);
2391
2392        let status = ScenesHandler::<3>::upsert_scene(
2393            &mut inner,
2394            fab(1),
2395            1,
2396            10,
2397            99,
2398            200,
2399            fill_with(&[0x18]),
2400        )
2401        .unwrap();
2402
2403        assert_eq!(status, SC_INSUFFICIENT_SPACE);
2404        assert!(inner.current_per_fabric.iter().any(|c| c.fab_idx == fab(1)));
2405    }
2406
2407    #[test]
2408    fn upsert_fill_failure_on_new_entry_rolls_back_the_push() {
2409        // If the fill closure errors *after* `push_init` has stamped
2410        // an empty SceneEntry into the slot, that slot must be popped
2411        // so the table returns to its pre-call state.
2412        let mut inner = ScenesStateInner::<8>::new();
2413        push(&mut inner, entry(fab(1), 1, 10, 1, 100));
2414
2415        let result = ScenesHandler::<8>::upsert_scene(
2416            &mut inner,
2417            fab(1),
2418            1,
2419            10,
2420            42, // brand new
2421            200,
2422            |_| Err(ErrorCode::NoSpace.into()),
2423        );
2424
2425        assert!(result.is_err());
2426        assert_eq!(
2427            inner.table.len(),
2428            1,
2429            "rolled-back push leaves count untouched"
2430        );
2431        assert!(find_tt(&inner, fab(1), 1, 10, 42).is_none());
2432    }
2433}