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