From a5efdbe5847a78928ea01a376cca1139d39f6200 Mon Sep 17 00:00:00 2001 From: ivmarkov Date: Thu, 28 May 2026 13:12:54 +0000 Subject: [PATCH 01/15] Scenes cluster handler --- rs-matter/src/dm/clusters.rs | 1 + rs-matter/src/dm/clusters/scenes.rs | 1171 +++++++++++++++++++++++++++ 2 files changed, 1172 insertions(+) create mode 100644 rs-matter/src/dm/clusters/scenes.rs diff --git a/rs-matter/src/dm/clusters.rs b/rs-matter/src/dm/clusters.rs index 93cc3ff74..a6e436dd8 100644 --- a/rs-matter/src/dm/clusters.rs +++ b/rs-matter/src/dm/clusters.rs @@ -37,6 +37,7 @@ pub mod grp_key_mgmt; pub mod identify; pub mod net_comm; pub mod noc; +pub mod scenes; pub mod sw_diag; pub mod thread_diag; pub mod time_sync; diff --git a/rs-matter/src/dm/clusters/scenes.rs b/rs-matter/src/dm/clusters/scenes.rs new file mode 100644 index 000000000..88d1cb6b7 --- /dev/null +++ b/rs-matter/src/dm/clusters/scenes.rs @@ -0,0 +1,1171 @@ +/* + * + * Copyright (c) 2026 Project CHIP Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +//! Scenes Management cluster (Matter Application Cluster Specification). +//! +//! # Overview +//! +//! A scene is a named snapshot of a chosen subset of cluster attributes +//! on one endpoint, stored on the device and recallable on demand. The +//! Scenes Management cluster owns the scene table and exposes commands +//! to add, view, remove, snapshot (`StoreScene`) and apply +//! (`RecallScene`) scenes. +//! +//! # Implementation status (v1) +//! +//! - All 8 commands have entry points. +//! - The 6 **data-only** commands are fully implemented: +//! `AddScene`, `ViewScene`, `RemoveScene`, `RemoveAllScenes`, +//! `GetSceneMembership`, `CopyScene`. +//! - **`StoreScene` / `RecallScene` are stubs** that return +//! `IMStatusCode::Failure` — they need cross-cluster attribute +//! read/write plumbing (via `ctx.handler()` on the global handler), +//! which is the v2 workstream. The handler is wired as +//! [`ClusterAsyncHandler`] so v2 can `.await` cross-cluster calls +//! without a trait-shape migration. +//! - **`ExtensionFieldSetStructs` payloads from `AddScene` are +//! discarded** in v1; `ViewScene` always echoes the field as absent. +//! v2 will keep the wire bytes in a per-scene blob and replay them. +//! - **In-RAM storage only**: scenes are not persisted across reboots +//! (also v2). +//! - The `SceneNames` feature is **disabled** by default; scene names +//! sent by the controller are accepted on the wire but discarded. +//! +//! # Storage model +//! +//! Scenes are fabric-scoped (each fabric has its own scene table) and +//! per-endpoint (scenes on EP1 don't affect EP2). The state is a +//! single flat [`heapless::Vec`] of [`SceneEntry`] entries keyed by +//! `(fab_idx, endpoint_id, group_id, scene_id)`. +//! +//! The caller owns the [`ScenesState`] and shares it via reference +//! with one [`ScenesHandler`] per endpoint where the cluster is +//! exposed. +//! +//! # Async-trait shape note +//! +//! [`ClusterAsyncHandler`] methods that don't actually need to +//! `.await` anything (i.e. all of them in v1) are written as +//! `fn foo(...) -> impl Future<...> { ready(self.foo_sync(...)) }` +//! delegating to a plain `fn foo_sync(...) -> Result<...>` helper. +//! This compiles to a much smaller image than `async fn` — no +//! state-machine generator, no closure. Matters on flash-constrained +//! MCUs. + +use core::future::{ready, Future}; +use core::num::NonZeroU8; + +use crate::dm::{ + ArrayAttributeRead, Cluster, Dataver, EndptId, InvokeContext, ReadContext, SceneId, +}; +use crate::error::{Error, ErrorCode}; +use crate::im::IMStatusCode; +use crate::tlv::TLVBuilderParent; +use crate::utils::cell::RefCell; +use crate::utils::init::{init, Init}; +use crate::utils::storage::Vec; +use crate::utils::sync::blocking::Mutex; + +pub use crate::dm::clusters::decl::scenes_management::*; + +/// IM status codes specific to the Scenes Management cluster (see +/// "Generic Usage Notes" in the Matter Application Cluster spec). +const SC_NOT_FOUND: u8 = 0x8B; +const SC_INSUFFICIENT_SPACE: u8 = 0x89; + +/// One scene record. v1 stores metadata only — the wire-form +/// `ExtensionFieldSetStructs` payload supplied by `AddScene` is +/// discarded (echo'd as absent on `ViewScene`). v2 will add a blob +/// field carrying that payload for `RecallScene`. +#[derive(Debug)] +#[cfg_attr(feature = "defmt", derive(defmt::Format))] +pub struct SceneEntry { + /// Fabric index that owns this scene (spec reserves `0` for + /// "no fabric" / PASE; an installed fabric is always non-zero). + fab_idx: NonZeroU8, + /// Endpoint this scene lives on. + endpoint_id: EndptId, + /// Group ID (0 ⇒ "no group" / per-endpoint). + group_id: u16, + /// Scene ID within the group. + scene_id: SceneId, + /// Transition time encoded per spec (1/10 s units). + transition_time: u32, +} + +impl SceneEntry { + fn matches( + &self, + fab_idx: NonZeroU8, + endpoint_id: EndptId, + group_id: u16, + scene_id: SceneId, + ) -> bool { + self.fab_idx == fab_idx + && self.endpoint_id == endpoint_id + && self.group_id == group_id + && self.scene_id == scene_id + } +} + +/// Per-fabric "last recalled scene" pointer feeding +/// `FabricSceneInfo.CurrentScene` / `CurrentGroup` / `SceneValid`. +#[derive(Debug, Clone, Copy)] +#[cfg_attr(feature = "defmt", derive(defmt::Format))] +struct CurrentScene { + fab_idx: NonZeroU8, + group_id: u16, + scene_id: SceneId, +} + +/// All mutable Scenes state, held behind a single mutex via +/// [`ScenesState`]. Grouped so the cluster handler takes exactly one +/// lock per operation — mirrors the `OnOffState` / `Mutex>` +/// shape used elsewhere in `rs-matter`. +struct ScenesStateInner { + /// The scene table, keyed by `(fab_idx, endpoint_id, group_id, scene_id)`. + table: Vec, + /// Bounded by `N` for storage symmetry; in practice one slot per + /// fabric. Absent for a given `fab_idx` ⇒ `SceneValid = false`. + current_per_fabric: Vec, + /// Bookkeeping bump for the `FabricSceneInfo` reader. + info_dataver: u32, +} + +impl ScenesStateInner { + const fn new() -> Self { + Self { + table: Vec::new(), + current_per_fabric: Vec::new(), + info_dataver: 0, + } + } + + /// In-place initializer — preferred when stamping into uninit + /// memory (e.g. `StaticCell::uninit().init_with(...)`). Mirrors + /// the same pattern used by `Fabrics`, `MatterState`, etc. + fn init() -> impl Init { + init!(Self { + table <- Vec::init(), + current_per_fabric <- Vec::init(), + info_dataver: 0, + }) + } + + fn bump_info_dataver(&mut self) { + self.info_dataver = self.info_dataver.wrapping_add(1); + } +} + +/// Caller-owned per-device Scenes state. Capacity is the const +/// generic `N`. Shared across all endpoints that expose the cluster. +/// +/// Internally a single [`Mutex`] over a [`RefCell`] — every handler +/// operation takes one lock and mutates the inner table directly. +pub struct ScenesState { + inner: Mutex>>, +} + +impl ScenesState { + pub const fn new() -> Self { + Self { + inner: Mutex::new(RefCell::new(ScenesStateInner::new())), + } + } + + /// In-place initializer. + pub fn init() -> impl Init { + init!(Self { + inner <- Mutex::init(RefCell::init(ScenesStateInner::init())), + }) + } + + /// Take the lock and run `f` against the mutable inner state. + fn with(&self, f: F) -> R + where + F: FnOnce(&mut ScenesStateInner) -> R, + { + self.inner.lock(|cell| { + let mut inner = cell.borrow_mut(); + f(&mut inner) + }) + } +} + +impl Default for ScenesState { + fn default() -> Self { + Self::new() + } +} + +/// Scenes Management cluster handler. Implements the **async** +/// codegen trait so the v2 store/recall paths can `.await` +/// cross-cluster reads/writes via `ctx.handler()` without a trait- +/// shape migration. +pub struct ScenesHandler<'a, const N: usize> { + dataver: Dataver, + state: &'a ScenesState, +} + +impl<'a, const N: usize> ScenesHandler<'a, N> { + pub const fn new(dataver: Dataver, state: &'a ScenesState) -> Self { + Self { dataver, state } + } + + pub const fn adapt(self) -> HandlerAsyncAdaptor { + HandlerAsyncAdaptor(self) + } + + fn fab_idx(ctx: &C) -> Result { + ctx.exchange().accessor()?.fab_idx() + } + + /// Stamp `(group, scene)` as the current recalled scene for this + /// fabric. Bumps `FabricSceneInfo` dataver. Operates on already- + /// locked inner state. + fn remember_current( + inner: &mut ScenesStateInner, + fab_idx: NonZeroU8, + group_id: u16, + scene_id: SceneId, + ) { + if let Some(slot) = inner + .current_per_fabric + .iter_mut() + .find(|c| c.fab_idx == fab_idx) + { + slot.group_id = group_id; + slot.scene_id = scene_id; + } else { + // Best-effort push; if the slab is full we silently stop + // tracking CurrentScene for this fabric (the spec permits + // SceneValid=false in such cases). + let _ = inner.current_per_fabric.push(CurrentScene { + fab_idx, + group_id, + scene_id, + }); + } + inner.bump_info_dataver(); + } + + /// Drop the recalled-scene tracker for this fabric — called after + /// operations that change the scene table in ways that may make + /// the previously-recalled scene no longer represent the current + /// attribute state (per the `SceneValid` field rules in the spec). + /// Operates on already-locked inner state. + fn invalidate_current(inner: &mut ScenesStateInner, fab_idx: NonZeroU8) { + inner.current_per_fabric.retain(|c| c.fab_idx != fab_idx); + inner.bump_info_dataver(); + } + + /// Internal copy helper — runs against an already-locked + /// [`ScenesStateInner`]. Returns the IM status code (0 on success). + /// + /// In-place index-walk: no scratch buffer. `heapless::Vec::push` + /// always appends at the end, so an upsert that has to push a new + /// destination row lands at an index strictly greater than the + /// current source index — earlier-index iteration stays valid. + /// Pushed rows live in `group_to`, never match the `group_from` + /// filter, so the loop converges. Worst case is O(N²) on the + /// inner `position` lookup, which is fine for the small `N` this + /// cluster carries. + #[allow(clippy::too_many_arguments)] + fn copy_scenes_inner( + inner: &mut ScenesStateInner, + fab_idx: NonZeroU8, + endpoint_id: EndptId, + group_from: u16, + scene_from: SceneId, + group_to: u16, + scene_to: SceneId, + copy_all: bool, + ) -> u8 { + let mut found_source = false; + let mut idx = 0; + while idx < inner.table.len() { + let src = &inner.table[idx]; + let src_matches = src.fab_idx == fab_idx + && src.endpoint_id == endpoint_id + && src.group_id == group_from + && (copy_all || src.scene_id == scene_from); + if src_matches { + found_source = true; + // Copy the scalars out so we can re-borrow the table + // mutably for the upsert. + let src_scene_id = src.scene_id; + let src_transition_time = src.transition_time; + let target_scene_id = if copy_all { src_scene_id } else { scene_to }; + + // Upsert into (fab, ep, group_to, target_scene_id). + if let Some(pos) = inner + .table + .iter() + .position(|e| e.matches(fab_idx, endpoint_id, group_to, target_scene_id)) + { + inner.table[pos].transition_time = src_transition_time; + } else if inner + .table + .push(SceneEntry { + fab_idx, + endpoint_id, + group_id: group_to, + scene_id: target_scene_id, + transition_time: src_transition_time, + }) + .is_err() + { + return SC_INSUFFICIENT_SPACE; + } + + // Single-scene mode copies exactly one entry — bail + // out before we walk the rest of the table. + if !copy_all { + break; + } + } + idx += 1; + } + + // Source must exist for the operation to succeed (per the + // `CopyScene` command's effect-on-receipt). + if !found_source { + return SC_NOT_FOUND; + } + + Self::invalidate_current(inner, fab_idx); + 0 + } + + // ----------------------------------------------------------------- + // Synchronous handler bodies. + // + // The trait-required methods in the `ClusterAsyncHandler` impl + // below are tiny `fn -> impl Future` wrappers that delegate here + // via `ready(self.foo_sync(...))`. Keeping the real logic + // synchronous (a) lets us use `?` freely, (b) skips the + // `async fn` state-machine codegen, and (c) avoids closure + // captures in the wrappers. Three small wins that add up on + // flash-constrained targets. + // ----------------------------------------------------------------- + + fn fabric_scene_info_sync( + &self, + ctx: &impl ReadContext, + builder: ArrayAttributeRead, SceneInfoStructBuilder

>, + ) -> Result { + let endpoint_id = ctx.attr().endpoint_id; + let accessor_fab_idx = ctx.exchange().accessor()?.fab_idx()?; + + // Snapshot the relevant scalars under a single lock, then build + // the response outside the lock. + let (scene_count, cur_group, cur_scene, valid, remaining) = self.state.with(|inner| { + let count = inner + .table + .iter() + .filter(|e| e.fab_idx == accessor_fab_idx && e.endpoint_id == endpoint_id) + .count(); + let current = inner + .current_per_fabric + .iter() + .find(|c| c.fab_idx == accessor_fab_idx) + .copied(); + let (g, s, v) = match current { + Some(c) => (Some(c.group_id), Some(c.scene_id), true), + None => (None, None, false), + }; + let rem = (N.saturating_sub(inner.table.len())).min(0xFF) as u8; + (count.min(0xFF) as u8, g, s, v, rem) + }); + + match builder { + ArrayAttributeRead::ReadAll(arr) => { + let arr = arr + .push()? + .scene_count(scene_count)? + .current_scene(cur_scene)? + .current_group(cur_group)? + .scene_valid(Some(valid))? + .remaining_capacity(remaining)? + .fabric_index(Some(accessor_fab_idx.get()))? + .end()?; + + arr.end() + } + ArrayAttributeRead::ReadNone(arr) => arr.end(), + ArrayAttributeRead::ReadOne(_idx, _entry) => { + // Indexed single-row reads aren't useful here (we only + // emit one row); reject as not found. + Err(ErrorCode::AttributeNotFound.into()) + } + } + } + + fn handle_add_scene_sync( + &self, + ctx: &impl InvokeContext, + request: &AddSceneRequest<'_>, + response: AddSceneResponseBuilder

, + ) -> Result { + let fab_idx = Self::fab_idx(ctx)?; + let endpoint_id = ctx.cmd().endpoint_id; + let group_id = request.group_id()?; + let scene_id = request.scene_id()?; + let transition_time = request.transition_time()?; + + // v1 discards the `ExtensionFieldSetStructs` payload; v2 will + // capture it so `RecallScene` can replay it. Scene names are + // accepted on the wire (codegen parses them) but not stored. + + // Insert / replace + invalidate SceneValid for this fabric — all + // under a single lock. Per the `SceneValid` field rules, + // adding/storing a scene that doesn't match the current + // attribute state invalidates SceneValid. + let status_code: u8 = self.state.with(|inner| { + if let Some(pos) = inner + .table + .iter() + .position(|e| e.matches(fab_idx, endpoint_id, group_id, scene_id)) + { + inner.table[pos].transition_time = transition_time; + Self::invalidate_current(inner, fab_idx); + 0 + } else if inner.table.len() >= N { + SC_INSUFFICIENT_SPACE + } else { + let _ = inner.table.push(SceneEntry { + fab_idx, + endpoint_id, + group_id, + scene_id, + transition_time, + }); + Self::invalidate_current(inner, fab_idx); + 0 + } + }); + + if status_code == 0 { + self.dataver_changed(); + } + + response + .status(status_code)? + .group_id(group_id)? + .scene_id(scene_id)? + .end() + } + + fn handle_view_scene_sync( + &self, + ctx: &impl InvokeContext, + request: &ViewSceneRequest<'_>, + response: ViewSceneResponseBuilder

, + ) -> Result { + let fab_idx = Self::fab_idx(ctx)?; + let endpoint_id = ctx.cmd().endpoint_id; + let group_id = request.group_id()?; + let scene_id = request.scene_id()?; + + let transition_time = self.state.with(|inner| { + inner + .table + .iter() + .find(|e| e.matches(fab_idx, endpoint_id, group_id, scene_id)) + .map(|e| e.transition_time) + }); + + // The wire shape is (status, group_id, scene_id, optional + // transition_time, optional scene_name, optional extension + // fields). All three optional fields are emitted as absent on + // NotFound; on Success, transition_time is populated, scene + // name is empty (SceneNames disabled), extension fields are + // absent (v2 will fill these from a stored blob). + match transition_time { + Some(tt) => response + .status(0)? + .group_id(group_id)? + .scene_id(scene_id)? + .transition_time(Some(tt))? + .scene_name(Some(""))? + .extension_field_set_structs()? + .none() + .end(), + None => response + .status(SC_NOT_FOUND)? + .group_id(group_id)? + .scene_id(scene_id)? + .transition_time(None)? + .scene_name(None)? + .extension_field_set_structs()? + .none() + .end(), + } + } + + fn handle_remove_scene_sync( + &self, + ctx: &impl InvokeContext, + request: &RemoveSceneRequest<'_>, + response: RemoveSceneResponseBuilder

, + ) -> Result { + let fab_idx = Self::fab_idx(ctx)?; + let endpoint_id = ctx.cmd().endpoint_id; + let group_id = request.group_id()?; + let scene_id = request.scene_id()?; + + let status: u8 = self.state.with(|inner| { + if let Some(pos) = inner + .table + .iter() + .position(|e| e.matches(fab_idx, endpoint_id, group_id, scene_id)) + { + inner.table.swap_remove(pos); + Self::invalidate_current(inner, fab_idx); + 0 + } else { + SC_NOT_FOUND + } + }); + + if status == 0 { + self.dataver_changed(); + } + + response + .status(status)? + .group_id(group_id)? + .scene_id(scene_id)? + .end() + } + + fn handle_remove_all_scenes_sync( + &self, + ctx: &impl InvokeContext, + request: &RemoveAllScenesRequest<'_>, + response: RemoveAllScenesResponseBuilder

, + ) -> Result { + let fab_idx = Self::fab_idx(ctx)?; + let endpoint_id = ctx.cmd().endpoint_id; + let group_id = request.group_id()?; + + let removed = self.state.with(|inner| { + let before = inner.table.len(); + inner.table.retain(|e| { + !(e.fab_idx == fab_idx && e.endpoint_id == endpoint_id && e.group_id == group_id) + }); + let changed = before != inner.table.len(); + if changed { + Self::invalidate_current(inner, fab_idx); + } + changed + }); + + if removed { + self.dataver_changed(); + } + + response.status(0)?.group_id(group_id)?.end() + } + + fn handle_store_scene_sync( + &self, + request: &StoreSceneRequest<'_>, + response: StoreSceneResponseBuilder

, + ) -> Result { + // v2: snapshot scene-able attributes on this endpoint via + // `ctx.handler()` and store as ExtensionFieldSetStructs. + let group_id = request.group_id()?; + let scene_id = request.scene_id()?; + response + .status(IMStatusCode::Failure as u8)? + .group_id(group_id)? + .scene_id(scene_id)? + .end() + } + + fn handle_recall_scene_sync( + &self, + ctx: &impl InvokeContext, + request: &RecallSceneRequest<'_>, + ) -> Result<(), Error> { + // v1 stub: succeeds for known (group, scene) — bumps + // CurrentScene — but doesn't actually apply attribute writes. + // v2 will parse the stored ExtensionFieldSetStructs and apply + // each attribute write via `ctx.handler()`. + let fab_idx = Self::fab_idx(ctx)?; + let endpoint_id = ctx.cmd().endpoint_id; + let group_id = request.group_id()?; + let scene_id = request.scene_id()?; + + let found = self.state.with(|inner| { + let f = inner + .table + .iter() + .any(|e| e.matches(fab_idx, endpoint_id, group_id, scene_id)); + if f { + Self::remember_current(inner, fab_idx, group_id, scene_id); + } + f + }); + + if !found { + return Err(ErrorCode::Failure.into()); + } + + self.dataver_changed(); + Ok(()) + } + + fn handle_get_scene_membership_sync( + &self, + ctx: &impl InvokeContext, + request: &GetSceneMembershipRequest<'_>, + response: GetSceneMembershipResponseBuilder

, + ) -> Result { + let fab_idx = Self::fab_idx(ctx)?; + let endpoint_id = ctx.cmd().endpoint_id; + let group_id = request.group_id()?; + + // Build the response directly inside the lock — the TLV + // builder is purely synchronous (no `.await`), so holding the + // lock for the write is cheap. This avoids snapshotting scene + // IDs into a stack `Vec` (could be ~N bytes; + // matters on small-stack MCUs). + self.state.with(|inner| -> Result { + let remaining = (N.saturating_sub(inner.table.len())).min(0xFF) as u8; + let group_has_scenes = inner.table.iter().any(|e| { + e.fab_idx == fab_idx && e.endpoint_id == endpoint_id && e.group_id == group_id + }); + + let resp = response + .status(0)? + .capacity(crate::tlv::Nullable::some(remaining))? + .group_id(group_id)?; + + // Per the `GetSceneMembership` command spec: when GroupID + // has no scenes on this device, SceneList SHALL be + // omitted (None). + if !group_has_scenes { + return resp.scene_list()?.none().end(); + } + + let list = resp.scene_list()?.some()?; + let list = inner + .table + .iter() + .filter(|e| { + e.fab_idx == fab_idx && e.endpoint_id == endpoint_id && e.group_id == group_id + }) + .try_fold(list, |list, e| list.push(&e.scene_id))?; + list.end()?.end() + }) + } + + fn handle_copy_scene_sync( + &self, + ctx: &impl InvokeContext, + request: &CopySceneRequest<'_>, + response: CopySceneResponseBuilder

, + ) -> Result { + let fab_idx = Self::fab_idx(ctx)?; + let endpoint_id = ctx.cmd().endpoint_id; + let mode = request.mode()?; + let group_from = request.group_identifier_from()?; + let scene_from = request.scene_identifier_from()?; + let group_to = request.group_identifier_to()?; + let scene_to = request.scene_identifier_to()?; + + // Per the `CopyModeBitmap` spec: bit 0 of Mode = COPY_ALL_SCENES + // (copy all scenes from the source group; the From/To SceneIDs + // are ignored when set). + let copy_all = (mode.bits() & 0x01) != 0; + + // The whole "look up source + copy entries" operation runs + // under one lock so the table can't change mid-copy. + let status = self.state.with(|inner| { + Self::copy_scenes_inner( + inner, + fab_idx, + endpoint_id, + group_from, + scene_from, + group_to, + scene_to, + copy_all, + ) + }); + + if status == 0 { + self.dataver_changed(); + } + + response + .status(status)? + .group_identifier_from(group_from)? + .scene_identifier_from(scene_from)? + .end() + } +} + +impl ClusterAsyncHandler for ScenesHandler<'_, N> { + /// FULL_CLUSTER minus the SceneNames feature (we accept the field + /// on the wire but don't persist it — see module docs). + const CLUSTER: Cluster<'static> = FULL_CLUSTER; + + fn dataver(&self) -> u32 { + self.dataver.get() + } + + fn dataver_changed(&self) { + self.dataver.changed(); + } + + fn scene_table_size(&self, _ctx: impl ReadContext) -> impl Future> { + ready(Ok(N as u16)) + } + + fn fabric_scene_info( + &self, + ctx: impl ReadContext, + builder: ArrayAttributeRead, SceneInfoStructBuilder

>, + ) -> impl Future> { + ready(self.fabric_scene_info_sync(&ctx, builder)) + } + + fn handle_add_scene( + &self, + ctx: impl InvokeContext, + request: AddSceneRequest<'_>, + response: AddSceneResponseBuilder

, + ) -> impl Future> { + ready(self.handle_add_scene_sync(&ctx, &request, response)) + } + + fn handle_view_scene( + &self, + ctx: impl InvokeContext, + request: ViewSceneRequest<'_>, + response: ViewSceneResponseBuilder

, + ) -> impl Future> { + ready(self.handle_view_scene_sync(&ctx, &request, response)) + } + + fn handle_remove_scene( + &self, + ctx: impl InvokeContext, + request: RemoveSceneRequest<'_>, + response: RemoveSceneResponseBuilder

, + ) -> impl Future> { + ready(self.handle_remove_scene_sync(&ctx, &request, response)) + } + + fn handle_remove_all_scenes( + &self, + ctx: impl InvokeContext, + request: RemoveAllScenesRequest<'_>, + response: RemoveAllScenesResponseBuilder

, + ) -> impl Future> { + ready(self.handle_remove_all_scenes_sync(&ctx, &request, response)) + } + + fn handle_store_scene( + &self, + _ctx: impl InvokeContext, + request: StoreSceneRequest<'_>, + response: StoreSceneResponseBuilder

, + ) -> impl Future> { + ready(self.handle_store_scene_sync(&request, response)) + } + + fn handle_recall_scene( + &self, + ctx: impl InvokeContext, + request: RecallSceneRequest<'_>, + ) -> impl Future> { + ready(self.handle_recall_scene_sync(&ctx, &request)) + } + + fn handle_get_scene_membership( + &self, + ctx: impl InvokeContext, + request: GetSceneMembershipRequest<'_>, + response: GetSceneMembershipResponseBuilder

, + ) -> impl Future> { + ready(self.handle_get_scene_membership_sync(&ctx, &request, response)) + } + + fn handle_copy_scene( + &self, + ctx: impl InvokeContext, + request: CopySceneRequest<'_>, + response: CopySceneResponseBuilder

, + ) -> impl Future> { + ready(self.handle_copy_scene_sync(&ctx, &request, response)) + } +} + +#[cfg(test)] +mod tests { + //! Unit tests for the more intricate Scenes Management logic. + //! + //! Focus is on [`ScenesHandler::copy_scenes_inner`] (in-place + //! upsert loop on a shared `inner.table`) and the `CurrentScene` + //! invalidation rules — the pieces with the easiest-to-introduce + //! bugs. + //! + //! Tests run against [`ScenesStateInner`] directly (the + //! handler-visible storage), so we don't have to spin up a full + //! `Matter`/`Exchange`/`InvokeContext` to exercise the algorithm. + + use super::*; + + fn fab(n: u8) -> NonZeroU8 { + NonZeroU8::new(n).unwrap() + } + + fn entry( + fab_idx: NonZeroU8, + endpoint_id: EndptId, + group_id: u16, + scene_id: SceneId, + transition_time: u32, + ) -> SceneEntry { + SceneEntry { + fab_idx, + endpoint_id, + group_id, + scene_id, + transition_time, + } + } + + fn push(inner: &mut ScenesStateInner<8>, e: SceneEntry) { + inner.table.push(e).expect("test table overflow"); + } + + /// Count entries in `inner.table` matching the given filter. + fn count(inner: &ScenesStateInner<8>, fab_idx: NonZeroU8, ep: EndptId, group: u16) -> usize { + inner + .table + .iter() + .filter(|e| e.fab_idx == fab_idx && e.endpoint_id == ep && e.group_id == group) + .count() + } + + fn find_tt( + inner: &ScenesStateInner<8>, + fab_idx: NonZeroU8, + ep: EndptId, + group: u16, + scene: SceneId, + ) -> Option { + inner + .table + .iter() + .find(|e| e.matches(fab_idx, ep, group, scene)) + .map(|e| e.transition_time) + } + + // ---- copy_scenes_inner: specific-scene mode ---- + + #[test] + fn copy_single_scene_to_new_dest() { + let mut inner = ScenesStateInner::<8>::new(); + push(&mut inner, entry(fab(1), 1, 10, 5, 100)); + + let status = ScenesHandler::<8>::copy_scenes_inner( + &mut inner, + fab(1), + 1, + /*from*/ 10, + 5, + /*to*/ 20, + 7, + /*copy_all*/ false, + ); + + assert_eq!(status, 0); + // Source still there. + assert_eq!(find_tt(&inner, fab(1), 1, 10, 5), Some(100)); + // Dest got a new entry with the source's transition_time but + // the requested target scene_id. + assert_eq!(find_tt(&inner, fab(1), 1, 20, 7), Some(100)); + assert_eq!(inner.table.len(), 2); + } + + #[test] + fn copy_single_scene_replaces_existing_dest() { + let mut inner = ScenesStateInner::<8>::new(); + push(&mut inner, entry(fab(1), 1, 10, 5, 100)); + push(&mut inner, entry(fab(1), 1, 20, 7, 999)); + + let status = + ScenesHandler::<8>::copy_scenes_inner(&mut inner, fab(1), 1, 10, 5, 20, 7, false); + + assert_eq!(status, 0); + // Dest's transition_time was overwritten — no new row pushed. + assert_eq!(find_tt(&inner, fab(1), 1, 20, 7), Some(100)); + assert_eq!(inner.table.len(), 2); + } + + #[test] + fn copy_single_scene_missing_source_returns_not_found() { + let mut inner = ScenesStateInner::<8>::new(); + push(&mut inner, entry(fab(1), 1, 10, 5, 100)); + + let status = ScenesHandler::<8>::copy_scenes_inner( + &mut inner, + fab(1), + 1, + /*from*/ 99, // group doesn't exist + 5, + 20, + 7, + false, + ); + + assert_eq!(status, SC_NOT_FOUND); + // No side effects on the table. + assert_eq!(inner.table.len(), 1); + assert_eq!(find_tt(&inner, fab(1), 1, 20, 7), None); + } + + // ---- copy_scenes_inner: copy-all mode ---- + + #[test] + fn copy_all_copies_every_source_scene() { + let mut inner = ScenesStateInner::<8>::new(); + push(&mut inner, entry(fab(1), 1, 10, 1, 100)); + push(&mut inner, entry(fab(1), 1, 10, 2, 200)); + push(&mut inner, entry(fab(1), 1, 10, 3, 300)); + + let status = ScenesHandler::<8>::copy_scenes_inner( + &mut inner, + fab(1), + 1, + 10, + /*scene_from*/ 0, // ignored in copy_all + 20, + /*scene_to*/ 0, // ignored in copy_all + true, + ); + + assert_eq!(status, 0); + // All three source scene IDs replicated under group 20 with + // the same scene IDs and transition_times. + assert_eq!(count(&inner, fab(1), 1, 20), 3); + assert_eq!(find_tt(&inner, fab(1), 1, 20, 1), Some(100)); + assert_eq!(find_tt(&inner, fab(1), 1, 20, 2), Some(200)); + assert_eq!(find_tt(&inner, fab(1), 1, 20, 3), Some(300)); + // Sources untouched. + assert_eq!(count(&inner, fab(1), 1, 10), 3); + } + + #[test] + fn copy_all_to_same_group_is_noop() { + // Edge case: group_from == group_to. Each "copy" lands on the + // existing source row → in-place replace of transition_time + // with itself. Loop must terminate (pushes never occur) and + // not infinite-loop on newly-pushed rows. + let mut inner = ScenesStateInner::<8>::new(); + push(&mut inner, entry(fab(1), 1, 10, 1, 100)); + push(&mut inner, entry(fab(1), 1, 10, 2, 200)); + + let status = ScenesHandler::<8>::copy_scenes_inner( + &mut inner, + fab(1), + 1, + /*from*/ 10, + 0, + /*to*/ 10, // SAME as from + 0, + true, + ); + + assert_eq!(status, 0); + assert_eq!(inner.table.len(), 2); + } + + #[test] + fn copy_all_missing_source_returns_not_found() { + let mut inner = ScenesStateInner::<8>::new(); + // Some unrelated scenes — should not interfere. + push(&mut inner, entry(fab(1), 1, 99, 1, 100)); + + let status = ScenesHandler::<8>::copy_scenes_inner( + &mut inner, + fab(1), + 1, + 10, // empty group + 0, + 20, + 0, + true, + ); + + assert_eq!(status, SC_NOT_FOUND); + assert_eq!(inner.table.len(), 1); + assert_eq!(count(&inner, fab(1), 1, 20), 0); + } + + #[test] + fn copy_all_capacity_exhaustion_returns_insufficient_space() { + // N=3 capacity. Fill with 3 scenes in group 10. Copying all + // to a new group 20 needs 3 more slots → fail mid-copy. + let mut inner = ScenesStateInner::<3>::new(); + inner.table.push(entry(fab(1), 1, 10, 1, 100)).unwrap(); + inner.table.push(entry(fab(1), 1, 10, 2, 200)).unwrap(); + inner.table.push(entry(fab(1), 1, 10, 3, 300)).unwrap(); + + let status = + ScenesHandler::<3>::copy_scenes_inner(&mut inner, fab(1), 1, 10, 0, 20, 0, true); + + assert_eq!(status, SC_INSUFFICIENT_SPACE); + // Table is at capacity, partial copies are NOT rolled back + // (matches the original Vec-based implementation's behaviour); + // just assert we didn't lose the sources. + assert_eq!(inner.table.len(), 3); + } + + // ---- isolation: don't touch other fabrics or endpoints ---- + + #[test] + fn copy_does_not_cross_fabric_boundary() { + let mut inner = ScenesStateInner::<8>::new(); + push(&mut inner, entry(fab(1), 1, 10, 5, 100)); + + let status = ScenesHandler::<8>::copy_scenes_inner( + &mut inner, + fab(2), // different fabric + 1, + 10, + 5, + 20, + 7, + false, + ); + + assert_eq!(status, SC_NOT_FOUND); + // fab(2) didn't gain a row. + assert_eq!(count(&inner, fab(2), 1, 20), 0); + // fab(1)'s row is untouched. + assert_eq!(find_tt(&inner, fab(1), 1, 10, 5), Some(100)); + } + + #[test] + fn copy_does_not_cross_endpoint_boundary() { + let mut inner = ScenesStateInner::<8>::new(); + push(&mut inner, entry(fab(1), 1, 10, 5, 100)); + + let status = ScenesHandler::<8>::copy_scenes_inner( + &mut inner, + fab(1), + 2, // different endpoint + 10, + 5, + 20, + 7, + false, + ); + + assert_eq!(status, SC_NOT_FOUND); + assert_eq!(count(&inner, fab(1), 2, 20), 0); + } + + // ---- side effect: SceneValid invalidation ---- + + #[test] + fn successful_copy_invalidates_current_scene() { + let mut inner = ScenesStateInner::<8>::new(); + push(&mut inner, entry(fab(1), 1, 10, 5, 100)); + // Stamp a "current scene" for fab 1, then assert it gets + // cleared after the copy. + ScenesHandler::<8>::remember_current(&mut inner, fab(1), 99, 99); + assert_eq!(inner.current_per_fabric.len(), 1); + + let status = + ScenesHandler::<8>::copy_scenes_inner(&mut inner, fab(1), 1, 10, 5, 20, 7, false); + + assert_eq!(status, 0); + // current_per_fabric for fab(1) was cleared. + assert!(inner.current_per_fabric.iter().all(|c| c.fab_idx != fab(1))); + } + + #[test] + fn failed_copy_does_not_invalidate_current_scene() { + let mut inner = ScenesStateInner::<8>::new(); + ScenesHandler::<8>::remember_current(&mut inner, fab(1), 99, 99); + let dv_before = inner.info_dataver; + + let status = ScenesHandler::<8>::copy_scenes_inner( + &mut inner, + fab(1), + 1, + 10, // empty group + 5, + 20, + 7, + false, + ); + + assert_eq!(status, SC_NOT_FOUND); + // current_per_fabric untouched. + assert_eq!(inner.current_per_fabric.len(), 1); + assert_eq!(inner.current_per_fabric[0].fab_idx, fab(1)); + assert_eq!(inner.current_per_fabric[0].group_id, 99); + assert_eq!(inner.current_per_fabric[0].scene_id, 99); + // info_dataver not bumped on failure. + assert_eq!(inner.info_dataver, dv_before); + } + + // ---- remember_current / invalidate_current helpers ---- + + #[test] + fn remember_current_replaces_existing_slot_in_place() { + let mut inner = ScenesStateInner::<8>::new(); + ScenesHandler::<8>::remember_current(&mut inner, fab(1), 10, 1); + ScenesHandler::<8>::remember_current(&mut inner, fab(1), 20, 2); + + // Same fabric ⇒ slot is updated, not duplicated. + assert_eq!(inner.current_per_fabric.len(), 1); + assert_eq!(inner.current_per_fabric[0].group_id, 20); + assert_eq!(inner.current_per_fabric[0].scene_id, 2); + } + + #[test] + fn remember_current_keeps_fabrics_independent() { + let mut inner = ScenesStateInner::<8>::new(); + ScenesHandler::<8>::remember_current(&mut inner, fab(1), 10, 1); + ScenesHandler::<8>::remember_current(&mut inner, fab(2), 20, 2); + + assert_eq!(inner.current_per_fabric.len(), 2); + } + + #[test] + fn invalidate_current_only_clears_target_fabric() { + let mut inner = ScenesStateInner::<8>::new(); + ScenesHandler::<8>::remember_current(&mut inner, fab(1), 10, 1); + ScenesHandler::<8>::remember_current(&mut inner, fab(2), 20, 2); + + ScenesHandler::<8>::invalidate_current(&mut inner, fab(1)); + + // fab(2)'s entry survives. + assert_eq!(inner.current_per_fabric.len(), 1); + assert_eq!(inner.current_per_fabric[0].fab_idx, fab(2)); + } +} From e8e17084f1e94afec3230e29a512f5a350ac9f99 Mon Sep 17 00:00:00 2001 From: ivmarkov Date: Thu, 28 May 2026 16:29:32 +0000 Subject: [PATCH 02/15] Calling other clusters from the Scenes one --- rs-matter/src/dm/clusters/app.rs | 1 + .../src/dm/clusters/app/color_control.rs | 494 +++++++ .../src/dm/clusters/app/level_control.rs | 127 ++ rs-matter/src/dm/clusters/app/on_off.rs | 84 ++ rs-matter/src/dm/clusters/scenes.rs | 1187 ++++++++++++++--- rs-matter/src/dm/types.rs | 1 + 6 files changed, 1736 insertions(+), 158 deletions(-) create mode 100644 rs-matter/src/dm/clusters/app/color_control.rs diff --git a/rs-matter/src/dm/clusters/app.rs b/rs-matter/src/dm/clusters/app.rs index b07fbd556..5831a5d27 100644 --- a/rs-matter/src/dm/clusters/app.rs +++ b/rs-matter/src/dm/clusters/app.rs @@ -30,6 +30,7 @@ pub mod cam_av_settings; pub mod cam_av_stream; pub mod chime; +pub mod color_control; pub mod level_control; pub mod on_off; pub mod push_av_stream; diff --git a/rs-matter/src/dm/clusters/app/color_control.rs b/rs-matter/src/dm/clusters/app/color_control.rs new file mode 100644 index 000000000..899af6259 --- /dev/null +++ b/rs-matter/src/dm/clusters/app/color_control.rs @@ -0,0 +1,494 @@ +/* + * + * Copyright (c) 2026 Project CHIP Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +//! ColorControl cluster (placeholder). +//! +//! A full ColorControl cluster handler is not yet shipped — this +//! module currently only provides the [`scenes`] submodule, which +//! lets ColorControl participate in scene capture / recall via +//! Scenes Management. Downstream apps that ship their own +//! ColorControl handler can register the [`scenes::ColorControlSceneClusterHandler`] +//! alongside it with `ScenesHandler::new`. + +/// Scenes Management integration for the ColorControl cluster. +/// +/// # Why ColorControl needs special wiring +/// +/// Unlike OnOff / LevelControl (each one read-only scene-able +/// attribute, one apply command), ColorControl has: +/// +/// - **Up to 9 scene-able attributes** (`CurrentX`, `CurrentY`, +/// `EnhancedCurrentHue`, `CurrentSaturation`, `ColorLoopActive`, +/// `ColorLoopDirection`, `ColorLoopTime`, `ColorTemperatureMireds`, +/// `EnhancedColorMode`). All are read-only at the attribute level. +/// - **Feature-conditional capture**: which attributes are stored +/// depends on the device's `FeatureMap` (`XY`, `HUE_AND_SATURATION`, +/// `ENHANCED_HUE`, `COLOR_LOOP`, `COLOR_TEMPERATURE`). +/// - **Mode-dependent apply**: the captured `EnhancedColorMode` +/// selects which `MoveTo*` command to invoke (`MoveToColor` for XY, +/// `MoveToColorTemperature` for `ColorTemperatureMireds`, etc.). If +/// `ColorLoopActive` is captured as `1`, apply instead starts a +/// color loop via `ColorLoopSet`. +/// +/// Reading the FeatureMap attribute at runtime is possible but adds +/// one cross-cluster read per scene operation. Instead we let the +/// application inject a [`ColorControlFeatureLookup`] that maps +/// `EndptId` → `Feature` bits — the app knows which ColorControl +/// features it enabled on which endpoints, so this is free. +pub mod scenes { + use crate::dm::clusters::decl::color_control::{ + AttributeId, ColorLoopActionEnum, ColorLoopDirectionEnum, ColorLoopSetRequestBuilder, + CommandId, EnhancedColorModeEnum, EnhancedMoveToHueAndSaturationRequestBuilder, Feature, + MoveToColorRequestBuilder, MoveToColorTemperatureRequestBuilder, + MoveToHueAndSaturationRequestBuilder, OptionsBitmap, UpdateFlagsBitmap, FULL_CLUSTER, + }; + use crate::dm::clusters::decl::scenes_management::{ + AttributeValuePairStruct, AttributeValuePairStructArrayBuilder, + }; + use crate::dm::clusters::scenes::{SceneClusterHandler, SceneContext}; + use crate::dm::{ClusterId, EndptId, InvokeContext}; + use crate::error::Error; + use crate::tlv::{TLVArray, TLVBuilderParent, TLVTag, TLVWriteParent}; + use crate::utils::storage::WriteBuf; + + /// Worst-case TLV-encoded size of any command this scene impl + /// sends. The largest is `ColorLoopSet` (7 fields): + /// + /// ```text + /// 0x15 (struct start, anon) 1 B + /// updateFlags bitmap8 @ Ctx 0 3 B + /// action enum8 @ Ctx 1 3 B + /// direction enum8 @ Ctx 2 3 B + /// time u16 @ Ctx 3 4 B + /// startHue u16 @ Ctx 4 4 B + /// optionsMask bitmap8 @ Ctx 5 3 B + /// optionsOverride bitmap8 @ Ctx 6 3 B + /// 0x18 (end_container) 1 B + /// ----------------------------------------------------------- 25 B + /// ``` + /// + /// The 4 Move-To commands all fit in ≤ 20 B. 32 leaves a + /// comfortable margin without over-committing. + const MAX_REQUEST_BUF: usize = 32; + + /// Application-supplied feature lookup for ColorControl. + /// + /// The Matter spec lets ColorControl be deployed with any subset + /// of {`XY`, `HUE_AND_SATURATION`, `ENHANCED_HUE`, `COLOR_LOOP`, + /// `COLOR_TEMPERATURE`}. The Scenes integration needs to know + /// which features are active on each endpoint so it can decide + /// which attributes to capture and which `MoveTo*` / + /// `ColorLoopSet` command to invoke on recall. + /// + /// The application implements this on whatever per-endpoint + /// state it already has (often a static lookup), and passes a + /// reference into [`ColorControlSceneClusterHandler::new`]. + pub trait ColorControlFeatureLookup { + /// Return the `Feature` bitmap enabled for `endpoint_id`. + /// Empty for endpoints where ColorControl is not installed + /// (callers should not invoke this for endpoints without + /// ColorControl). + fn features(&self, endpoint_id: EndptId) -> Feature; + } + + impl ColorControlFeatureLookup for &T { + fn features(&self, endpoint_id: EndptId) -> Feature { + (**self).features(endpoint_id) + } + } + + /// [`SceneClusterHandler`] for ColorControl. + /// + /// Holds a reference to a [`ColorControlFeatureLookup`]; not a + /// ZST because the cluster's behaviour is feature-conditional. + /// + /// ```ignore + /// let cc_lookup = MyFeatureLookup; + /// let scenes = ScenesHandler::new( + /// dataver, &scenes_state, + /// (OnOffSceneClusterHandler, + /// (LevelControlSceneClusterHandler, + /// (ColorControlSceneClusterHandler::new(&cc_lookup), ()))), + /// ); + /// ``` + #[derive(Copy, Clone)] + pub struct ColorControlSceneClusterHandler<'a> { + features: &'a dyn ColorControlFeatureLookup, + } + + impl<'a> ColorControlSceneClusterHandler<'a> { + pub const fn new(features: &'a dyn ColorControlFeatureLookup) -> Self { + Self { features } + } + } + + impl SceneClusterHandler for ColorControlSceneClusterHandler<'_> { + const CLUSTER_ID: ClusterId = FULL_CLUSTER.id; + + async fn capture( + &self, + sctx: &SceneContext, + endpoint_id: EndptId, + avp_array: AttributeValuePairStructArrayBuilder

, + ) -> Result, Error> + where + C: InvokeContext, + P: TLVBuilderParent, + { + let features = self.features.features(endpoint_id); + + // Capture order mirrors chip's + // `DefaultColorControlSceneHandler::SerializeSave`. + // `EnhancedColorMode` is captured unconditionally — apply + // dispatches on it. + let avp_array = if features.contains(Feature::XY) { + let x: u16 = sctx + .read(endpoint_id, FULL_CLUSTER.id, AttributeId::CurrentX as _) + .await?; + let avp_array = avp_array.push_u16(AttributeId::CurrentX as _, x)?; + let y: u16 = sctx + .read(endpoint_id, FULL_CLUSTER.id, AttributeId::CurrentY as _) + .await?; + avp_array.push_u16(AttributeId::CurrentY as _, y)? + } else { + avp_array + }; + + let avp_array = if features.contains(Feature::ENHANCED_HUE) { + let h: u16 = sctx + .read( + endpoint_id, + FULL_CLUSTER.id, + AttributeId::EnhancedCurrentHue as _, + ) + .await?; + avp_array.push_u16(AttributeId::EnhancedCurrentHue as _, h)? + } else { + avp_array + }; + + let avp_array = if features.contains(Feature::HUE_AND_SATURATION) { + let s: u8 = sctx + .read( + endpoint_id, + FULL_CLUSTER.id, + AttributeId::CurrentSaturation as _, + ) + .await?; + avp_array.push_u8(AttributeId::CurrentSaturation as _, s)? + } else { + avp_array + }; + + let avp_array = if features.contains(Feature::COLOR_LOOP) { + let active: u8 = sctx + .read( + endpoint_id, + FULL_CLUSTER.id, + AttributeId::ColorLoopActive as _, + ) + .await?; + let avp_array = avp_array.push_u8(AttributeId::ColorLoopActive as _, active)?; + let direction: u8 = sctx + .read( + endpoint_id, + FULL_CLUSTER.id, + AttributeId::ColorLoopDirection as _, + ) + .await?; + let avp_array = + avp_array.push_u8(AttributeId::ColorLoopDirection as _, direction)?; + let time: u16 = sctx + .read( + endpoint_id, + FULL_CLUSTER.id, + AttributeId::ColorLoopTime as _, + ) + .await?; + avp_array.push_u16(AttributeId::ColorLoopTime as _, time)? + } else { + avp_array + }; + + let avp_array = if features.contains(Feature::COLOR_TEMPERATURE) { + let mireds: u16 = sctx + .read( + endpoint_id, + FULL_CLUSTER.id, + AttributeId::ColorTemperatureMireds as _, + ) + .await?; + avp_array.push_u16(AttributeId::ColorTemperatureMireds as _, mireds)? + } else { + avp_array + }; + + // `EnhancedColorMode` is always captured. The enum is + // `enum8`, so serialized as `valueUnsigned8`. + let mode_u8: u8 = sctx + .read( + endpoint_id, + FULL_CLUSTER.id, + AttributeId::EnhancedColorMode as _, + ) + .await?; + avp_array.push_u8(AttributeId::EnhancedColorMode as _, mode_u8) + } + + async fn apply( + &self, + sctx: &SceneContext, + endpoint_id: EndptId, + transition_time_ms: u32, + avp_list: &TLVArray<'_, AttributeValuePairStruct<'_>>, + ) -> Result<(), Error> + where + C: InvokeContext, + { + // Sweep the AVP list once and stash each known value. We + // need EnhancedColorMode *and* the mode-specific values + // before we can decide which command to invoke. + let mut mode: Option = None; + let mut current_x: Option = None; + let mut current_y: Option = None; + let mut current_saturation: Option = None; + let mut enhanced_current_hue: Option = None; + let mut color_temperature_mireds: Option = None; + let mut color_loop_active: Option = None; + let mut color_loop_direction: Option = None; + let mut color_loop_time: Option = None; + + for avp in avp_list.iter() { + let avp = avp?; + let attr_id = avp.attribute_id()?; + if attr_id == AttributeId::EnhancedColorMode as _ { + if let Some(v) = avp.value_unsigned_8()? { + mode = enhanced_color_mode_from_u8(v); + } + } else if attr_id == AttributeId::CurrentX as _ { + current_x = avp.value_unsigned_16()?; + } else if attr_id == AttributeId::CurrentY as _ { + current_y = avp.value_unsigned_16()?; + } else if attr_id == AttributeId::CurrentSaturation as _ { + current_saturation = avp.value_unsigned_8()?; + } else if attr_id == AttributeId::EnhancedCurrentHue as _ { + enhanced_current_hue = avp.value_unsigned_16()?; + } else if attr_id == AttributeId::ColorTemperatureMireds as _ { + color_temperature_mireds = avp.value_unsigned_16()?; + } else if attr_id == AttributeId::ColorLoopActive as _ { + color_loop_active = avp.value_unsigned_8()?; + } else if attr_id == AttributeId::ColorLoopDirection as _ { + color_loop_direction = avp.value_unsigned_8()?; + } else if attr_id == AttributeId::ColorLoopTime as _ { + color_loop_time = avp.value_unsigned_16()?; + } + } + + // `MoveTo*` and `ColorLoopSet` carry `transitionTime` / + // `time` as `int16u`. Recall passes `int32u` milliseconds + // — convert with saturation. (`ColorLoopSet.time` is + // already in seconds in the spec; we pass through the + // captured `ColorLoopTime` value unchanged because that + // attribute is already in seconds per the spec.) + let transition_ds = (transition_time_ms / 100).min(u16::MAX as u32) as u16; + + // If the scene captured an active color loop, hand off to + // ColorLoopSet and ignore the Move-To dispatch — mirrors + // chip's behavior in `ColorControl::ApplyScene`. + if color_loop_active == Some(1) { + let direction = color_loop_direction + .and_then(color_loop_direction_from_u8) + .unwrap_or(ColorLoopDirectionEnum::Increment); + let time = color_loop_time.unwrap_or(0x0019); + + let mut data_buf = [0u8; MAX_REQUEST_BUF]; + let data_len = { + let mut wb = WriteBuf::new(&mut data_buf); + let parent = TLVWriteParent::new("Scene/ColorLoopSet", &mut wb); + ColorLoopSetRequestBuilder::new(parent, &TLVTag::Anonymous)? + .update_flags( + UpdateFlagsBitmap::UPDATE_ACTION + | UpdateFlagsBitmap::UPDATE_DIRECTION + | UpdateFlagsBitmap::UPDATE_TIME, + )? + .action(ColorLoopActionEnum::ActivateFromColorLoopStartEnhancedHue)? + .direction(direction)? + .time(time)? + // `StartHue` isn't updated here (no + // UPDATE_START_HUE flag set) but the field is + // mandatory on the wire — pass 0. + .start_hue(0)? + .options_mask(OptionsBitmap::empty())? + .options_override(OptionsBitmap::empty())? + .end()?; + wb.get_tail() + }; + return sctx + .invoke( + endpoint_id, + FULL_CLUSTER.id, + CommandId::ColorLoopSet as _, + &data_buf[..data_len], + ) + .await; + } + + let Some(mode) = mode else { + // No mode captured (perhaps an older firmware's blob + // that didn't include it) — nothing to do. + return Ok(()); + }; + + match mode { + EnhancedColorModeEnum::CurrentXAndCurrentY => { + let (Some(x), Some(y)) = (current_x, current_y) else { + return Ok(()); + }; + let mut data_buf = [0u8; MAX_REQUEST_BUF]; + let data_len = { + let mut wb = WriteBuf::new(&mut data_buf); + let parent = TLVWriteParent::new("Scene/MoveToColor", &mut wb); + MoveToColorRequestBuilder::new(parent, &TLVTag::Anonymous)? + .color_x(x)? + .color_y(y)? + .transition_time(transition_ds)? + .options_mask(OptionsBitmap::empty())? + .options_override(OptionsBitmap::empty())? + .end()?; + wb.get_tail() + }; + sctx.invoke( + endpoint_id, + FULL_CLUSTER.id, + CommandId::MoveToColor as _, + &data_buf[..data_len], + ) + .await + } + EnhancedColorModeEnum::ColorTemperatureMireds => { + let Some(mireds) = color_temperature_mireds else { + return Ok(()); + }; + let mut data_buf = [0u8; MAX_REQUEST_BUF]; + let data_len = { + let mut wb = WriteBuf::new(&mut data_buf); + let parent = TLVWriteParent::new("Scene/MoveToColorTemperature", &mut wb); + MoveToColorTemperatureRequestBuilder::new(parent, &TLVTag::Anonymous)? + .color_temperature_mireds(mireds)? + .transition_time(transition_ds)? + .options_mask(OptionsBitmap::empty())? + .options_override(OptionsBitmap::empty())? + .end()?; + wb.get_tail() + }; + sctx.invoke( + endpoint_id, + FULL_CLUSTER.id, + CommandId::MoveToColorTemperature as _, + &data_buf[..data_len], + ) + .await + } + EnhancedColorModeEnum::CurrentHueAndCurrentSaturation => { + // Non-enhanced hue is u8; if only EnhancedHue was + // captured but the mode says non-enhanced, take the + // low byte. (Chip's behavior is similar — it stashes + // into `colorHueTransitionState->finalEnhancedHue` + // which is then truncated on apply.) + let (Some(hue), Some(sat)) = ( + enhanced_current_hue.map(|h| (h & 0xFF) as u8), + current_saturation, + ) else { + return Ok(()); + }; + let mut data_buf = [0u8; MAX_REQUEST_BUF]; + let data_len = { + let mut wb = WriteBuf::new(&mut data_buf); + let parent = TLVWriteParent::new("Scene/MoveToHueAndSaturation", &mut wb); + MoveToHueAndSaturationRequestBuilder::new(parent, &TLVTag::Anonymous)? + .hue(hue)? + .saturation(sat)? + .transition_time(transition_ds)? + .options_mask(OptionsBitmap::empty())? + .options_override(OptionsBitmap::empty())? + .end()?; + wb.get_tail() + }; + sctx.invoke( + endpoint_id, + FULL_CLUSTER.id, + CommandId::MoveToHueAndSaturation as _, + &data_buf[..data_len], + ) + .await + } + EnhancedColorModeEnum::EnhancedCurrentHueAndCurrentSaturation => { + let (Some(hue), Some(sat)) = (enhanced_current_hue, current_saturation) else { + return Ok(()); + }; + let mut data_buf = [0u8; MAX_REQUEST_BUF]; + let data_len = { + let mut wb = WriteBuf::new(&mut data_buf); + let parent = + TLVWriteParent::new("Scene/EnhancedMoveToHueAndSaturation", &mut wb); + EnhancedMoveToHueAndSaturationRequestBuilder::new( + parent, + &TLVTag::Anonymous, + )? + .enhanced_hue(hue)? + .saturation(sat)? + .transition_time(transition_ds)? + .options_mask(OptionsBitmap::empty())? + .options_override(OptionsBitmap::empty())? + .end()?; + wb.get_tail() + }; + sctx.invoke( + endpoint_id, + FULL_CLUSTER.id, + CommandId::EnhancedMoveToHueAndSaturation as _, + &data_buf[..data_len], + ) + .await + } + } + } + } + + /// Convert a stored `valueUnsigned8` to an + /// `EnhancedColorModeEnum`, returning `None` for unknown values + /// rather than failing the apply. + fn enhanced_color_mode_from_u8(v: u8) -> Option { + match v { + 0 => Some(EnhancedColorModeEnum::CurrentHueAndCurrentSaturation), + 1 => Some(EnhancedColorModeEnum::CurrentXAndCurrentY), + 2 => Some(EnhancedColorModeEnum::ColorTemperatureMireds), + 3 => Some(EnhancedColorModeEnum::EnhancedCurrentHueAndCurrentSaturation), + _ => None, + } + } + + /// Convert a stored `valueUnsigned8` to a `ColorLoopDirectionEnum`, + /// returning `None` for unknown values. + fn color_loop_direction_from_u8(v: u8) -> Option { + match v { + 0 => Some(ColorLoopDirectionEnum::Decrement), + 1 => Some(ColorLoopDirectionEnum::Increment), + _ => None, + } + } +} diff --git a/rs-matter/src/dm/clusters/app/level_control.rs b/rs-matter/src/dm/clusters/app/level_control.rs index 9625569fb..8c3f54c76 100644 --- a/rs-matter/src/dm/clusters/app/level_control.rs +++ b/rs-matter/src/dm/clusters/app/level_control.rs @@ -1802,6 +1802,133 @@ impl OnOffHooks for NoOnOff { } } +/// Scenes Management integration for the LevelControl cluster. +/// +/// Per Matter Application Cluster Spec §1.5, LevelControl exposes a +/// single scene-able attribute (`CurrentLevel`, nullable u8) that is +/// **read-only** at the attribute level. Scene apply therefore goes +/// through the `MoveToLevel` command with the captured level + the +/// scene's transition time (ms → deciseconds, saturating). See +/// [`crate::dm::clusters::scenes::SceneClusterHandler`]. +pub mod scenes { + use crate::dm::clusters::decl::level_control::{ + AttributeId, CommandId, MoveToLevelRequestBuilder, OptionsBitmap, FULL_CLUSTER, + }; + use crate::dm::clusters::decl::scenes_management::{ + AttributeValuePairStruct, AttributeValuePairStructArrayBuilder, + }; + use crate::dm::clusters::scenes::{SceneClusterHandler, SceneContext}; + use crate::dm::{ClusterId, EndptId, InvokeContext}; + use crate::error::Error; + use crate::tlv::{Nullable, TLVArray, TLVBuilderParent, TLVTag, TLVWriteParent}; + use crate::utils::storage::WriteBuf; + + /// Worst-case TLV-encoded size of any command this scene impl + /// sends. The only command is `MoveToLevel`: + /// + /// ```text + /// 0x15 (struct start, anon) 1 B + /// level u8 @ Ctx 0 (ctrl + tag + 1 B value) 3 B + /// transitionTime nullable u16 @ Ctx 1 (worst case: 4 B) 4 B + /// optionsMask bitmap8 @ Ctx 2 (ctrl + tag + 1 B value) 3 B + /// optionsOverride bitmap8 @ Ctx 3 (ctrl + tag + 1 B value) 3 B + /// 0x18 (end_container) 1 B + /// ----------------------------------------------------------- 15 B + /// ``` + /// + /// Rounded up to 16 to keep one byte of slack. + const MAX_REQUEST_BUF: usize = 16; + + /// Zero-sized [`SceneClusterHandler`] impl for the LevelControl + /// cluster. + /// + /// Register with [`crate::dm::clusters::scenes::ScenesHandler::new`]: + /// + /// ```ignore + /// ScenesHandler::new( + /// dataver, &state, + /// (OnOffSceneClusterHandler, + /// (LevelControlSceneClusterHandler, ())), + /// ) + /// ``` + #[derive(Copy, Clone, Debug, Default, Eq, PartialEq, Hash)] + #[cfg_attr(feature = "defmt", derive(defmt::Format))] + pub struct LevelControlSceneClusterHandler; + + impl SceneClusterHandler for LevelControlSceneClusterHandler { + const CLUSTER_ID: ClusterId = FULL_CLUSTER.id; + + async fn capture( + &self, + sctx: &SceneContext, + endpoint_id: EndptId, + avp_array: AttributeValuePairStructArrayBuilder

, + ) -> Result, Error> + where + C: InvokeContext, + P: TLVBuilderParent, + { + // `CurrentLevel` is `nullable int8u`. Null → skip the AVP + // entry; downstream apply has nothing to act on. + let v: Nullable = sctx + .read(endpoint_id, FULL_CLUSTER.id, AttributeId::CurrentLevel as _) + .await?; + if let Some(level) = v.into_option() { + avp_array.push_u8(AttributeId::CurrentLevel as _, level) + } else { + Ok(avp_array) + } + } + + async fn apply( + &self, + sctx: &SceneContext, + endpoint_id: EndptId, + transition_time_ms: u32, + avp_list: &TLVArray<'_, AttributeValuePairStruct<'_>>, + ) -> Result<(), Error> + where + C: InvokeContext, + { + for avp in avp_list.iter() { + let avp = avp?; + if avp.attribute_id()? != AttributeId::CurrentLevel as _ { + continue; + } + let Some(level) = avp.value_unsigned_8()? else { + continue; + }; + // `MoveToLevelRequest.transitionTime` is `int16u` + // deciseconds; `RecallScene.transitionTime` is `int32u` + // milliseconds. Convert with saturation. + let transition_ds = (transition_time_ms / 100).min(u16::MAX as u32) as u16; + + let mut data_buf = [0u8; MAX_REQUEST_BUF]; + let data_len = { + let mut wb = WriteBuf::new(&mut data_buf); + let parent = TLVWriteParent::new("Scene/MoveToLevel", &mut wb); + MoveToLevelRequestBuilder::new(parent, &TLVTag::Anonymous)? + .level(level)? + .transition_time(Nullable::some(transition_ds))? + .options_mask(OptionsBitmap::empty())? + .options_override(OptionsBitmap::empty())? + .end()?; + wb.get_tail() + }; + return sctx + .invoke( + endpoint_id, + FULL_CLUSTER.id, + CommandId::MoveToLevel as _, + &data_buf[..data_len], + ) + .await; + } + Ok(()) + } + } +} + pub mod test { use crate::dm::clusters::app::level_control::{ AttributeId, CommandId, Feature, LevelControlHooks, FULL_CLUSTER, diff --git a/rs-matter/src/dm/clusters/app/on_off.rs b/rs-matter/src/dm/clusters/app/on_off.rs index 357976812..9e9cf7d26 100644 --- a/rs-matter/src/dm/clusters/app/on_off.rs +++ b/rs-matter/src/dm/clusters/app/on_off.rs @@ -1098,6 +1098,90 @@ impl LevelControlHooks for NoLevelControl { } } +/// Scenes Management integration for the OnOff cluster. +/// +/// Per Matter Application Cluster Spec §1.4 / §1.5, OnOff exposes a +/// single scene-able attribute (`OnOff`, bool) that is **read-only** +/// at the attribute level. Scene apply therefore goes through the +/// `On` / `Off` commands rather than an attribute write. See +/// [`crate::dm::clusters::scenes::SceneClusterHandler`]. +pub mod scenes { + use crate::dm::clusters::decl::on_off::{AttributeId, CommandId, FULL_CLUSTER}; + use crate::dm::clusters::decl::scenes_management::{ + AttributeValuePairStruct, AttributeValuePairStructArrayBuilder, + }; + use crate::dm::clusters::scenes::{SceneClusterHandler, SceneContext}; + use crate::dm::{ClusterId, EndptId, InvokeContext}; + use crate::error::Error; + use crate::tlv::{TLVArray, TLVBuilderParent}; + + /// Zero-sized [`SceneClusterHandler`] impl for the OnOff cluster. + /// + /// Register with [`crate::dm::clusters::scenes::ScenesHandler::new`]: + /// + /// ```ignore + /// ScenesHandler::new(dataver, &state, (OnOffSceneClusterHandler, ())) + /// ``` + #[derive(Copy, Clone, Debug, Default, Eq, PartialEq, Hash)] + #[cfg_attr(feature = "defmt", derive(defmt::Format))] + pub struct OnOffSceneClusterHandler; + + impl SceneClusterHandler for OnOffSceneClusterHandler { + const CLUSTER_ID: ClusterId = FULL_CLUSTER.id; + + async fn capture( + &self, + sctx: &SceneContext, + endpoint_id: EndptId, + avp_array: AttributeValuePairStructArrayBuilder

, + ) -> Result, Error> + where + C: InvokeContext, + P: TLVBuilderParent, + { + // `OnOff.OnOff` is bool; serialize as `valueUnsigned8` + // (0 / 1) per the Scenes spec's AttributeValuePairStruct. + let v: bool = sctx + .read(endpoint_id, FULL_CLUSTER.id, AttributeId::OnOff as _) + .await?; + avp_array.push_u8(AttributeId::OnOff as _, v as u8) + } + + async fn apply( + &self, + sctx: &SceneContext, + endpoint_id: EndptId, + _transition_time_ms: u32, + avp_list: &TLVArray<'_, AttributeValuePairStruct<'_>>, + ) -> Result<(), Error> + where + C: InvokeContext, + { + for avp in avp_list.iter() { + let avp = avp?; + if avp.attribute_id()? != AttributeId::OnOff as _ { + continue; + } + let Some(value) = avp.value_unsigned_8()? else { + continue; + }; + // Per spec, OnOff doesn't honour a per-scene transition + // time (it's a discrete on/off transition). Invoke the + // matching command with an empty payload. + let cmd_id = if value != 0 { + CommandId::On + } else { + CommandId::Off + }; + return sctx + .invoke(endpoint_id, FULL_CLUSTER.id, cmd_id as _, &[]) + .await; + } + Ok(()) + } + } +} + pub mod test { use embassy_time::{Duration, Timer}; diff --git a/rs-matter/src/dm/clusters/scenes.rs b/rs-matter/src/dm/clusters/scenes.rs index 88d1cb6b7..880a5726d 100644 --- a/rs-matter/src/dm/clusters/scenes.rs +++ b/rs-matter/src/dm/clusters/scenes.rs @@ -25,23 +25,25 @@ //! to add, view, remove, snapshot (`StoreScene`) and apply //! (`RecallScene`) scenes. //! -//! # Implementation status (v1) +//! # Implementation status //! //! - All 8 commands have entry points. //! - The 6 **data-only** commands are fully implemented: //! `AddScene`, `ViewScene`, `RemoveScene`, `RemoveAllScenes`, //! `GetSceneMembership`, `CopyScene`. -//! - **`StoreScene` / `RecallScene` are stubs** that return -//! `IMStatusCode::Failure` — they need cross-cluster attribute -//! read/write plumbing (via `ctx.handler()` on the global handler), -//! which is the v2 workstream. The handler is wired as -//! [`ClusterAsyncHandler`] so v2 can `.await` cross-cluster calls -//! without a trait-shape migration. -//! - **`ExtensionFieldSetStructs` payloads from `AddScene` are -//! discarded** in v1; `ViewScene` always echoes the field as absent. -//! v2 will keep the wire bytes in a per-scene blob and replay them. -//! - **In-RAM storage only**: scenes are not persisted across reboots -//! (also v2). +//! - **`StoreScene`** is fully implemented: it reads the scene-able +//! attributes of the registered clusters (OnOff + LevelControl — +//! see [`SCENEABLE_CLUSTERS`]) on the host endpoint via +//! `ctx.handler().read()` and stores the result as a wire-form +//! `ExtensionFieldSetStructs` blob keyed by `(group, scene)`. +//! - **`RecallScene`** is fully implemented: it parses the stored +//! `ExtensionFieldSetStructs` blob and re-applies each cluster's +//! captured state by invoking the spec'd cluster command (OnOff: +//! `On` / `Off`; LevelControl: `MoveToLevel`) via +//! `ctx.handler().invoke()`. Apply is intentionally per-cluster (not +//! a generic attribute write) because both spec'd scene-able +//! attributes are read-only. +//! - **In-RAM storage only**: scenes are not persisted across reboots. //! - The `SceneNames` feature is **disabled** by default; scene names //! sent by the controller are accepted on the wire but discarded. //! @@ -49,7 +51,7 @@ //! //! Scenes are fabric-scoped (each fabric has its own scene table) and //! per-endpoint (scenes on EP1 don't affect EP2). The state is a -//! single flat [`heapless::Vec`] of [`SceneEntry`] entries keyed by +//! single flat [`Vec`] of [`SceneEntry`] entries keyed by //! `(fab_idx, endpoint_id, group_id, scene_id)`. //! //! The caller owns the [`ScenesState`] and shares it via reference @@ -59,25 +61,33 @@ //! # Async-trait shape note //! //! [`ClusterAsyncHandler`] methods that don't actually need to -//! `.await` anything (i.e. all of them in v1) are written as -//! `fn foo(...) -> impl Future<...> { ready(self.foo_sync(...)) }` -//! delegating to a plain `fn foo_sync(...) -> Result<...>` helper. -//! This compiles to a much smaller image than `async fn` — no -//! state-machine generator, no closure. Matters on flash-constrained -//! MCUs. - +//! `.await` anything (every one except `handle_store_scene`) are +//! written as +//! `fn foo(...) -> impl Future<...> { ready(self.foo(...)) }` +//! delegating to a plain `fn foo(...) -> Result<...>` helper. This +//! compiles to a much smaller image than `async fn` — no state-machine +//! generator, no closure. Matters on flash-constrained MCUs. +//! `handle_store_scene` and `handle_recall_scene` *do* await +//! (cross-cluster reads + invokes), so their wrappers are real +//! `async fn`s that call [`Self::store_scene`] / [`Self::recall_scene`]. + +use core::cell::Cell; use core::future::{ready, Future}; use core::num::NonZeroU8; use crate::dm::{ - ArrayAttributeRead, Cluster, Dataver, EndptId, InvokeContext, ReadContext, SceneId, + ArrayAttributeRead, AsyncHandler, AttrDetails, AttrId, Cluster, ClusterId, CmdDetails, CmdId, + Dataver, EndptId, InvokeContext, InvokeContextInstance, InvokeReplyInstance, Metadata, + ReadContext, ReadContextInstance, ReadReply, Reply, SceneId, }; use crate::error::{Error, ErrorCode}; -use crate::im::IMStatusCode; -use crate::tlv::TLVBuilderParent; +use crate::tlv::{ + FromTLV, TLVArray, TLVBuilderParent, TLVElement, TLVTag, TLVWrite, TLVWriteParent, TagType, + ToTLV, +}; use crate::utils::cell::RefCell; use crate::utils::init::{init, Init}; -use crate::utils::storage::Vec; +use crate::utils::storage::{Vec, WriteBuf}; use crate::utils::sync::blocking::Mutex; pub use crate::dm::clusters::decl::scenes_management::*; @@ -87,13 +97,392 @@ pub use crate::dm::clusters::decl::scenes_management::*; const SC_NOT_FOUND: u8 = 0x8B; const SC_INSUFFICIENT_SPACE: u8 = 0x89; -/// One scene record. v1 stores metadata only — the wire-form -/// `ExtensionFieldSetStructs` payload supplied by `AddScene` is -/// discarded (echo'd as absent on `ViewScene`). v2 will add a blob -/// field carrying that payload for `RecallScene`. +/// Max length of the serialized `ExtensionFieldSetStructs` payload +/// carried on a single scene record. Per chip's notes a Color Control +/// scene is the largest realistic case at ~99 B; OnOff + LevelControl +/// scenes are ~16 B. `128` covers the realistic worst case for the +/// clusters Phase B.2 / C will register, with the cost paid per scene +/// (so `N * MAX_EXT_FIELDS_LEN` RAM total). +pub const MAX_EXT_FIELDS_LEN: usize = 128; + +/// Per-cluster scene capture + apply trait. +/// +/// Implemented (typically as a zero-sized type) alongside each +/// scene-able cluster's handler — see +/// [`crate::dm::clusters::app::on_off::OnOffSceneClusterHandler`] etc. +/// The user composes a tuple of these and registers it with +/// [`ScenesHandler::new`]; the Scenes handler delegates the per-cluster +/// work via the [`SceneClusters`] tuple-recursive dispatch. +/// +/// **Invariant**: all cross-cluster I/O goes through +/// `ctx.handler().{read, write, invoke}` — the Scenes handler has no +/// direct reference to other cluster handlers, only the routing layer +/// does. +pub trait SceneClusterHandler { + /// The Matter cluster ID this impl handles. Used by [`SceneClusters`] + /// to route apply dispatch. + const CLUSTER_ID: ClusterId; + + /// Read this cluster's scene-able attributes via + /// `sctx.read(...)` and emit zero-or-more + /// `AttributeValuePairStruct` elements into `avp_array` (use + /// [`AttributeValuePairStructArrayBuilder::push_u8`] / + /// [`AttributeValuePairStructArrayBuilder::push_u16`] / etc. for a + /// one-line per-attribute API). + /// + /// Returns the (advanced) builder so the caller can close the array. + fn capture( + &self, + sctx: &SceneContext, + endpoint_id: EndptId, + avp_array: AttributeValuePairStructArrayBuilder

, + ) -> impl Future, Error>> + where + C: InvokeContext, + P: TLVBuilderParent; + + /// Apply the captured attribute values by invoking the right + /// cluster commands (via `sctx.invoke(...)`) — or, for clusters + /// with writable scene-able attrs, by attribute writes. + /// `transition_time_ms` is the effective transition for this + /// recall (either the `RecallScene` request override or the stored + /// value). + fn apply( + &self, + sctx: &SceneContext, + endpoint_id: EndptId, + transition_time_ms: u32, + avp_list: &TLVArray<'_, AttributeValuePairStruct<'_>>, + ) -> impl Future> + where + C: InvokeContext; +} + +/// A tuple-recursive composition of [`SceneClusterHandler`]s, mirroring +/// the convention used by [`crate::dm::ChainedHandler`]. +/// +/// Terminated by `()`; one cluster registers as `(impl, ())`; multiple +/// register as `(a, (b, (c, ())))`. The macro-free spelling is +/// intentionally verbose for now — a `scene_clusters!` macro can be +/// layered on later. +pub trait SceneClusters { + /// Walk the registry, emitting one `ExtensionFieldSetStruct` per + /// cluster that is actually present on `endpoint_id`. + /// + /// `parent` is a raw [`TLVBuilderParent`] (e.g. wrapping a + /// [`crate::utils::storage::WriteBuf`] over the destination + /// buffer) — *not* an `ExtensionFieldSetStructArrayBuilder`. Each + /// cluster's EFS struct is emitted directly into the parent + /// (`start_struct(Anonymous) … end_container`), with no outer + /// `start_array` byte written. The caller is responsible for + /// writing the trailing `0x18` array terminator after this + /// returns. This keeps the captured wire form aligned with + /// [`SceneEntry::extension_fields`]'s "contents + 0x18" storage + /// shape without needing an extra `+ 1` byte to absorb a leading + /// control byte. + fn capture( + &self, + sctx: &SceneContext, + endpoint_id: EndptId, + parent: P, + ) -> impl Future> + where + C: InvokeContext, + P: TLVBuilderParent; + + /// Find the registered cluster matching `cluster_id` and let it + /// apply `avp_list`. Returns `Ok(true)` if a cluster handled it, + /// `Ok(false)` if no registered cluster matches (the entry is + /// silently skipped, matching chip's behavior). + fn apply( + &self, + sctx: &SceneContext, + endpoint_id: EndptId, + cluster_id: ClusterId, + transition_time_ms: u32, + avp_list: &TLVArray<'_, AttributeValuePairStruct<'_>>, + ) -> impl Future> + where + C: InvokeContext; +} + +impl SceneClusters for () { + fn capture( + &self, + _sctx: &SceneContext, + _endpoint_id: EndptId, + parent: P, + ) -> impl Future> + where + C: InvokeContext, + P: TLVBuilderParent, + { + ready(Ok(parent)) + } + + fn apply( + &self, + _sctx: &SceneContext, + _endpoint_id: EndptId, + _cluster_id: ClusterId, + _transition_time_ms: u32, + _avp_list: &TLVArray<'_, AttributeValuePairStruct<'_>>, + ) -> impl Future> + where + C: InvokeContext, + { + ready(Ok(false)) + } +} + +impl SceneClusters for (H, T) +where + H: SceneClusterHandler, + T: SceneClusters, +{ + async fn capture( + &self, + sctx: &SceneContext, + endpoint_id: EndptId, + parent: P, + ) -> Result + where + C: InvokeContext, + P: TLVBuilderParent, + { + let parent = if sctx.cluster_present(endpoint_id, H::CLUSTER_ID) { + // Open this cluster's ExtensionFieldSetStruct directly on + // the parent (no outer array wrapper), hand the inner + // AVP-array builder to the cluster impl, then close both + // containers and continue down the chain. + let efs = ExtensionFieldSetStructBuilder::new(parent, &TLVTag::Anonymous)?; + let efs = efs.cluster_id(H::CLUSTER_ID)?; + let avp_array = efs.attribute_value_list()?; + let avp_array = self.0.capture(sctx, endpoint_id, avp_array).await?; + let efs = avp_array.end()?; + efs.end()? + } else { + parent + }; + self.1.capture(sctx, endpoint_id, parent).await + } + + async fn apply( + &self, + sctx: &SceneContext, + endpoint_id: EndptId, + cluster_id: ClusterId, + transition_time_ms: u32, + avp_list: &TLVArray<'_, AttributeValuePairStruct<'_>>, + ) -> Result + where + C: InvokeContext, + { + if H::CLUSTER_ID == cluster_id { + self.0 + .apply(sctx, endpoint_id, transition_time_ms, avp_list) + .await?; + Ok(true) + } else { + self.1 + .apply(sctx, endpoint_id, cluster_id, transition_time_ms, avp_list) + .await + } + } +} + +// --------------------------------------------------------------------- +// SceneContext — wraps the active InvokeContext and gives per-cluster +// scene impls a small, focused API (`read`, `invoke`, `cluster_present`) +// instead of bare `ctx.handler().{read,invoke}` + raw +// `ReadContextInstance` / `InvokeContextInstance` plumbing. +// --------------------------------------------------------------------- + +/// Per-call context handed to [`SceneClusterHandler::capture`] and +/// [`SceneClusterHandler::apply`]. +/// +/// Wraps the live [`InvokeContext`] for the in-flight `StoreScene` / +/// `RecallScene` command and surfaces the operations a scene-able +/// cluster impl actually needs: +/// +/// - [`SceneContext::read`] — cross-cluster attribute read, decoded +/// as a `FromTLV` type. +/// - [`SceneContext::invoke`] — cross-cluster command dispatch with +/// the response discarded. +/// - [`SceneContext::cluster_present`] — metadata-driven check used +/// by the tuple recursion to skip clusters not installed on the +/// host endpoint. +/// +/// All three go through the global handler (`ctx.handler()`), matching +/// the invariant noted on [`SceneClusterHandler`]. +pub struct SceneContext(C); + +impl SceneContext { + pub const fn new(ctx: C) -> Self { + Self(ctx) + } + + /// The wrapped [`InvokeContext`]. Useful when a cluster impl needs + /// something outside the small scene-focused surface (e.g. + /// `notify_attr_changed`, `set_cluster_status`). + /// + /// Construction takes `C` by value; callers typically pass a + /// reference (e.g. `SceneContext::new(ctx)` where `ctx: &impl + /// InvokeContext`) — `&InvokeContext: InvokeContext` via the + /// blanket impl, so the `'a` lifetime is folded into `C` itself. + pub const fn ctx(&self) -> &C { + &self.0 + } + + /// Read one attribute via the global handler and decode it as + /// `T`. + /// + /// Drives [`AsyncHandler::read`] with a custom reply that + /// captures the value bytes (TLV-encoded with anonymous tag) into + /// a stack buffer, then decodes them as `T` via `FromTLV`. The + /// `T: for<'b> FromTLV<'b>` bound restricts use to types that + /// don't borrow from the TLV bytes (primitives, `Nullable`, + /// enums, …) — which covers all scalar-valued attributes scene + /// capture cares about. + pub async fn read( + &self, + endpoint_id: EndptId, + cluster_id: ClusterId, + attr_id: AttrId, + ) -> Result + where + T: for<'b> FromTLV<'b>, + { + let mut buf = [0u8; 16]; + let mut wb = WriteBuf::new(&mut buf); + + let attr = AttrDetails { + endpoint_id, + cluster_id, + attr_id, + list_index: None, + list_chunked: false, + // Fabric-scoped attrs are not in the spec'd scene-able set, + // but pass the accessor's fabric in case a future scene-able + // attribute is fabric-scoped. + fab_idx: self.0.exchange().accessor()?.fab_idx()?.get(), + fab_filter: false, + dataver: None, + wildcard: false, + array: false, + cluster_status: Cell::new(0), + }; + + let handler = self.0.handler(); + let read_ctx = ReadContextInstance::new(self.0.exchange(), &self.0, &attr); + let reply = CaptureReply { wb: &mut wb }; + handler.read(read_ctx, reply).await?; + + T::from_tlv(&TLVElement::new(wb.as_slice())) + } + + /// Dispatch a cross-cluster command through `ctx.handler().invoke()`. + /// The command reply is captured into a small stack buffer and + /// discarded — most cluster-apply paths only care about + /// success/failure, not the echoed `DefaultSuccess` payload. + /// + /// `data` must be a complete TLV-encoded command request struct + /// (anonymous-tagged), or empty for commands with no payload + /// (`On`, `Off`, `Toggle`). + pub async fn invoke( + &self, + endpoint_id: EndptId, + cluster_id: ClusterId, + cmd_id: CmdId, + data: &[u8], + ) -> Result<(), Error> { + let fab_idx = self.0.exchange().accessor()?.fab_idx()?.get(); + let cmd = CmdDetails::new(endpoint_id, cluster_id, cmd_id, fab_idx, false, None); + let data_elem = TLVElement::new(data); + + // 64 B is plenty for a `DefaultSuccess` reply (anonymous outer + // struct + cmd-resp struct + path). + let mut response_buf = [0u8; 64]; + let mut response_wb = WriteBuf::new(&mut response_buf); + let reply = InvokeReplyInstance::new(&cmd, &mut response_wb); + + let handler = self.0.handler(); + let inv_ctx = InvokeContextInstance::new(self.0.exchange(), &self.0, &cmd, &data_elem); + handler.invoke(inv_ctx, reply).await + } + + /// Check whether `cluster_id` is exposed on `endpoint_id` per the + /// node metadata. Used by the [`SceneClusters`] tuple recursion + /// to skip scene-able cluster impls that the host endpoint + /// doesn't actually install — and available to cluster impls that + /// want to do the same check (e.g. for sibling-cluster + /// dependencies). + pub fn cluster_present(&self, endpoint_id: EndptId, cluster_id: ClusterId) -> bool { + self.0.metadata().access(|node| { + node.endpoint(endpoint_id) + .and_then(|ep| ep.cluster(cluster_id)) + .is_some() + }) + } +} + +// --------------------------------------------------------------------- +// Builder ergonomics — push_u8 / push_u16 etc. on the codegen'd AVP +// array builder so capture impls read as +// `avp_array.push_u8(attr_id, v)?` instead of carrying an external +// helper. Inherent impls are legal cross-module because the type is in +// the same crate (rs-matter), generated from the Scenes IDL. +// --------------------------------------------------------------------- + +impl

AttributeValuePairStructArrayBuilder

+where + P: TLVBuilderParent, +{ + /// Push one `AttributeValuePairStruct { attributeID, + /// valueUnsigned8 }` element. Wraps the codegen builder's 9-state + /// push chain so callers don't have to spell out 8 + /// `value_*(None)?` hops manually. + pub fn push_u8(self, attr_id: AttrId, value: u8) -> Result { + self.push()? + .attribute_id(attr_id)? + .value_unsigned_8(Some(value))? + .value_signed_8(None)? + .value_unsigned_16(None)? + .value_signed_16(None)? + .value_unsigned_32(None)? + .value_signed_32(None)? + .value_unsigned_64(None)? + .value_signed_64(None)? + .end() + } + + /// Push one `AttributeValuePairStruct { attributeID, + /// valueUnsigned16 }` element. + pub fn push_u16(self, attr_id: AttrId, value: u16) -> Result { + self.push()? + .attribute_id(attr_id)? + .value_unsigned_8(None)? + .value_signed_8(None)? + .value_unsigned_16(Some(value))? + .value_signed_16(None)? + .value_unsigned_32(None)? + .value_signed_32(None)? + .value_unsigned_64(None)? + .value_signed_64(None)? + .end() + } +} + +/// One scene record. Stores both the metadata (group/scene/transition) +/// and the wire-form `ExtensionFieldSetStructs` blob captured on +/// `AddScene` / `StoreScene` and replayed on `ViewScene` / +/// `RecallScene` / `CopyScene`. +/// +/// `M` is the per-scene blob capacity (see [`MAX_EXT_FIELDS_LEN`] for +/// the default rationale). #[derive(Debug)] #[cfg_attr(feature = "defmt", derive(defmt::Format))] -pub struct SceneEntry { +pub struct SceneEntry { /// Fabric index that owns this scene (spec reserves `0` for /// "no fabric" / PASE; an installed fabric is always non-zero). fab_idx: NonZeroU8, @@ -105,9 +494,18 @@ pub struct SceneEntry { scene_id: SceneId, /// Transition time encoded per spec (1/10 s units). transition_time: u32, + /// Serialized `ExtensionFieldSetStructs` array payload — what the + /// controller passed on `AddScene` (or what `StoreScene` + /// captured). Stored as the array container's *value* bytes (the + /// TLV element payload between the start-array control byte and + /// the end-of-container terminator; see + /// [`crate::tlv::TLVElement::raw_value`]). On `ViewScene` we + /// splice it back out at the response tag. Empty ⇒ no captured + /// fields (echoed as absent). + extension_fields: Vec, } -impl SceneEntry { +impl SceneEntry { fn matches( &self, fab_idx: NonZeroU8, @@ -120,6 +518,33 @@ impl SceneEntry { && self.group_id == group_id && self.scene_id == scene_id } + + /// In-place initializer used by [`super::ScenesHandler::upsert_scene`] to + /// stamp a fresh row directly into the slot inside the scene + /// table — avoiding the `M`-byte stack copy that + /// `extension_fields: Vec` would otherwise incur if + /// `SceneEntry` were constructed by value first. + /// + /// The `extension_fields` Vec is initialized empty; the caller of + /// [`super::ScenesHandler::upsert_scene`] supplies a closure that + /// fills it in place (typically by `extend_from_slice` from a + /// caller-owned slice). + fn init( + fab_idx: NonZeroU8, + endpoint_id: EndptId, + group_id: u16, + scene_id: SceneId, + transition_time: u32, + ) -> impl Init { + init!(Self { + fab_idx, + endpoint_id, + group_id, + scene_id, + transition_time, + extension_fields <- Vec::init(), + }) + } } /// Per-fabric "last recalled scene" pointer feeding @@ -136,9 +561,9 @@ struct CurrentScene { /// [`ScenesState`]. Grouped so the cluster handler takes exactly one /// lock per operation — mirrors the `OnOffState` / `Mutex>` /// shape used elsewhere in `rs-matter`. -struct ScenesStateInner { +struct ScenesStateInner { /// The scene table, keyed by `(fab_idx, endpoint_id, group_id, scene_id)`. - table: Vec, + table: Vec, N>, /// Bounded by `N` for storage symmetry; in practice one slot per /// fabric. Absent for a given `fab_idx` ⇒ `SceneValid = false`. current_per_fabric: Vec, @@ -146,7 +571,7 @@ struct ScenesStateInner { info_dataver: u32, } -impl ScenesStateInner { +impl ScenesStateInner { const fn new() -> Self { Self { table: Vec::new(), @@ -171,16 +596,24 @@ impl ScenesStateInner { } } -/// Caller-owned per-device Scenes state. Capacity is the const -/// generic `N`. Shared across all endpoints that expose the cluster. +/// Caller-owned per-device Scenes state. +/// +/// Const generics: +/// - `N` — scene-table capacity (rows across all fabrics + endpoints). +/// - `M` — per-scene `ExtensionFieldSetStructs` blob capacity in +/// bytes. Defaults to [`MAX_EXT_FIELDS_LEN`] (128). Bump it when +/// you wire ColorControl into a multi-feature deployment whose +/// captured EFS exceeds the default budget. Total static RAM for +/// the scene table is `N * (M + small overhead)`. /// -/// Internally a single [`Mutex`] over a [`RefCell`] — every handler -/// operation takes one lock and mutates the inner table directly. -pub struct ScenesState { - inner: Mutex>>, +/// Shared across all endpoints that expose the cluster. Internally +/// a single [`Mutex`] over a [`RefCell`] — every handler operation +/// takes one lock and mutates the inner table directly. +pub struct ScenesState { + inner: Mutex>>, } -impl ScenesState { +impl ScenesState { pub const fn new() -> Self { Self { inner: Mutex::new(RefCell::new(ScenesStateInner::new())), @@ -197,7 +630,7 @@ impl ScenesState { /// Take the lock and run `f` against the mutable inner state. fn with(&self, f: F) -> R where - F: FnOnce(&mut ScenesStateInner) -> R, + F: FnOnce(&mut ScenesStateInner) -> R, { self.inner.lock(|cell| { let mut inner = cell.borrow_mut(); @@ -206,24 +639,54 @@ impl ScenesState { } } -impl Default for ScenesState { +impl Default for ScenesState { fn default() -> Self { Self::new() } } -/// Scenes Management cluster handler. Implements the **async** -/// codegen trait so the v2 store/recall paths can `.await` -/// cross-cluster reads/writes via `ctx.handler()` without a trait- -/// shape migration. -pub struct ScenesHandler<'a, const N: usize> { +/// Scenes Management cluster handler. +/// +/// Generic over a tuple-recursive registry `R: SceneClusters` that +/// names which application-level clusters participate in scene +/// capture / recall on this device. Construct as: +/// +/// ```ignore +/// use rs_matter::dm::clusters::app::on_off::OnOffSceneClusterHandler; +/// use rs_matter::dm::clusters::app::level_control::LevelControlSceneClusterHandler; +/// +/// let scenes = ScenesHandler::new( +/// dataver, +/// &scenes_state, +/// (OnOffSceneClusterHandler, (LevelControlSceneClusterHandler, ())), +/// ); +/// ``` +/// +/// The default `R = ()` constructs a Scenes handler with **no** +/// scene-able clusters — useful for tests / certification of the +/// table-management commands (Add/View/Remove/RemoveAll/GetSceneMembership/ +/// CopyScene) in isolation. `M` mirrors the same parameter on +/// [`ScenesState`] (per-scene blob capacity, defaults to +/// [`MAX_EXT_FIELDS_LEN`]). +pub struct ScenesHandler<'a, const N: usize, R = (), const M: usize = MAX_EXT_FIELDS_LEN> +where + R: SceneClusters, +{ dataver: Dataver, - state: &'a ScenesState, + state: &'a ScenesState, + clusters: R, } -impl<'a, const N: usize> ScenesHandler<'a, N> { - pub const fn new(dataver: Dataver, state: &'a ScenesState) -> Self { - Self { dataver, state } +impl<'a, const N: usize, R, const M: usize> ScenesHandler<'a, N, R, M> +where + R: SceneClusters, +{ + pub const fn new(dataver: Dataver, state: &'a ScenesState, clusters: R) -> Self { + Self { + dataver, + state, + clusters, + } } pub const fn adapt(self) -> HandlerAsyncAdaptor { @@ -238,7 +701,7 @@ impl<'a, const N: usize> ScenesHandler<'a, N> { /// fabric. Bumps `FabricSceneInfo` dataver. Operates on already- /// locked inner state. fn remember_current( - inner: &mut ScenesStateInner, + inner: &mut ScenesStateInner, fab_idx: NonZeroU8, group_id: u16, scene_id: SceneId, @@ -268,7 +731,7 @@ impl<'a, const N: usize> ScenesHandler<'a, N> { /// the previously-recalled scene no longer represent the current /// attribute state (per the `SceneValid` field rules in the spec). /// Operates on already-locked inner state. - fn invalidate_current(inner: &mut ScenesStateInner, fab_idx: NonZeroU8) { + fn invalidate_current(inner: &mut ScenesStateInner, fab_idx: NonZeroU8) { inner.current_per_fabric.retain(|c| c.fab_idx != fab_idx); inner.bump_info_dataver(); } @@ -276,7 +739,7 @@ impl<'a, const N: usize> ScenesHandler<'a, N> { /// Internal copy helper — runs against an already-locked /// [`ScenesStateInner`]. Returns the IM status code (0 on success). /// - /// In-place index-walk: no scratch buffer. `heapless::Vec::push` + /// In-place index-walk: no scratch buffer. `Vec::push` /// always appends at the end, so an upsert that has to push a new /// destination row lands at an index strictly greater than the /// current source index — earlier-index iteration stays valid. @@ -286,7 +749,7 @@ impl<'a, const N: usize> ScenesHandler<'a, N> { /// cluster carries. #[allow(clippy::too_many_arguments)] fn copy_scenes_inner( - inner: &mut ScenesStateInner, + inner: &mut ScenesStateInner, fab_idx: NonZeroU8, endpoint_id: EndptId, group_from: u16, @@ -305,10 +768,13 @@ impl<'a, const N: usize> ScenesHandler<'a, N> { && (copy_all || src.scene_id == scene_from); if src_matches { found_source = true; - // Copy the scalars out so we can re-borrow the table - // mutably for the upsert. + // Copy the scalars + clone the extension-fields blob + // out so we can re-borrow the table mutably for the + // upsert. The clone is a `MAX_EXT_FIELDS_LEN`-sized + // stack value (~128 B), released after each iteration. let src_scene_id = src.scene_id; let src_transition_time = src.transition_time; + let src_extension_fields = src.extension_fields.clone(); let target_scene_id = if copy_all { src_scene_id } else { scene_to }; // Upsert into (fab, ep, group_to, target_scene_id). @@ -318,6 +784,7 @@ impl<'a, const N: usize> ScenesHandler<'a, N> { .position(|e| e.matches(fab_idx, endpoint_id, group_to, target_scene_id)) { inner.table[pos].transition_time = src_transition_time; + inner.table[pos].extension_fields = src_extension_fields; } else if inner .table .push(SceneEntry { @@ -326,6 +793,7 @@ impl<'a, const N: usize> ScenesHandler<'a, N> { group_id: group_to, scene_id: target_scene_id, transition_time: src_transition_time, + extension_fields: src_extension_fields, }) .is_err() { @@ -352,18 +820,23 @@ impl<'a, const N: usize> ScenesHandler<'a, N> { } // ----------------------------------------------------------------- - // Synchronous handler bodies. + // Handler bodies. // // The trait-required methods in the `ClusterAsyncHandler` impl - // below are tiny `fn -> impl Future` wrappers that delegate here - // via `ready(self.foo_sync(...))`. Keeping the real logic + // below are tiny `fn -> impl Future` wrappers that delegate to + // these via `ready(self.foo(...))`. Keeping the real logic // synchronous (a) lets us use `?` freely, (b) skips the // `async fn` state-machine codegen, and (c) avoids closure // captures in the wrappers. Three small wins that add up on // flash-constrained targets. + // + // `store_scene` is the one exception — it actually `.await`s + // because it issues cross-cluster attribute reads through + // `ctx.handler().read()`. The wrapper for that one just `.await`s + // this method directly. // ----------------------------------------------------------------- - fn fabric_scene_info_sync( + fn read_fabric_scene_info( &self, ctx: &impl ReadContext, builder: ArrayAttributeRead, SceneInfoStructBuilder

>, @@ -415,7 +888,7 @@ impl<'a, const N: usize> ScenesHandler<'a, N> { } } - fn handle_add_scene_sync( + fn add_scene( &self, ctx: &impl InvokeContext, request: &AddSceneRequest<'_>, @@ -427,40 +900,49 @@ impl<'a, const N: usize> ScenesHandler<'a, N> { let scene_id = request.scene_id()?; let transition_time = request.transition_time()?; - // v1 discards the `ExtensionFieldSetStructs` payload; v2 will - // capture it so `RecallScene` can replay it. Scene names are - // accepted on the wire (codegen parses them) but not stored. + // Capture the `ExtensionFieldSetStructs` array payload from the + // request — store the *value* bytes (contents-plus-terminator + // of the array container), which is what `ViewScene` and + // `CopyScene` will splice back out at the relevant response + // tag. The codegen parser at context-tag 4 errors if the field + // is missing; tolerate that by treating it as an empty blob. + // Scene names are accepted on the wire (codegen parses them) + // but not stored — see the SceneNames feature note in the + // module docs. + // + // `upsert_scene`'s fill closure copies the request's raw EFS + // bytes directly into the table slot's `extension_fields` + // Vec, skipping an intermediate stack-allocated Vec. + let raw = match request.extension_field_set_structs() { + Ok(array) => array.element().raw_value()?, + Err(_) => &[], + }; // Insert / replace + invalidate SceneValid for this fabric — all // under a single lock. Per the `SceneValid` field rules, // adding/storing a scene that doesn't match the current // attribute state invalidates SceneValid. - let status_code: u8 = self.state.with(|inner| { - if let Some(pos) = inner - .table - .iter() - .position(|e| e.matches(fab_idx, endpoint_id, group_id, scene_id)) - { - inner.table[pos].transition_time = transition_time; - Self::invalidate_current(inner, fab_idx); - 0 - } else if inner.table.len() >= N { - SC_INSUFFICIENT_SPACE - } else { - let _ = inner.table.push(SceneEntry { - fab_idx, - endpoint_id, - group_id, - scene_id, - transition_time, - }); - Self::invalidate_current(inner, fab_idx); - 0 - } - }); + let status_code = self.state.with(|inner| { + Self::upsert_scene( + inner, + fab_idx, + endpoint_id, + group_id, + scene_id, + transition_time, + |ext_fields| { + if !raw.is_empty() { + ext_fields + .extend_from_slice(raw) + .map_err(|_| ErrorCode::NoSpace)?; + } + Ok(()) + }, + ) + })?; if status_code == 0 { - self.dataver_changed(); + ctx.notify_own_attr_changed(AttributeId::FabricSceneInfo as _); } response @@ -470,7 +952,82 @@ impl<'a, const N: usize> ScenesHandler<'a, N> { .end() } - fn handle_view_scene_sync( + /// Insert (or replace) one scene record and invalidate the fabric's + /// `CurrentScene` slot. Returns `Ok(0)` on success, or + /// `Ok(SC_INSUFFICIENT_SPACE)` when adding a *new* record would + /// overflow `N`. Errors from `fill` propagate to the caller. + /// + /// `fill` is handed a `&mut` reference to the **in-place** + /// `extension_fields` `Vec` inside the (newly-created or + /// to-be-replaced) `SceneEntry`. This lets callers populate the + /// blob directly into the table slot, skipping the 128 B stack + /// `Vec` an intermediate by-value parameter would have required. + /// + /// Used by both `AddScene` (closure copies the controller-provided + /// EFS payload) and `StoreScene` (closure copies the + /// just-captured-from-attributes EFS payload). + /// + /// **Atomicity caveat**: on the replace-existing path, the slot's + /// previous `extension_fields` are cleared *before* `fill` runs. + /// If `fill` then errors, the slot is left with an empty blob. + /// Spec doesn't mandate atomicity here, and in-tree callers' fill + /// closures are `extend_from_slice` calls — all-or-nothing per + /// [`Vec::extend_from_slice`] — so partial state never + /// occurs in practice. + fn upsert_scene( + inner: &mut ScenesStateInner, + fab_idx: NonZeroU8, + endpoint_id: EndptId, + group_id: u16, + scene_id: SceneId, + transition_time: u32, + fill: F, + ) -> Result + where + F: FnOnce(&mut Vec) -> Result<(), Error>, + { + if let Some(pos) = inner + .table + .iter() + .position(|e| e.matches(fab_idx, endpoint_id, group_id, scene_id)) + { + // Mutate the existing slot in place. + inner.table[pos].transition_time = transition_time; + inner.table[pos].extension_fields.clear(); + fill(&mut inner.table[pos].extension_fields)?; + Self::invalidate_current(inner, fab_idx); + Ok(0) + } else if inner.table.len() >= N { + Ok(SC_INSUFFICIENT_SPACE) + } else { + // Push an empty entry in place, then let the closure fill + // its `extension_fields` directly. `push_init_unchecked` + // is safe (it only panics when full, and we just checked + // `len < N`); the `Result<(), Infallible>` always + // unwraps. + inner + .table + .push_init_unchecked(SceneEntry::init( + fab_idx, + endpoint_id, + group_id, + scene_id, + transition_time, + )) + .unwrap(); + let pos = inner.table.len() - 1; + if let Err(e) = fill(&mut inner.table[pos].extension_fields) { + // Roll back the just-pushed entry so the table is + // pre-call state. + let _ = inner.table.pop(); + return Err(e); + } + Self::invalidate_current(inner, fab_idx); + Ok(0) + } + } + + fn view_scene( &self, ctx: &impl InvokeContext, request: &ViewSceneRequest<'_>, @@ -481,43 +1038,83 @@ impl<'a, const N: usize> ScenesHandler<'a, N> { let group_id = request.group_id()?; let scene_id = request.scene_id()?; - let transition_time = self.state.with(|inner| { - inner + // Build the response *inside* the lock so we can splice the + // stored `extension_fields` blob (a `&[u8]` borrow into the + // table) without cloning it onto the stack. The TLV builder + // chain is purely synchronous (no `.await`), so holding the + // mutex across the write is fine. + // + // Wire shape is (status, group_id, scene_id, optional + // transition_time, optional scene_name, optional extension + // fields). On NotFound all three optionals are absent; on + // Success transition_time is populated, scene name is empty + // (SceneNames feature disabled), and extension_field_set_structs + // gets the stored blob (or absent if none was supplied). + self.state.with(|inner| -> Result { + let entry = inner .table .iter() - .find(|e| e.matches(fab_idx, endpoint_id, group_id, scene_id)) - .map(|e| e.transition_time) - }); + .find(|e| e.matches(fab_idx, endpoint_id, group_id, scene_id)); + + let Some(e) = entry else { + return response + .status(SC_NOT_FOUND)? + .group_id(group_id)? + .scene_id(scene_id)? + .transition_time(None)? + .scene_name(None)? + .extension_field_set_structs()? + .none() + .end(); + }; - // The wire shape is (status, group_id, scene_id, optional - // transition_time, optional scene_name, optional extension - // fields). All three optional fields are emitted as absent on - // NotFound; on Success, transition_time is populated, scene - // name is empty (SceneNames disabled), extension fields are - // absent (v2 will fill these from a stored blob). - match transition_time { - Some(tt) => response + let opt = response .status(0)? .group_id(group_id)? .scene_id(scene_id)? - .transition_time(Some(tt))? + .transition_time(Some(e.transition_time))? .scene_name(Some(""))? - .extension_field_set_structs()? - .none() - .end(), - None => response - .status(SC_NOT_FOUND)? - .group_id(group_id)? - .scene_id(scene_id)? - .transition_time(None)? - .scene_name(None)? - .extension_field_set_structs()? - .none() - .end(), + .extension_field_set_structs()?; + + Self::write_blob_or_none(opt, &e.extension_fields)?.end() + }) + } + + /// Splice the stored extension-fields blob into the response at + /// the optional field's tag (context 5 for both `ViewScene` and + /// `AddScene` request — same tag number, different wire role). + /// + /// The blob is the array container's *value* bytes + /// (contents-plus-terminator). We emit a fresh `start_array` at + /// the destination tag and then write the stored bytes via + /// `TLVWrite::write_raw_data`. Empty blob ⇒ skip the field + /// entirely via `OptionalBuilder::none`. + fn write_blob_or_none( + mut opt: crate::tlv::OptionalBuilder, + blob: &[u8], + ) -> Result + where + P: TLVBuilderParent, + T: crate::tlv::TLVBuilder

, + { + use crate::tlv::{TLVTag, TLVWrite}; + if !blob.is_empty() { + // Tag is hard-coded as the spec field number for both + // `ViewSceneResponse.ExtensionFieldSetStructs` and other + // current call sites; if other tag positions reuse this + // helper, take the tag as an explicit argument. + let writer = opt.writer(); + writer.start_array(&TLVTag::Context(5))?; + writer.write_raw_data(blob.iter().copied())?; } + // `none()` returns the parent without further writes. When the + // blob was non-empty we already emitted the field via the + // writer; when empty we skip the field entirely. Either way + // the surrounding response is well-formed. + Ok(opt.none()) } - fn handle_remove_scene_sync( + fn remove_scene( &self, ctx: &impl InvokeContext, request: &RemoveSceneRequest<'_>, @@ -543,7 +1140,7 @@ impl<'a, const N: usize> ScenesHandler<'a, N> { }); if status == 0 { - self.dataver_changed(); + ctx.notify_own_attr_changed(AttributeId::FabricSceneInfo as _); } response @@ -553,7 +1150,7 @@ impl<'a, const N: usize> ScenesHandler<'a, N> { .end() } - fn handle_remove_all_scenes_sync( + fn remove_all_scenes( &self, ctx: &impl InvokeContext, request: &RemoveAllScenesRequest<'_>, @@ -576,62 +1173,181 @@ impl<'a, const N: usize> ScenesHandler<'a, N> { }); if removed { - self.dataver_changed(); + ctx.notify_own_attr_changed(AttributeId::FabricSceneInfo as _); } response.status(0)?.group_id(group_id)?.end() } - fn handle_store_scene_sync( + /// `StoreScene` capture + commit. + /// + /// One of two [`ClusterAsyncHandler`] entry points that actually + /// `.await` — it issues cross-cluster attribute reads through + /// `ctx.handler().read()` for every registered + /// [`SceneClusterHandler`] that is present on the host endpoint. + /// The captured `ExtensionFieldSetStructs` blob is built up on a + /// stack buffer (no per-attribute heap allocation, and no IO while + /// the scene-table mutex is held). Only the final upsert briefly + /// acquires the mutex. + async fn store_scene( &self, + ctx: &impl InvokeContext, request: &StoreSceneRequest<'_>, response: StoreSceneResponseBuilder

, ) -> Result { - // v2: snapshot scene-able attributes on this endpoint via - // `ctx.handler()` and store as ExtensionFieldSetStructs. + let fab_idx = Self::fab_idx(ctx)?; + let endpoint_id = ctx.cmd().endpoint_id; let group_id = request.group_id()?; let scene_id = request.scene_id()?; + + // Capture the EFS blob on the stack via the cluster registry. + // Doing this *before* the mutex acquire keeps async IO + // (`ctx.handler().read()`) out of the critical section. + // + // [`SceneClusters::capture`] writes EFS struct entries + // directly into the parent (no outer `start_array` byte); we + // append the trailing `0x18` ourselves. The result is exactly + // the "contents + 0x18 terminator" shape that + // [`SceneEntry::extension_fields`] stores — no leading byte + // to strip, no `MAX_EXT_FIELDS_LEN + 1` slack needed. + let sctx = SceneContext::new(ctx); + let mut scratch = [0u8; M]; + let total_len = { + let mut wb = WriteBuf::new(&mut scratch); + let parent = TLVWriteParent::new("StoreScene EFS", &mut wb); + let _ = self.clusters.capture(&sctx, endpoint_id, parent).await?; + wb.end_container()?; + wb.get_tail() + }; + let stored_bytes = &scratch[..total_len]; + + // StoreScene reuses AddScene's transition time when overwriting + // an existing record (spec: "If a Scene Table entry with the + // same Scene ID exists, all the fields of the entry shall be + // updated…"). For a fresh record the transition time defaults + // to 0 — the spec leaves the field implementation-defined for + // StoreScene, and chip's reference handler does the same. + let prior_tt = self.state.with(|inner| { + inner + .table + .iter() + .find(|e| e.matches(fab_idx, endpoint_id, group_id, scene_id)) + .map(|e| e.transition_time) + }); + let transition_time = prior_tt.unwrap_or(0); + + let status_code = self.state.with(|inner| { + Self::upsert_scene( + inner, + fab_idx, + endpoint_id, + group_id, + scene_id, + transition_time, + |ext_fields| { + if !stored_bytes.is_empty() { + ext_fields + .extend_from_slice(stored_bytes) + .map_err(|_| ErrorCode::NoSpace)?; + } + Ok(()) + }, + ) + })?; + + if status_code == 0 { + ctx.notify_own_attr_changed(AttributeId::FabricSceneInfo as _); + } + response - .status(IMStatusCode::Failure as u8)? + .status(status_code)? .group_id(group_id)? .scene_id(scene_id)? .end() } - fn handle_recall_scene_sync( + /// `RecallScene` parse + apply. + /// + /// Flow: + /// 1. Look up the stored `(transition_time, ext_fields)` snapshot + /// under the mutex; release the mutex. + /// 2. Walk the EFS blob and let the cluster registry apply each + /// `ExtensionFieldSetStruct` entry (see + /// [`SceneClusters::apply`]). + /// 3. Only after apply succeeds, commit `CurrentScene` for this + /// fabric (acquiring the mutex again briefly). + async fn recall_scene( &self, ctx: &impl InvokeContext, request: &RecallSceneRequest<'_>, ) -> Result<(), Error> { - // v1 stub: succeeds for known (group, scene) — bumps - // CurrentScene — but doesn't actually apply attribute writes. - // v2 will parse the stored ExtensionFieldSetStructs and apply - // each attribute write via `ctx.handler()`. let fab_idx = Self::fab_idx(ctx)?; let endpoint_id = ctx.cmd().endpoint_id; let group_id = request.group_id()?; let scene_id = request.scene_id()?; - let found = self.state.with(|inner| { - let f = inner + // RecallScene's request carries an optional+nullable + // transition-time override (ms). Present-and-non-null wins + // over the stored record's transition time; otherwise fall + // back to the stored value. + let override_tt_ms: Option = request.transition_time()?.and_then(|n| n.into_option()); + + // Copy the stored EFS blob into a stack buffer under the lock, + // then drop the lock so the cross-cluster invokes below don't + // run while it's held. + // + // The stored form is "ExtensionFieldSetStruct elements + 0x18 + // terminator" (i.e. what `TLVElement::array().raw_value()` + // returns — see [`SceneEntry`] and `view_scene`). We iterate + // it via [`TLVSequence`] rather than re-attaching the missing + // `start_array(Anonymous)` byte: `TLVSequence` walks raw TLV + // bytes directly and terminates cleanly on the trailing + // `0x18`, so no framing buffer is needed. + let mut blob = [0u8; M]; + let (blob_len, stored_tt_ms) = self.state.with(|inner| -> Result<_, Error> { + let Some(e) = inner .table .iter() - .any(|e| e.matches(fab_idx, endpoint_id, group_id, scene_id)); - if f { - Self::remember_current(inner, fab_idx, group_id, scene_id); - } - f - }); - - if !found { + .find(|e| e.matches(fab_idx, endpoint_id, group_id, scene_id)) + else { + return Ok((None, None)); + }; + let len = e.extension_fields.len(); + blob[..len].copy_from_slice(&e.extension_fields); + Ok((Some(len), Some(e.transition_time))) + })?; + let (Some(blob_len), Some(stored_tt_ms)) = (blob_len, stored_tt_ms) else { + // Spec: NotFound when no matching scene exists. The codegen + // turns the IM error into a NotFound status response. + ctx.cmd().set_cluster_status(SC_NOT_FOUND); return Err(ErrorCode::Failure.into()); + }; + + let effective_tt_ms = override_tt_ms.unwrap_or(stored_tt_ms); + + let sctx = SceneContext::new(ctx); + for efs_element in crate::tlv::TLVSequence(&blob[..blob_len]).iter() { + let efs = ExtensionFieldSetStruct::new(efs_element?); + let cluster_id = efs.cluster_id()?; + let avp_list = efs.attribute_value_list()?; + // `apply` returns `false` for unknown cluster IDs — match + // chip's behaviour and silently skip them (the blob may + // have been written by a previous firmware version with a + // different scene-able cluster set). + let _ = self + .clusters + .apply(&sctx, endpoint_id, cluster_id, effective_tt_ms, &avp_list) + .await?; } - self.dataver_changed(); + self.state + .with(|inner| Self::remember_current(inner, fab_idx, group_id, scene_id)); + + ctx.notify_own_attr_changed(AttributeId::FabricSceneInfo as _); Ok(()) } - fn handle_get_scene_membership_sync( + fn get_scene_membership( &self, ctx: &impl InvokeContext, request: &GetSceneMembershipRequest<'_>, @@ -676,7 +1392,7 @@ impl<'a, const N: usize> ScenesHandler<'a, N> { }) } - fn handle_copy_scene_sync( + fn copy_scene( &self, ctx: &impl InvokeContext, request: &CopySceneRequest<'_>, @@ -711,7 +1427,7 @@ impl<'a, const N: usize> ScenesHandler<'a, N> { }); if status == 0 { - self.dataver_changed(); + ctx.notify_own_attr_changed(AttributeId::FabricSceneInfo as _); } response @@ -722,7 +1438,64 @@ impl<'a, const N: usize> ScenesHandler<'a, N> { } } -impl ClusterAsyncHandler for ScenesHandler<'_, N> { +// --------------------------------------------------------------------- +// Cross-cluster read plumbing for StoreScene. +// +// `CaptureReply` is a minimal [`ReadReply`] that *only* records the +// attribute value bytes (TLV-encoded with [`TagType::Anonymous`]) into +// a caller-provided [`WriteBuf`]. We deliberately bypass the standard +// `AttrResp::Data` framing (dataver + path + data) used by +// `ReadReplyInstance`, because StoreScene's capture path doesn't need +// any of it — it would just have to be re-parsed back out. +// +// The codegen for an attribute read produces: +// reply.with_dataver(self.dataver())? +// .and_then(|writer| Reply::set(writer, value)) +// `with_dataver` here ignores the dataver entirely (we always want the +// current value) and `Reply::set` writes the value at TAG = Anonymous. +// --------------------------------------------------------------------- + +/// See module-level comment block above. +struct CaptureReply<'b, 'wb> { + wb: &'b mut WriteBuf<'wb>, +} + +impl<'b, 'wb> ReadReply for CaptureReply<'b, 'wb> { + fn with_dataver(self, _dataver: u32) -> Result, Error> { + Ok(Some(CaptureReplyWriter { wb: self.wb })) + } +} + +struct CaptureReplyWriter<'b, 'wb> { + wb: &'b mut WriteBuf<'wb>, +} + +impl Reply for CaptureReplyWriter<'_, '_> { + const TAG: TagType = TagType::Anonymous; + + fn set(self, value: T) -> Result<(), Error> { + value.to_tlv(&Self::TAG, self.wb) + } + + fn reset(&mut self) { + // No-op: the codegen-driven attribute read path calls + // `Reply::set` exactly once per attribute, so a partial-write + // rewind is never needed here. + } + + fn writer(&mut self) -> impl TLVWrite + Send + '_ { + &mut *self.wb + } + + fn complete(self) -> Result<(), Error> { + Ok(()) + } +} + +impl ClusterAsyncHandler for ScenesHandler<'_, N, R, M> +where + R: SceneClusters, +{ /// FULL_CLUSTER minus the SceneNames feature (we accept the field /// on the wire but don't persist it — see module docs). const CLUSTER: Cluster<'static> = FULL_CLUSTER; @@ -744,7 +1517,7 @@ impl ClusterAsyncHandler for ScenesHandler<'_, N> { ctx: impl ReadContext, builder: ArrayAttributeRead, SceneInfoStructBuilder

>, ) -> impl Future> { - ready(self.fabric_scene_info_sync(&ctx, builder)) + ready(self.read_fabric_scene_info(&ctx, builder)) } fn handle_add_scene( @@ -753,7 +1526,7 @@ impl ClusterAsyncHandler for ScenesHandler<'_, N> { request: AddSceneRequest<'_>, response: AddSceneResponseBuilder

, ) -> impl Future> { - ready(self.handle_add_scene_sync(&ctx, &request, response)) + ready(self.add_scene(&ctx, &request, response)) } fn handle_view_scene( @@ -762,7 +1535,7 @@ impl ClusterAsyncHandler for ScenesHandler<'_, N> { request: ViewSceneRequest<'_>, response: ViewSceneResponseBuilder

, ) -> impl Future> { - ready(self.handle_view_scene_sync(&ctx, &request, response)) + ready(self.view_scene(&ctx, &request, response)) } fn handle_remove_scene( @@ -771,7 +1544,7 @@ impl ClusterAsyncHandler for ScenesHandler<'_, N> { request: RemoveSceneRequest<'_>, response: RemoveSceneResponseBuilder

, ) -> impl Future> { - ready(self.handle_remove_scene_sync(&ctx, &request, response)) + ready(self.remove_scene(&ctx, &request, response)) } fn handle_remove_all_scenes( @@ -780,24 +1553,31 @@ impl ClusterAsyncHandler for ScenesHandler<'_, N> { request: RemoveAllScenesRequest<'_>, response: RemoveAllScenesResponseBuilder

, ) -> impl Future> { - ready(self.handle_remove_all_scenes_sync(&ctx, &request, response)) + ready(self.remove_all_scenes(&ctx, &request, response)) } - fn handle_store_scene( + // `handle_store_scene` actually `.await`s (unlike the other + // ClusterAsyncHandler methods on this handler) because StoreScene + // captures the current values of scene-able attributes on other + // clusters via `ctx.handler().read()` — see [`Self::store_scene`]. + async fn handle_store_scene( &self, - _ctx: impl InvokeContext, + ctx: impl InvokeContext, request: StoreSceneRequest<'_>, response: StoreSceneResponseBuilder

, - ) -> impl Future> { - ready(self.handle_store_scene_sync(&request, response)) + ) -> Result { + self.store_scene(&ctx, &request, response).await } - fn handle_recall_scene( + // Like `handle_store_scene`, `handle_recall_scene` `.await`s — + // apply is cluster-specific business logic that goes through + // `ctx.handler().invoke()` (see [`Self::recall_scene`]). + async fn handle_recall_scene( &self, ctx: impl InvokeContext, request: RecallSceneRequest<'_>, - ) -> impl Future> { - ready(self.handle_recall_scene_sync(&ctx, &request)) + ) -> Result<(), Error> { + self.recall_scene(&ctx, &request).await } fn handle_get_scene_membership( @@ -806,7 +1586,7 @@ impl ClusterAsyncHandler for ScenesHandler<'_, N> { request: GetSceneMembershipRequest<'_>, response: GetSceneMembershipResponseBuilder

, ) -> impl Future> { - ready(self.handle_get_scene_membership_sync(&ctx, &request, response)) + ready(self.get_scene_membership(&ctx, &request, response)) } fn handle_copy_scene( @@ -815,7 +1595,7 @@ impl ClusterAsyncHandler for ScenesHandler<'_, N> { request: CopySceneRequest<'_>, response: CopySceneResponseBuilder

, ) -> impl Future> { - ready(self.handle_copy_scene_sync(&ctx, &request, response)) + ready(self.copy_scene(&ctx, &request, response)) } } @@ -851,6 +1631,30 @@ mod tests { group_id, scene_id, transition_time, + extension_fields: Vec::new(), + } + } + + /// Variant of [`entry`] that stamps an arbitrary extension-fields + /// blob — used by the Phase B.1 copy-preserves-blob test. + fn entry_with_blob( + fab_idx: NonZeroU8, + endpoint_id: EndptId, + group_id: u16, + scene_id: SceneId, + transition_time: u32, + blob: &[u8], + ) -> SceneEntry { + let mut ext: Vec = Vec::new(); + ext.extend_from_slice(blob) + .expect("blob too large for test"); + SceneEntry { + fab_idx, + endpoint_id, + group_id, + scene_id, + transition_time, + extension_fields: ext, } } @@ -881,6 +1685,73 @@ mod tests { .map(|e| e.transition_time) } + /// Helper: look up the extension-fields blob for one entry. + fn find_blob( + inner: &ScenesStateInner<8>, + fab_idx: NonZeroU8, + ep: EndptId, + group: u16, + scene: SceneId, + ) -> Option<&[u8]> { + inner + .table + .iter() + .find(|e| e.matches(fab_idx, ep, group, scene)) + .map(|e| e.extension_fields.as_slice()) + } + + // ---- Phase B.1: extension-fields blob preservation ---- + + #[test] + fn copy_single_scene_preserves_extension_fields_blob() { + // Source carries an opaque blob; the copy must replicate the + // bytes byte-for-byte at the destination row. + let blob = &[0xDE, 0xAD, 0xBE, 0xEF, 0x18]; + let mut inner = ScenesStateInner::<8>::new(); + push(&mut inner, entry_with_blob(fab(1), 1, 10, 5, 100, blob)); + + let status = + ScenesHandler::<8>::copy_scenes_inner(&mut inner, fab(1), 1, 10, 5, 20, 7, false); + assert_eq!(status, 0); + + assert_eq!(find_blob(&inner, fab(1), 1, 20, 7), Some(&blob[..])); + // Source row keeps its blob too. + assert_eq!(find_blob(&inner, fab(1), 1, 10, 5), Some(&blob[..])); + } + + #[test] + fn copy_all_preserves_each_source_blob() { + let blob_a = &[0xAA, 0xBB, 0x18]; + let blob_b = &[0xCC, 0x18]; + let mut inner = ScenesStateInner::<8>::new(); + push(&mut inner, entry_with_blob(fab(1), 1, 10, 1, 100, blob_a)); + push(&mut inner, entry_with_blob(fab(1), 1, 10, 2, 200, blob_b)); + + let status = + ScenesHandler::<8>::copy_scenes_inner(&mut inner, fab(1), 1, 10, 0, 20, 0, true); + assert_eq!(status, 0); + + assert_eq!(find_blob(&inner, fab(1), 1, 20, 1), Some(&blob_a[..])); + assert_eq!(find_blob(&inner, fab(1), 1, 20, 2), Some(&blob_b[..])); + } + + #[test] + fn copy_overwrites_existing_dest_blob() { + let old_blob = &[0x11, 0x18]; + let new_blob = &[0x22, 0x33, 0x18]; + let mut inner = ScenesStateInner::<8>::new(); + push(&mut inner, entry_with_blob(fab(1), 1, 10, 5, 100, new_blob)); + push(&mut inner, entry_with_blob(fab(1), 1, 20, 7, 999, old_blob)); + + let status = + ScenesHandler::<8>::copy_scenes_inner(&mut inner, fab(1), 1, 10, 5, 20, 7, false); + assert_eq!(status, 0); + + // Dest row's blob got replaced with the source's blob (not + // appended to / mixed with the old). + assert_eq!(find_blob(&inner, fab(1), 1, 20, 7), Some(&new_blob[..])); + } + // ---- copy_scenes_inner: specific-scene mode ---- #[test] diff --git a/rs-matter/src/dm/types.rs b/rs-matter/src/dm/types.rs index 90f9dbacd..cf8a4dbab 100644 --- a/rs-matter/src/dm/types.rs +++ b/rs-matter/src/dm/types.rs @@ -24,6 +24,7 @@ pub use dataver::*; pub use endpoint::*; pub use event::*; pub use handler::*; +pub(crate) use handler::{InvokeContextInstance, ReadContextInstance}; pub use metadata::*; pub use node::*; pub use privilege::*; From eb3cb8248f173a94ce04512a82ac829fd187abd5 Mon Sep 17 00:00:00 2001 From: ivmarkov Date: Fri, 29 May 2026 18:15:31 +0000 Subject: [PATCH 03/15] Add more unit tests --- rs-matter/src/dm/clusters/scenes.rs | 216 +++++++++++++++++++++++++++- 1 file changed, 210 insertions(+), 6 deletions(-) diff --git a/rs-matter/src/dm/clusters/scenes.rs b/rs-matter/src/dm/clusters/scenes.rs index 880a5726d..e20ec9695 100644 --- a/rs-matter/src/dm/clusters/scenes.rs +++ b/rs-matter/src/dm/clusters/scenes.rs @@ -1658,12 +1658,17 @@ mod tests { } } - fn push(inner: &mut ScenesStateInner<8>, e: SceneEntry) { + fn push(inner: &mut ScenesStateInner, e: SceneEntry) { inner.table.push(e).expect("test table overflow"); } /// Count entries in `inner.table` matching the given filter. - fn count(inner: &ScenesStateInner<8>, fab_idx: NonZeroU8, ep: EndptId, group: u16) -> usize { + fn count( + inner: &ScenesStateInner, + fab_idx: NonZeroU8, + ep: EndptId, + group: u16, + ) -> usize { inner .table .iter() @@ -1671,8 +1676,8 @@ mod tests { .count() } - fn find_tt( - inner: &ScenesStateInner<8>, + fn find_tt( + inner: &ScenesStateInner, fab_idx: NonZeroU8, ep: EndptId, group: u16, @@ -1686,8 +1691,8 @@ mod tests { } /// Helper: look up the extension-fields blob for one entry. - fn find_blob( - inner: &ScenesStateInner<8>, + fn find_blob( + inner: &ScenesStateInner, fab_idx: NonZeroU8, ep: EndptId, group: u16, @@ -2039,4 +2044,203 @@ mod tests { assert_eq!(inner.current_per_fabric.len(), 1); assert_eq!(inner.current_per_fabric[0].fab_idx, fab(2)); } + + // ---- Phase D: AddScene / StoreScene shared `upsert_scene` path ---- + // + // `AddScene` and `StoreScene` differ only in *where* the EFS blob + // comes from (request payload vs cross-cluster capture) — both + // commit through `upsert_scene` with a fill closure that + // `extend_from_slice`s into the slot's `Vec`. These tests + // exercise the upsert state-machine directly (no async handler + // harness needed). + + /// Fill closure that copies a fixed slice into the slot Vec. + /// Used by `upsert_scene` tests where the contents don't matter. + fn fill_with<'a>(blob: &'a [u8]) -> impl FnOnce(&mut Vec) -> Result<(), Error> + 'a { + move |ext| { + ext.extend_from_slice(blob) + .map_err(|_| ErrorCode::NoSpace.into()) + } + } + + #[test] + fn upsert_inserts_new_record_with_status_zero() { + let mut inner = ScenesStateInner::<8>::new(); + let status = ScenesHandler::<8>::upsert_scene( + &mut inner, + fab(1), + 1, + 10, + 5, + 100, + fill_with(&[0xAA, 0x18]), + ) + .unwrap(); + + assert_eq!(status, 0); + assert_eq!(inner.table.len(), 1); + assert_eq!(find_tt(&inner, fab(1), 1, 10, 5), Some(100)); + assert_eq!(find_blob(&inner, fab(1), 1, 10, 5), Some(&[0xAA, 0x18][..])); + } + + #[test] + fn upsert_replaces_existing_record_in_place_no_growth() { + let mut inner = ScenesStateInner::<8>::new(); + push( + &mut inner, + entry_with_blob(fab(1), 1, 10, 5, 100, &[0xAA, 0x18]), + ); + + let status = ScenesHandler::<8>::upsert_scene( + &mut inner, + fab(1), + 1, + 10, + 5, + 999, + fill_with(&[0xBB, 0xCC, 0x18]), + ) + .unwrap(); + + assert_eq!(status, 0); + assert_eq!(inner.table.len(), 1, "replace must not grow the table"); + assert_eq!(find_tt(&inner, fab(1), 1, 10, 5), Some(999)); + assert_eq!( + find_blob(&inner, fab(1), 1, 10, 5), + Some(&[0xBB, 0xCC, 0x18][..]) + ); + } + + #[test] + fn upsert_returns_insufficient_space_when_table_is_full() { + // Fill the table to capacity, then try to insert a NEW key. + let mut inner = ScenesStateInner::<3>::new(); + inner.table.push(entry(fab(1), 1, 10, 1, 100)).unwrap(); + inner.table.push(entry(fab(1), 1, 10, 2, 100)).unwrap(); + inner.table.push(entry(fab(1), 1, 10, 3, 100)).unwrap(); + + let status = ScenesHandler::<3>::upsert_scene( + &mut inner, + fab(1), + 1, + 10, + 99, // new scene_id + 200, + fill_with(&[0x18]), + ) + .unwrap(); + + assert_eq!(status, SC_INSUFFICIENT_SPACE); + assert_eq!(inner.table.len(), 3, "table size unchanged on rejection"); + } + + #[test] + fn upsert_replace_at_full_capacity_still_succeeds() { + // Replacing an EXISTING entry doesn't need a new slot, so it + // should succeed even when the table is at capacity. + let mut inner = ScenesStateInner::<3>::new(); + inner.table.push(entry(fab(1), 1, 10, 1, 100)).unwrap(); + inner.table.push(entry(fab(1), 1, 10, 2, 100)).unwrap(); + inner.table.push(entry(fab(1), 1, 10, 3, 100)).unwrap(); + + let status = ScenesHandler::<3>::upsert_scene( + &mut inner, + fab(1), + 1, + 10, + 2, // existing scene_id + 999, + fill_with(&[0x18]), + ) + .unwrap(); + + assert_eq!(status, 0); + assert_eq!(inner.table.len(), 3); + assert_eq!(find_tt(&inner, fab(1), 1, 10, 2), Some(999)); + } + + #[test] + fn upsert_invalidates_current_scene_for_target_fabric() { + // Per `SceneValid` rules: any AddScene/StoreScene mutates + // table state in a way that may no longer match the recalled + // attributes, so CurrentScene gets cleared for the originating + // fabric. + let mut inner = ScenesStateInner::<8>::new(); + ScenesHandler::<8>::remember_current(&mut inner, fab(1), 99, 99); + + let _ = + ScenesHandler::<8>::upsert_scene(&mut inner, fab(1), 1, 10, 5, 100, fill_with(&[0x18])) + .unwrap(); + + assert!(!inner.current_per_fabric.iter().any(|c| c.fab_idx == fab(1))); + } + + #[test] + fn upsert_keeps_other_fabrics_current_scene_intact() { + let mut inner = ScenesStateInner::<8>::new(); + ScenesHandler::<8>::remember_current(&mut inner, fab(1), 99, 99); + ScenesHandler::<8>::remember_current(&mut inner, fab(2), 88, 88); + + let _ = + ScenesHandler::<8>::upsert_scene(&mut inner, fab(1), 1, 10, 5, 100, fill_with(&[0x18])) + .unwrap(); + + assert!(!inner.current_per_fabric.iter().any(|c| c.fab_idx == fab(1))); + assert!( + inner.current_per_fabric.iter().any(|c| c.fab_idx == fab(2)), + "fab(2)'s CurrentScene must not be touched" + ); + } + + #[test] + fn upsert_at_full_capacity_does_not_invalidate_current() { + // When the new-entry path errors with SC_INSUFFICIENT_SPACE, + // the table state is unchanged — CurrentScene must stay too. + let mut inner = ScenesStateInner::<3>::new(); + inner.table.push(entry(fab(1), 1, 10, 1, 100)).unwrap(); + inner.table.push(entry(fab(1), 1, 10, 2, 100)).unwrap(); + inner.table.push(entry(fab(1), 1, 10, 3, 100)).unwrap(); + ScenesHandler::<3>::remember_current(&mut inner, fab(1), 99, 99); + + let status = ScenesHandler::<3>::upsert_scene( + &mut inner, + fab(1), + 1, + 10, + 99, + 200, + fill_with(&[0x18]), + ) + .unwrap(); + + assert_eq!(status, SC_INSUFFICIENT_SPACE); + assert!(inner.current_per_fabric.iter().any(|c| c.fab_idx == fab(1))); + } + + #[test] + fn upsert_fill_failure_on_new_entry_rolls_back_the_push() { + // If the fill closure errors *after* `push_init` has stamped + // an empty SceneEntry into the slot, that slot must be popped + // so the table returns to its pre-call state. + let mut inner = ScenesStateInner::<8>::new(); + push(&mut inner, entry(fab(1), 1, 10, 1, 100)); + + let result = ScenesHandler::<8>::upsert_scene( + &mut inner, + fab(1), + 1, + 10, + 42, // brand new + 200, + |_| Err(ErrorCode::NoSpace.into()), + ); + + assert!(result.is_err()); + assert_eq!( + inner.table.len(), + 1, + "rolled-back push leaves count untouched" + ); + assert!(find_tt(&inner, fab(1), 1, 10, 42).is_none()); + } } From ef1dc98aae57722934a66a1d0abdb3306814e844 Mon Sep 17 00:00:00 2001 From: ivmarkov Date: Fri, 29 May 2026 18:31:52 +0000 Subject: [PATCH 04/15] YAML/ Python tests --- examples/Cargo.toml | 3 + examples/src/bin/scenes_tests.pics | 144 +++++ examples/src/bin/scenes_tests.rs | 586 ++++++++++++++++++ .../src/dm/clusters/app/color_control.rs | 12 +- .../src/dm/clusters/app/level_control.rs | 12 +- rs-matter/src/dm/clusters/app/on_off.rs | 12 +- rs-matter/src/dm/clusters/scenes.rs | 347 +++++++++-- xtask/src/itest.rs | 51 +- 8 files changed, 1086 insertions(+), 81 deletions(-) create mode 100644 examples/src/bin/scenes_tests.pics create mode 100644 examples/src/bin/scenes_tests.rs diff --git a/examples/Cargo.toml b/examples/Cargo.toml index 754703bc0..331234542 100644 --- a/examples/Cargo.toml +++ b/examples/Cargo.toml @@ -36,6 +36,9 @@ name = "commissioner_tests" [[bin]] name = "dimmable_light" +[[bin]] +name = "scenes_tests" + [[bin]] name = "webrtc_camera" required-features = ["webrtc"] diff --git a/examples/src/bin/scenes_tests.pics b/examples/src/bin/scenes_tests.pics new file mode 100644 index 000000000..27b795634 --- /dev/null +++ b/examples/src/bin/scenes_tests.pics @@ -0,0 +1,144 @@ +PICS_SDK_CI_ONLY=1 +# PICS values to be used when running the `scenes_tests` executable. +# This is a superset of `dimmable_light.pics` (OnOff + LevelControl) +# plus the Scenes Management cluster on EP1 and the +# Groups / GroupKeyManagement clusters needed to set up the multicast +# group plumbing the `Test_TC_S_*` certification tests exercise. + +# Groups Cluster (EP1, server) — Scenes tests use AddGroup/RemoveGroup +# in their fixture setup. +G.S=1 +G.S.F00=1 +G.S.A0000=0 +G.S.C00.Rsp=1 +G.S.C01.Rsp=1 +G.S.C02.Rsp=1 +G.S.C03.Rsp=1 +G.S.C04.Rsp=1 +G.S.C05.Rsp=0 +G.S.C00.Tx=1 +G.S.C01.Tx=1 +G.S.C02.Tx=1 +G.S.C03.Tx=1 +G.C=1 +G.C.A0000=0 +G.C.C00.Tx=1 +G.C.C01.Tx=0 +G.C.C02.Tx=0 +G.C.C03.Tx=0 +G.C.C04.Tx=0 +G.C.C05.Tx=0 + +# GroupKeyManagement Cluster (EP0, server) — required for KeySetWrite + +# WriteGroupKeyMap, which the Scenes tests' Step 0b/0c rely on. +GRPKEY.S=1 +GRPKEY.S.F00=0 +GRPKEY.S.A0000=1 +GRPKEY.S.A0001=1 +GRPKEY.S.A0002=1 +GRPKEY.S.A0003=1 +GRPKEY.S.C00.Rsp=1 +GRPKEY.S.C01.Rsp=1 +GRPKEY.S.C02.Tx=1 +GRPKEY.S.C03.Rsp=1 +GRPKEY.S.C04.Rsp=1 +GRPKEY.S.C05.Tx=1 +GRPKEY.C=1 +GRPKEY.C.A0000=1 +GRPKEY.C.A0001=0 +GRPKEY.C.C00.Tx=1 +GRPKEY.C.C01.Tx=1 + +# Scenes Management Cluster (server, EP1) +# `S.S.AM` and `S.S.AO` are mutually exclusive with the Action / Object +# feature subsets; we don't implement those, so leave them off. +S.S=1 +S.S.A0001=1 +S.S.A0002=1 +S.S.C00.Rsp=1 # AddScene +S.S.C01.Rsp=1 # ViewScene +S.S.C02.Rsp=1 # RemoveScene +S.S.C03.Rsp=1 # RemoveAllScenes +S.S.C04.Rsp=1 # StoreScene +S.S.C05.Rsp=1 # RecallScene +S.S.C06.Rsp=1 # GetSceneMembership +S.S.C40.Rsp=1 # CopyScene (Optional but exposed for TestScenesMultiFabric / TestScenesMaxCapacity) +S.S.AM=0 +S.S.AO=0 +S.S.F00=0 # SceneNames feature — accepted on wire but not stored; see scenes.rs module docs +S.C=0 +S.C.C00.Tx=0 +S.C.C01.Tx=0 +S.C.C02.Tx=0 +S.C.C03.Tx=0 +S.C.C04.Tx=0 +S.C.C05.Tx=0 +S.C.C06.Tx=0 +S.C.C40.Tx=0 +S.C.AM-READ=0 + +# Level Control Cluster +LVL.S=1 +LVL.S.F00=1 +LVL.S.F01=1 +LVL.S.F02=0 +LVL.S.A0000=1 +LVL.S.A0001=1 +LVL.S.A0002=1 +LVL.S.A0003=1 +LVL.S.A0005=0 +LVL.S.A0004=0 +LVL.S.A0006=0 +LVL.S.A000f=1 +LVL.S.A0010=1 +LVL.S.A0011=1 +LVL.S.A0012=1 +LVL.S.A0013=1 +LVL.S.A0014=1 +LVL.S.A4000=1 +LVL.S.C00.Rsp=1 +LVL.S.C01.Rsp=1 +LVL.S.C02.Rsp=1 +LVL.S.C03.Rsp=1 +LVL.S.C04.Rsp=1 +LVL.S.C05.Rsp=1 +LVL.S.C06.Rsp=1 +LVL.S.C07.Rsp=1 +LVL.S.C08.Rsp=0 +LVL.S.M.VarRate=1 + +LVL.C=0 +LVL.C.AM-READ=0 +LVL.C.AM-WRITE=0 +LVL.C.AO-READ=0 +LVL.C.AO-WRITE=0 + +# On/Off Cluster +OO.S=1 +OO.S.A0000=1 +OO.S.A4000=1 +OO.S.A4001=1 +OO.S.A4002=1 +OO.S.A4003=1 +OO.S.C00.Rsp=1 +OO.S.C01.Rsp=1 +OO.S.C02.Rsp=1 +OO.S.C40.Rsp=1 +OO.S.C41.Rsp=0 +OO.S.C42.Rsp=1 +OO.S.F00=1 +OO.S.F01=0 +OO.S.F02=0 +OO.M.ManuallyControlled=1 + +OO.C=0 +OO.C.C00.Tx=0 +OO.C.C01.Tx=0 +OO.C.C02.Tx=0 +OO.C.C40.Tx=0 +OO.C.C41.Tx=0 +OO.C.C42.Tx=0 +OO.C.AM-READ=0 +OO.C.AM-WRITE=0 +OO.C.AO-READ=0 +OO.C.AO-WRITE=0 diff --git a/examples/src/bin/scenes_tests.rs b/examples/src/bin/scenes_tests.rs new file mode 100644 index 000000000..541ac32ce --- /dev/null +++ b/examples/src/bin/scenes_tests.rs @@ -0,0 +1,586 @@ +/* + * + * Copyright (c) 2025-2026 Project CHIP Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +//! An example Matter device that exercises the Scenes Management +//! cluster (`0x0062`) alongside On/Off + LevelControl over Ethernet. +//! +//! Driven by the `xtask Scenes` suite (see `xtask::TestSuite::Scenes`), +//! which runs the chip-tool `Test_TC_S_*` YAML certification tests +//! against this binary. Structurally this is a clone of +//! `dimmable_light` (same OnOff + LevelControl hooks + business +//! logic), with the Scenes Management cluster added on EP1 and the +//! per-cluster `SceneClusterHandler` impls registered with +//! `ScenesHandler::new`. +#![recursion_limit = "256"] +#![allow(clippy::uninlined_format_args)] + +use core::cell::Cell; +use core::pin::pin; + +use std::fs; +use std::io::{Read, Write}; +use std::net::UdpSocket; +use std::path::PathBuf; + +use embassy_futures::select::select3; + +use async_signal::{Signal, Signals}; +use log::{error, info, trace}; + +use futures_lite::StreamExt; + +use rand::RngCore; +use rs_matter::crypto::{default_crypto, Crypto}; +use rs_matter::dm::clusters::app::level_control::scenes::LevelControlSceneClusterHandler; +use rs_matter::dm::clusters::app::level_control::{self, LevelControlHooks}; +use rs_matter::dm::clusters::app::on_off::scenes::OnOffSceneClusterHandler; +use rs_matter::dm::clusters::app::on_off::{self, OnOffHooks, StartUpOnOffEnum}; +use rs_matter::dm::clusters::decl::level_control::{ + AttributeId, CommandId, OptionsBitmap, FULL_CLUSTER as LEVEL_CONTROL_FULL_CLUSTER, +}; +use rs_matter::dm::clusters::decl::on_off as on_off_cluster; +use rs_matter::dm::clusters::decl::scenes_management::FULL_CLUSTER as SCENES_FULL_CLUSTER; +use rs_matter::dm::clusters::desc::{self, ClusterHandler as _}; +use rs_matter::dm::clusters::groups::{self, ClusterHandler as _}; +use rs_matter::dm::clusters::net_comm::SharedNetworks; +use rs_matter::dm::clusters::scenes::{ScenesHandler, ScenesState}; +use rs_matter::dm::devices::test::{DAC_PRIVKEY, TEST_DEV_ATT, TEST_DEV_COMM, TEST_DEV_DET}; +use rs_matter::dm::devices::DEV_TYPE_DIMMABLE_LIGHT; +use rs_matter::dm::events::Events; +use rs_matter::dm::networks::eth::EthNetwork; +use rs_matter::dm::networks::SysNetifs; +use rs_matter::dm::subscriptions::Subscriptions; +use rs_matter::dm::IMBuffer; +use rs_matter::dm::{endpoints, AsyncHandler, EmptyHandler}; +use rs_matter::dm::{ + Async, Cluster, DataModel, DataModelHandler, Dataver, Endpoint, EpClMatcher, Node, +}; +use rs_matter::error::{Error, ErrorCode}; +use rs_matter::pairing::qr::QrTextType; +use rs_matter::pairing::DiscoveryCapabilities; +use rs_matter::persist::SharedKvBlobStore; +use rs_matter::respond::DefaultResponder; +use rs_matter::sc::pase::MAX_COMM_WINDOW_TIMEOUT_SECS; +use rs_matter::tlv::Nullable; +use rs_matter::transport::MATTER_SOCKET_BIND_ADDR; +use rs_matter::utils::init::InitMaybeUninit; +use rs_matter::utils::select::Coalesce; +use rs_matter::utils::storage::pooled::PooledBuffers; +use rs_matter::{clusters, devices, root_endpoint, with, Matter, MATTER_PORT}; + +use static_cell::StaticCell; + +#[path = "../common/mdns.rs"] +mod mdns; + +// Statically allocate in BSS the bigger objects +// `rs-matter` supports efficient initialization of BSS objects (with `init`) +// as well as just allocating the objects on-stack or on the heap. +static MATTER: StaticCell = StaticCell::new(); +static BUFFERS: StaticCell> = StaticCell::new(); +static SUBSCRIPTIONS: StaticCell = StaticCell::new(); +static EVENTS: StaticCell = StaticCell::new(); +static KV_BUF: StaticCell<[u8; 4096]> = StaticCell::new(); + +/// Scene-table capacity for this example. `Test_TC_S_2_x` / `_3_1` and +/// the spec-mandated minimum (16) both fit comfortably; bump if +/// `TestScenesMaxCapacity` ever joins the suite. +const SCENES_CAPACITY: usize = 16; +static SCENES_STATE: StaticCell> = StaticCell::new(); + +fn main() -> Result<(), Error> { + let thread = std::thread::Builder::new() + // Increase the stack size until the example can work without stack blowups. + // Note that the used stack size increases exponentially by lowering the level of compiler optimizations, + // as lower optimization settings prevent the Rust compiler from inlining constructor functions + // which often results in (unnecessary) memory moves and increased stack utilization: + // e.g., an opt-level of "0" will require a several times' larger stack. + // + // Optimizing/lowering `rs-matter` memory consumption is an ongoing topic. + .stack_size(550 * 1024) + .spawn(run) + .unwrap(); + + thread.join().unwrap() +} + +fn run() -> Result<(), Error> { + env_logger::builder() + .format(|buf, record| { + use std::io::Write; + writeln!(buf, "{}: {}", record.level(), record.args()) + }) + .target(env_logger::Target::Stdout) + .filter_level(::log::LevelFilter::Debug) + .init(); + + info!( + "Matter memory: Matter (BSS)={}B, IM Buffers (BSS)={}B, Subscriptions (BSS)={}B", + core::mem::size_of::(), + core::mem::size_of::>(), + core::mem::size_of::() + ); + + let matter = MATTER.uninit().init_with(Matter::init( + &TEST_DEV_DET, + TEST_DEV_COMM, + &TEST_DEV_ATT, + MATTER_PORT, + )); + + // Create the event queue + let events = EVENTS.uninit().init_with(Events::init()); + + // Persistence + let kv_buf = KV_BUF.uninit().init_zeroed().as_mut_slice(); + let mut kv = rs_matter::persist::FileKvBlobStore::new_default(); + futures_lite::future::block_on(matter.load_persist(&mut kv, kv_buf))?; + futures_lite::future::block_on(events.load_persist(&mut kv, kv_buf))?; + + // Create the transport buffers + let buffers = BUFFERS.uninit().init_with(PooledBuffers::init(0)); + + // Create the subscriptions + let subscriptions = SUBSCRIPTIONS.uninit().init_with(Subscriptions::init()); + + // Create the crypto instance + let crypto = default_crypto(rand::thread_rng(), DAC_PRIVKEY); + + let mut rand = crypto.rand()?; + + // OnOff cluster setup + let on_off_handler = + on_off::OnOffHandler::new(Dataver::new_rand(&mut rand), 1, OnOffDeviceLogic::new()); + + // LevelControl cluster setup + let level_control_handler = level_control::LevelControlHandler::new( + Dataver::new_rand(&mut rand), + 1, + LevelControlDeviceLogic::new(), + level_control::AttributeDefaults { + on_level: Nullable::some(42), + options: OptionsBitmap::from_bits(OptionsBitmap::EXECUTE_IF_OFF.bits()).unwrap(), + ..Default::default() + }, + ); + + // Cluster wiring, validation and initialisation + on_off_handler.init(Some(&level_control_handler)); + level_control_handler.init(Some(&on_off_handler)); + + let scenes_handler0 = EmptyHandler + .chain( + EpClMatcher::new(Some(1), Some(OnOffDeviceLogic::CLUSTER.id)), + on_off::HandlerAsyncAdaptor(&on_off_handler), + ) + .chain( + EpClMatcher::new(Some(1), Some(LevelControlDeviceLogic::CLUSTER.id)), + level_control::HandlerAsyncAdaptor(&level_control_handler), + ); + + // Scenes Management cluster setup. + // + // `ScenesState` holds the scene table; `ScenesHandler` is generic + // over a tuple-recursive `SceneClusters` registry that names the + // per-cluster `SceneClusterHandler` impls. Both OnOff and + // LevelControl ship a ZST impl in their respective modules + // (`app::on_off::scenes` / `app::level_control::scenes`). + let scenes_state = SCENES_STATE.uninit().init_with(ScenesState::init()); + let scenes_handler = ScenesHandler::new( + Dataver::new_rand(&mut rand), + scenes_state, + scenes_handler0, + ( + OnOffSceneClusterHandler, + (LevelControlSceneClusterHandler, ()), + ), + ); + + // Create the Data Model instance + let dm = DataModel::new( + matter, + &crypto, + buffers, + subscriptions, + events, + dm_handler( + rand, + &on_off_handler, + &level_control_handler, + scenes_handler, + ), + SharedKvBlobStore::new(kv, kv_buf), + SharedNetworks::new(EthNetwork::new_default()), + ); + + // Create a default responder capable of handling up to 3 subscriptions + // All other subscription requests will be turned down with "resource exhausted" + let responder = DefaultResponder::new(&dm); + info!( + "Responder memory: Responder (stack)={}B, Runner fut (stack)={}B", + core::mem::size_of_val(&responder), + core::mem::size_of_val(&responder.run::<4, 4>()) + ); + + // Run the responder with up to 4 handlers (i.e. 4 exchanges can be handled simultaneously) + // Clients trying to open more exchanges than the ones currently running will get "I'm busy, please try again later" + let mut respond = pin!(responder.run::<4, 4>()); + + // Run the background job of the data model + let mut dm_job = pin!(dm.run()); + + let socket = async_io::Async::::bind(MATTER_SOCKET_BIND_ADDR)?; + + info!( + "Transport memory: Transport fut (stack)={}B, mDNS fut (stack)={}B", + core::mem::size_of_val(&matter.run(&crypto, &socket, &socket, &socket)), + core::mem::size_of_val(&mdns::run_mdns(matter, &crypto)) + ); + + // Run the Matter and mDNS transports + let mut mdns = pin!(mdns::run_mdns(matter, &crypto)); + let mut transport = pin!(matter.run(&crypto, &socket, &socket, &socket)); + + // We need to always print the QR text, because the test runner expects it to be printed + // even if the device is already commissioned + matter.print_standard_qr_text(DiscoveryCapabilities::IP)?; + + if !matter.is_commissioned() { + // If the device is not commissioned yet, print the QR code to the console + // and enable basic commissioning + + matter.print_standard_qr_code(QrTextType::Unicode, DiscoveryCapabilities::IP)?; + + matter.open_basic_comm_window(MAX_COMM_WINDOW_TIMEOUT_SECS, &crypto, &())?; + } + + // Listen to SIGTERM (or Ctrl-C on Windows, where SIGTERM is not + // supported by `async-signal`) because at the end of the test we'll + // receive it. + #[cfg(not(windows))] + let mut term_signal = Signals::new([Signal::Term])?; + #[cfg(windows)] + let mut term_signal = Signals::new([Signal::Int])?; + let mut term = pin!(async { + term_signal.next().await; + Ok(()) + }); + + // Combine all async tasks in a single one + let all = select3( + &mut transport, + &mut mdns, + select3(&mut respond, &mut dm_job, &mut term).coalesce(), + ); + + // Run with a simple `block_on`. Any local executor would do. + futures_lite::future::block_on(all.coalesce()) +} + +/// The Node meta-data describing our Matter device. +/// +/// EP1 carries the Dimmable Light device type (so OnOff+LevelControl +/// are auto-required) plus the Scenes Management cluster. +const NODE: Node<'static> = Node { + endpoints: &[ + root_endpoint!(eth), + Endpoint::new( + 1, + devices!(DEV_TYPE_DIMMABLE_LIGHT), + clusters!( + desc::DescHandler::CLUSTER, + groups::GroupsHandler::CLUSTER, + OnOffDeviceLogic::CLUSTER, + LevelControlDeviceLogic::CLUSTER, + SCENES_FULL_CLUSTER, + ), + ), + ], +}; + +/// The Data Model handler + meta-data for our Matter device. +/// The handler is the root endpoint 0 handler plus the OnOff / +/// LevelControl / Scenes handlers wired onto EP1. +fn dm_handler<'a, LH: LevelControlHooks, OH: OnOffHooks, R>( + mut rand: impl RngCore + Copy, + on_off: &'a on_off::OnOffHandler<'a, OH, LH>, + level_control: &'a level_control::LevelControlHandler<'a, LH, OH>, + scenes: ScenesHandler<'a, SCENES_CAPACITY, impl AsyncHandler + 'a, R>, +) -> impl DataModelHandler + 'a +where + R: rs_matter::dm::clusters::scenes::SceneClusters + 'a, +{ + ( + NODE, + endpoints::EthSysHandlerBuilder::new() + .netif_diag(&SysNetifs) + .build(rand) + .chain( + EpClMatcher::new(Some(1), Some(desc::DescHandler::CLUSTER.id)), + Async(desc::DescHandler::new(Dataver::new_rand(&mut rand)).adapt()), + ) + .chain( + EpClMatcher::new(Some(1), Some(groups::GroupsHandler::CLUSTER.id)), + Async(groups::GroupsHandler::new(Dataver::new_rand(&mut rand)).adapt()), + ) + .chain( + EpClMatcher::new(Some(1), Some(OnOffDeviceLogic::CLUSTER.id)), + on_off::HandlerAsyncAdaptor(on_off), + ) + .chain( + EpClMatcher::new(Some(1), Some(LevelControlDeviceLogic::CLUSTER.id)), + level_control::HandlerAsyncAdaptor(level_control), + ) + .chain( + EpClMatcher::new(Some(1), Some(SCENES_FULL_CLUSTER.id)), + scenes.adapt(), + ), + ) +} + +// Implementing the LevelControl business logic +pub struct LevelControlDeviceLogic { + current_level: Cell>, + start_up_current_level: Cell>, +} + +impl Default for LevelControlDeviceLogic { + fn default() -> Self { + Self::new() + } +} + +impl LevelControlDeviceLogic { + pub const fn new() -> Self { + Self { + current_level: Cell::new(Some(1)), + start_up_current_level: Cell::new(None), + } + } +} + +impl LevelControlHooks for LevelControlDeviceLogic { + const MIN_LEVEL: u8 = 1; + const MAX_LEVEL: u8 = 254; + const FASTEST_RATE: u8 = 50; + const CLUSTER: Cluster<'static> = LEVEL_CONTROL_FULL_CLUSTER + .with_features( + level_control::Feature::LIGHTING.bits() | level_control::Feature::ON_OFF.bits(), + ) + .with_attrs(with!( + required; + AttributeId::CurrentLevel + | AttributeId::RemainingTime + | AttributeId::MinLevel + | AttributeId::MaxLevel + | AttributeId::OnOffTransitionTime + | AttributeId::OnLevel + | AttributeId::OnTransitionTime + | AttributeId::OffTransitionTime + | AttributeId::DefaultMoveRate + | AttributeId::Options + | AttributeId::StartUpCurrentLevel + )) + .with_cmds(with!( + CommandId::MoveToLevel + | CommandId::Move + | CommandId::Step + | CommandId::Stop + | CommandId::MoveToLevelWithOnOff + | CommandId::MoveWithOnOff + | CommandId::StepWithOnOff + | CommandId::StopWithOnOff + )); + + fn set_device_level(&self, level: u8) -> Result, ()> { + // This is where business logic is implemented to physically change the level of the device. + Ok(Some(level)) + } + + fn current_level(&self) -> Option { + self.current_level.get() + } + + fn set_current_level(&self, level: Option) { + info!( + "LevelControlDeviceLogic::set_current_level: setting level to {:?}", + level + ); + self.current_level.set(level); + } + + fn start_up_current_level(&self) -> Result, Error> { + Ok(self.start_up_current_level.get()) + } + + fn set_start_up_current_level(&self, value: Option) -> Result<(), Error> { + self.start_up_current_level.set(value); + Ok(()) + } +} + +// Implementing the OnOff business logic + +// A simple serializer and deserializer for persisting the OnOff state in a single byte. +// Stores the on_off state in the first bit. +// Stores the start_up_on_off state in the remaining bits. +#[derive(Default)] +struct OnOffPersistentState { + on_off: bool, + start_up_on_off: Option, +} + +impl OnOffPersistentState { + fn to_bytes_from_values(on_off: bool, start_up_on_off: Option) -> u8 { + trace!( + "to_bytes_from_values: got on_off: {} | start_up_on_off: {:?}", + on_off, + start_up_on_off + ); + let on_off = on_off as u8; + let start_up_on_off: u8 = match start_up_on_off { + Some(StartUpOnOffEnum::Off) => 0, + Some(StartUpOnOffEnum::On) => 1, + Some(StartUpOnOffEnum::Toggle) => 2, + None => 3, + }; + trace!( + "to_bytes_from_values: vals before writing on_off: {} | start_up_on_off: {}", + on_off, + start_up_on_off + ); + trace!("final val: {}", on_off + (start_up_on_off << 1)); + on_off + (start_up_on_off << 1) + } + + fn from_bytes(data: u8) -> Result { + Ok(Self { + on_off: data & 1 != 0, + start_up_on_off: match data >> 1 { + 0 => Some(StartUpOnOffEnum::Off), + 1 => Some(StartUpOnOffEnum::On), + 2 => Some(StartUpOnOffEnum::Toggle), + 3 => None, + _ => return Err(ErrorCode::Failure.into()), + }, + }) + } +} + +#[derive(Default)] +pub struct OnOffDeviceLogic { + on_off: Cell, + start_up_on_off: Cell>, + storage_path: PathBuf, +} + +const STORAGE_FILE_NAME: &str = "rs-matter-on-off-state"; + +impl OnOffDeviceLogic { + pub fn new() -> Self { + let storage_path = std::env::temp_dir().join(STORAGE_FILE_NAME); + info!( + "OnOffDeviceLogic using storage path: {}", + storage_path.as_path().to_str().unwrap_or("none") + ); + + let persisted_state = match fs::File::open(storage_path.as_path()) { + Ok(mut file) => { + let mut buf: [u8; 1] = [0]; + file.read_exact(&mut buf).unwrap(); + + trace!("OnOffDeviceLogic::new: read from storage: {:0x}", buf[0]); + + OnOffPersistentState::from_bytes(buf[0]).unwrap() + } + Err(_) => OnOffPersistentState::default(), + }; + + Self { + on_off: Cell::new(persisted_state.on_off), + start_up_on_off: Cell::new(persisted_state.start_up_on_off), + storage_path, + } + } + + fn save_state(&self) -> Result<(), Error> { + let mut file = fs::File::create(self.storage_path.as_path())?; + + let value = OnOffPersistentState::to_bytes_from_values( + self.on_off.get(), + self.start_up_on_off.get(), + ); + + let buf = &[value]; + + trace!("save_storage: wrote {:0x}", value); + + file.write_all(buf)?; + + Ok(()) + } +} + +impl OnOffHooks for OnOffDeviceLogic { + const CLUSTER: Cluster<'static> = on_off_cluster::FULL_CLUSTER + .with_revision(6) + .with_features(on_off_cluster::Feature::LIGHTING.bits()) + .with_attrs(with!( + required; + on_off_cluster::AttributeId::OnOff + | on_off_cluster::AttributeId::GlobalSceneControl + | on_off_cluster::AttributeId::OnTime + | on_off_cluster::AttributeId::OffWaitTime + | on_off_cluster::AttributeId::StartUpOnOff + )) + .with_cmds(with!( + on_off_cluster::CommandId::Off + | on_off_cluster::CommandId::On + | on_off_cluster::CommandId::Toggle + | on_off_cluster::CommandId::OffWithEffect + | on_off_cluster::CommandId::OnWithRecallGlobalScene + | on_off_cluster::CommandId::OnWithTimedOff + )); + + fn on_off(&self) -> bool { + self.on_off.get() + } + + fn set_on_off(&self, on: bool) { + self.on_off.set(on); + info!("OnOff state set to: {}", on); + if let Err(err) = self.save_state() { + error!("Error saving state: {}", err); + } + } + + fn start_up_on_off(&self) -> Nullable { + match self.start_up_on_off.get() { + Some(value) => Nullable::some(value), + None => Nullable::none(), + } + } + + fn set_start_up_on_off(&self, value: Nullable) -> Result<(), Error> { + self.start_up_on_off.set(value.into_option()); + self.save_state() + } + + async fn handle_off_with_effect(&self, _effect: on_off::EffectVariantEnum) { + // no effect + } +} diff --git a/rs-matter/src/dm/clusters/app/color_control.rs b/rs-matter/src/dm/clusters/app/color_control.rs index 899af6259..ebc107358 100644 --- a/rs-matter/src/dm/clusters/app/color_control.rs +++ b/rs-matter/src/dm/clusters/app/color_control.rs @@ -60,7 +60,7 @@ pub mod scenes { AttributeValuePairStruct, AttributeValuePairStructArrayBuilder, }; use crate::dm::clusters::scenes::{SceneClusterHandler, SceneContext}; - use crate::dm::{ClusterId, EndptId, InvokeContext}; + use crate::dm::{AsyncHandler, ClusterId, EndptId, InvokeContext}; use crate::error::Error; use crate::tlv::{TLVArray, TLVBuilderParent, TLVTag, TLVWriteParent}; use crate::utils::storage::WriteBuf; @@ -139,14 +139,15 @@ pub mod scenes { impl SceneClusterHandler for ColorControlSceneClusterHandler<'_> { const CLUSTER_ID: ClusterId = FULL_CLUSTER.id; - async fn capture( + async fn capture( &self, - sctx: &SceneContext, + sctx: &SceneContext, endpoint_id: EndptId, avp_array: AttributeValuePairStructArrayBuilder

, ) -> Result, Error> where C: InvokeContext, + T: AsyncHandler, P: TLVBuilderParent, { let features = self.features.features(endpoint_id); @@ -249,15 +250,16 @@ pub mod scenes { avp_array.push_u8(AttributeId::EnhancedColorMode as _, mode_u8) } - async fn apply( + async fn apply( &self, - sctx: &SceneContext, + sctx: &SceneContext, endpoint_id: EndptId, transition_time_ms: u32, avp_list: &TLVArray<'_, AttributeValuePairStruct<'_>>, ) -> Result<(), Error> where C: InvokeContext, + T: AsyncHandler, { // Sweep the AVP list once and stash each known value. We // need EnhancedColorMode *and* the mode-specific values diff --git a/rs-matter/src/dm/clusters/app/level_control.rs b/rs-matter/src/dm/clusters/app/level_control.rs index 8c3f54c76..f8b4fa067 100644 --- a/rs-matter/src/dm/clusters/app/level_control.rs +++ b/rs-matter/src/dm/clusters/app/level_control.rs @@ -1818,7 +1818,7 @@ pub mod scenes { AttributeValuePairStruct, AttributeValuePairStructArrayBuilder, }; use crate::dm::clusters::scenes::{SceneClusterHandler, SceneContext}; - use crate::dm::{ClusterId, EndptId, InvokeContext}; + use crate::dm::{AsyncHandler, ClusterId, EndptId, InvokeContext}; use crate::error::Error; use crate::tlv::{Nullable, TLVArray, TLVBuilderParent, TLVTag, TLVWriteParent}; use crate::utils::storage::WriteBuf; @@ -1858,14 +1858,15 @@ pub mod scenes { impl SceneClusterHandler for LevelControlSceneClusterHandler { const CLUSTER_ID: ClusterId = FULL_CLUSTER.id; - async fn capture( + async fn capture( &self, - sctx: &SceneContext, + sctx: &SceneContext, endpoint_id: EndptId, avp_array: AttributeValuePairStructArrayBuilder

, ) -> Result, Error> where C: InvokeContext, + T: AsyncHandler, P: TLVBuilderParent, { // `CurrentLevel` is `nullable int8u`. Null → skip the AVP @@ -1880,15 +1881,16 @@ pub mod scenes { } } - async fn apply( + async fn apply( &self, - sctx: &SceneContext, + sctx: &SceneContext, endpoint_id: EndptId, transition_time_ms: u32, avp_list: &TLVArray<'_, AttributeValuePairStruct<'_>>, ) -> Result<(), Error> where C: InvokeContext, + T: AsyncHandler, { for avp in avp_list.iter() { let avp = avp?; diff --git a/rs-matter/src/dm/clusters/app/on_off.rs b/rs-matter/src/dm/clusters/app/on_off.rs index 9e9cf7d26..58ec1f34f 100644 --- a/rs-matter/src/dm/clusters/app/on_off.rs +++ b/rs-matter/src/dm/clusters/app/on_off.rs @@ -1111,7 +1111,7 @@ pub mod scenes { AttributeValuePairStruct, AttributeValuePairStructArrayBuilder, }; use crate::dm::clusters::scenes::{SceneClusterHandler, SceneContext}; - use crate::dm::{ClusterId, EndptId, InvokeContext}; + use crate::dm::{AsyncHandler, ClusterId, EndptId, InvokeContext}; use crate::error::Error; use crate::tlv::{TLVArray, TLVBuilderParent}; @@ -1129,14 +1129,15 @@ pub mod scenes { impl SceneClusterHandler for OnOffSceneClusterHandler { const CLUSTER_ID: ClusterId = FULL_CLUSTER.id; - async fn capture( + async fn capture( &self, - sctx: &SceneContext, + sctx: &SceneContext, endpoint_id: EndptId, avp_array: AttributeValuePairStructArrayBuilder

, ) -> Result, Error> where C: InvokeContext, + T: AsyncHandler, P: TLVBuilderParent, { // `OnOff.OnOff` is bool; serialize as `valueUnsigned8` @@ -1147,15 +1148,16 @@ pub mod scenes { avp_array.push_u8(AttributeId::OnOff as _, v as u8) } - async fn apply( + async fn apply( &self, - sctx: &SceneContext, + sctx: &SceneContext, endpoint_id: EndptId, _transition_time_ms: u32, avp_list: &TLVArray<'_, AttributeValuePairStruct<'_>>, ) -> Result<(), Error> where C: InvokeContext, + T: AsyncHandler, { for avp in avp_list.iter() { let avp = avp?; diff --git a/rs-matter/src/dm/clusters/scenes.rs b/rs-matter/src/dm/clusters/scenes.rs index e20ec9695..0e4ab6951 100644 --- a/rs-matter/src/dm/clusters/scenes.rs +++ b/rs-matter/src/dm/clusters/scenes.rs @@ -77,13 +77,13 @@ use core::num::NonZeroU8; use crate::dm::{ ArrayAttributeRead, AsyncHandler, AttrDetails, AttrId, Cluster, ClusterId, CmdDetails, CmdId, - Dataver, EndptId, InvokeContext, InvokeContextInstance, InvokeReplyInstance, Metadata, - ReadContext, ReadContextInstance, ReadReply, Reply, SceneId, + Dataver, EmptyHandler, EndptId, InvokeContext, InvokeContextInstance, InvokeReplyInstance, + Metadata, ReadContext, ReadContextInstance, ReadReply, Reply, SceneId, }; use crate::error::{Error, ErrorCode}; use crate::tlv::{ - FromTLV, TLVArray, TLVBuilderParent, TLVElement, TLVTag, TLVWrite, TLVWriteParent, TagType, - ToTLV, + FromTLV, Nullable, OptionalBuilder, TLVArray, TLVBuilder, TLVBuilderParent, TLVElement, + TLVSequence, TLVTag, TLVWrite, TLVWriteParent, TagType, ToTLV, }; use crate::utils::cell::RefCell; use crate::utils::init::{init, Init}; @@ -96,6 +96,17 @@ pub use crate::dm::clusters::decl::scenes_management::*; /// "Generic Usage Notes" in the Matter Application Cluster spec). const SC_NOT_FOUND: u8 = 0x8B; const SC_INSUFFICIENT_SPACE: u8 = 0x89; +/// IM-level `INVALID_COMMAND` (0x85). Returned by every group-aware +/// Scenes command when `GroupID != 0` is not present in the Groups +/// cluster's Group Table for `(fab_idx, endpoint_id)` — per Matter +/// Application Cluster spec §1.4.9 "Common per-command behavior". +const SC_INVALID_COMMAND: u8 = 0x85; +/// IM-level `CONSTRAINT_ERROR` (0x87). Returned by every Scenes +/// command that takes a `SceneID` when the value is `0xFF`, which the +/// spec reserves as invalid (valid range is `0x00 – 0xFE`). +const SC_CONSTRAINT_ERROR: u8 = 0x87; +/// Reserved (invalid) `SceneID` value per Matter App Cluster spec. +const RESERVED_SCENE_ID: SceneId = 0xFF; /// Max length of the serialized `ExtensionFieldSetStructs` payload /// carried on a single scene record. Per chip's notes a Color Control @@ -131,14 +142,15 @@ pub trait SceneClusterHandler { /// one-line per-attribute API). /// /// Returns the (advanced) builder so the caller can close the array. - fn capture( + fn capture( &self, - sctx: &SceneContext, + sctx: &SceneContext, endpoint_id: EndptId, avp_array: AttributeValuePairStructArrayBuilder

, ) -> impl Future, Error>> where C: InvokeContext, + T: AsyncHandler, P: TLVBuilderParent; /// Apply the captured attribute values by invoking the right @@ -147,15 +159,16 @@ pub trait SceneClusterHandler { /// `transition_time_ms` is the effective transition for this /// recall (either the `RecallScene` request override or the stored /// value). - fn apply( + fn apply( &self, - sctx: &SceneContext, + sctx: &SceneContext, endpoint_id: EndptId, transition_time_ms: u32, avp_list: &TLVArray<'_, AttributeValuePairStruct<'_>>, ) -> impl Future> where - C: InvokeContext; + C: InvokeContext, + T: AsyncHandler; } /// A tuple-recursive composition of [`SceneClusterHandler`]s, mirroring @@ -180,49 +193,52 @@ pub trait SceneClusters { /// [`SceneEntry::extension_fields`]'s "contents + 0x18" storage /// shape without needing an extra `+ 1` byte to absorb a leading /// control byte. - fn capture( + fn capture( &self, - sctx: &SceneContext, + sctx: &SceneContext, endpoint_id: EndptId, parent: P, ) -> impl Future> where C: InvokeContext, + T: AsyncHandler, P: TLVBuilderParent; /// Find the registered cluster matching `cluster_id` and let it /// apply `avp_list`. Returns `Ok(true)` if a cluster handled it, /// `Ok(false)` if no registered cluster matches (the entry is /// silently skipped, matching chip's behavior). - fn apply( + fn apply( &self, - sctx: &SceneContext, + sctx: &SceneContext, endpoint_id: EndptId, cluster_id: ClusterId, transition_time_ms: u32, avp_list: &TLVArray<'_, AttributeValuePairStruct<'_>>, ) -> impl Future> where - C: InvokeContext; + C: InvokeContext, + T: AsyncHandler; } impl SceneClusters for () { - fn capture( + fn capture( &self, - _sctx: &SceneContext, + _sctx: &SceneContext, _endpoint_id: EndptId, parent: P, ) -> impl Future> where C: InvokeContext, + T: AsyncHandler, P: TLVBuilderParent, { ready(Ok(parent)) } - fn apply( + fn apply( &self, - _sctx: &SceneContext, + _sctx: &SceneContext, _endpoint_id: EndptId, _cluster_id: ClusterId, _transition_time_ms: u32, @@ -230,6 +246,7 @@ impl SceneClusters for () { ) -> impl Future> where C: InvokeContext, + T: AsyncHandler, { ready(Ok(false)) } @@ -240,14 +257,15 @@ where H: SceneClusterHandler, T: SceneClusters, { - async fn capture( + async fn capture( &self, - sctx: &SceneContext, + sctx: &SceneContext, endpoint_id: EndptId, parent: P, ) -> Result where C: InvokeContext, + U: AsyncHandler, P: TLVBuilderParent, { let parent = if sctx.cluster_present(endpoint_id, H::CLUSTER_ID) { @@ -267,9 +285,9 @@ where self.1.capture(sctx, endpoint_id, parent).await } - async fn apply( + async fn apply( &self, - sctx: &SceneContext, + sctx: &SceneContext, endpoint_id: EndptId, cluster_id: ClusterId, transition_time_ms: u32, @@ -277,6 +295,7 @@ where ) -> Result where C: InvokeContext, + U: AsyncHandler, { if H::CLUSTER_ID == cluster_id { self.0 @@ -315,11 +334,11 @@ where /// /// All three go through the global handler (`ctx.handler()`), matching /// the invariant noted on [`SceneClusterHandler`]. -pub struct SceneContext(C); +pub struct SceneContext(C, T); -impl SceneContext { - pub const fn new(ctx: C) -> Self { - Self(ctx) +impl SceneContext { + pub const fn new(ctx: C, handler: T) -> Self { + Self(ctx, handler) } /// The wrapped [`InvokeContext`]. Useful when a cluster impl needs @@ -334,24 +353,23 @@ impl SceneContext { &self.0 } - /// Read one attribute via the global handler and decode it as - /// `T`. + /// Read one attribute via the global handler and decode it as `Q`. /// /// Drives [`AsyncHandler::read`] with a custom reply that /// captures the value bytes (TLV-encoded with anonymous tag) into - /// a stack buffer, then decodes them as `T` via `FromTLV`. The - /// `T: for<'b> FromTLV<'b>` bound restricts use to types that + /// a stack buffer, then decodes them as `Q` via `FromTLV`. The + /// `Q: for<'b> FromTLV<'b>` bound restricts use to types that /// don't borrow from the TLV bytes (primitives, `Nullable`, /// enums, …) — which covers all scalar-valued attributes scene /// capture cares about. - pub async fn read( + pub async fn read( &self, endpoint_id: EndptId, cluster_id: ClusterId, attr_id: AttrId, - ) -> Result + ) -> Result where - T: for<'b> FromTLV<'b>, + Q: for<'b> FromTLV<'b>, { let mut buf = [0u8; 16]; let mut wb = WriteBuf::new(&mut buf); @@ -373,12 +391,13 @@ impl SceneContext { cluster_status: Cell::new(0), }; - let handler = self.0.handler(); + //let handler = self.0.handler(); let read_ctx = ReadContextInstance::new(self.0.exchange(), &self.0, &attr); let reply = CaptureReply { wb: &mut wb }; - handler.read(read_ctx, reply).await?; + //handler.read(read_ctx, reply).await?; + self.1.read(read_ctx, reply).await?; - T::from_tlv(&TLVElement::new(wb.as_slice())) + Q::from_tlv(&TLVElement::new(wb.as_slice())) } /// Dispatch a cross-cluster command through `ctx.handler().invoke()`. @@ -406,9 +425,10 @@ impl SceneContext { let mut response_wb = WriteBuf::new(&mut response_buf); let reply = InvokeReplyInstance::new(&cmd, &mut response_wb); - let handler = self.0.handler(); + //let handler = self.0.handler(); let inv_ctx = InvokeContextInstance::new(self.0.exchange(), &self.0, &cmd, &data_elem); - handler.invoke(inv_ctx, reply).await + //handler.invoke(inv_ctx, reply).await + self.1.invoke(inv_ctx, reply).await } /// Check whether `cluster_id` is exposed on `endpoint_id` per the @@ -668,23 +688,37 @@ impl Default for ScenesState { /// CopyScene) in isolation. `M` mirrors the same parameter on /// [`ScenesState`] (per-scene blob capacity, defaults to /// [`MAX_EXT_FIELDS_LEN`]). -pub struct ScenesHandler<'a, const N: usize, R = (), const M: usize = MAX_EXT_FIELDS_LEN> -where +pub struct ScenesHandler< + 'a, + const N: usize, + T = EmptyHandler, + R = (), + const M: usize = MAX_EXT_FIELDS_LEN, +> where + T: AsyncHandler, R: SceneClusters, { dataver: Dataver, state: &'a ScenesState, + handler: T, clusters: R, } -impl<'a, const N: usize, R, const M: usize> ScenesHandler<'a, N, R, M> +impl<'a, const N: usize, T, R, const M: usize> ScenesHandler<'a, N, T, R, M> where + T: AsyncHandler, R: SceneClusters, { - pub const fn new(dataver: Dataver, state: &'a ScenesState, clusters: R) -> Self { + pub const fn new( + dataver: Dataver, + state: &'a ScenesState, + handler: T, + clusters: R, + ) -> Self { Self { dataver, state, + handler, clusters, } } @@ -697,6 +731,46 @@ where ctx.exchange().accessor()?.fab_idx() } + /// Per-fabric remaining-capacity estimate used by both + /// `GetSceneMembership::Capacity` and + /// `FabricSceneInfo::RemainingCapacity`. Per chip's reference + /// implementation (and the `Test_TC_S_*` certification suites), + /// the formula is **`(N - 1) / 2 − scenes_in_this_fabric`**: + /// `N - 1` slack reserves one row for inter-fabric arbitration, + /// `/ 2` splits the remaining budget evenly across the + /// (typically two) fabrics the spec expects to share the table. + /// The result is saturated at 0 and clamped to `0xFF` for u8. + fn remaining_capacity_for_fab(inner: &ScenesStateInner, fab_idx: NonZeroU8) -> u8 { + let per_fab_budget = N.saturating_sub(1) / 2; + let used = inner.table.iter().filter(|e| e.fab_idx == fab_idx).count(); + per_fab_budget.saturating_sub(used).min(0xFF) as u8 + } + + /// Check whether `group_id` is present in the Groups cluster's + /// Group Table for `(fab_idx, endpoint_id)`. Every group-aware + /// Scenes command must reject with `SC_INVALID_COMMAND` when this + /// returns `false`. `group_id == 0` is treated as "always valid" + /// matching the spec's special handling of the reserved no-group + /// ID. + fn group_in_table( + ctx: &C, + fab_idx: NonZeroU8, + endpoint_id: EndptId, + group_id: u16, + ) -> Result { + if group_id == 0 { + return Ok(true); + } + ctx.exchange().with_state(|state| { + let fabric = state.fabrics.fabric(fab_idx)?; + Ok(fabric + .groups() + .get(group_id) + .map(|g| g.endpoints.contains(&endpoint_id)) + .unwrap_or(false)) + }) + } + /// Stamp `(group, scene)` as the current recalled scene for this /// fabric. Bumps `FabricSceneInfo` dataver. Operates on already- /// locked inner state. @@ -861,7 +935,7 @@ where Some(c) => (Some(c.group_id), Some(c.scene_id), true), None => (None, None, false), }; - let rem = (N.saturating_sub(inner.table.len())).min(0xFF) as u8; + let rem = Self::remaining_capacity_for_fab(inner, accessor_fab_idx); (count.min(0xFF) as u8, g, s, v, rem) }); @@ -900,6 +974,26 @@ where let scene_id = request.scene_id()?; let transition_time = request.transition_time()?; + // Spec: `CONSTRAINT_ERROR` for the reserved `SceneID = 0xFF`. + // Checked before the group-table existence check. + if scene_id == RESERVED_SCENE_ID { + return response + .status(SC_CONSTRAINT_ERROR)? + .group_id(group_id)? + .scene_id(scene_id)? + .end(); + } + + // Spec: `INVALID_COMMAND` when `group_id != 0` is absent from + // the Groups cluster's Group Table for this endpoint. + if !Self::group_in_table(ctx, fab_idx, endpoint_id, group_id)? { + return response + .status(SC_INVALID_COMMAND)? + .group_id(group_id)? + .scene_id(scene_id)? + .end(); + } + // Capture the `ExtensionFieldSetStructs` array payload from the // request — store the *value* bytes (contents-plus-terminator // of the array container), which is what `ViewScene` and @@ -1038,6 +1132,33 @@ where let group_id = request.group_id()?; let scene_id = request.scene_id()?; + // Spec: `CONSTRAINT_ERROR` for the reserved `SceneID = 0xFF`. + if scene_id == RESERVED_SCENE_ID { + return response + .status(SC_CONSTRAINT_ERROR)? + .group_id(group_id)? + .scene_id(scene_id)? + .transition_time(None)? + .scene_name(None)? + .extension_field_set_structs()? + .none() + .end(); + } + + // Spec: `INVALID_COMMAND` when `group_id != 0` is absent from + // the Groups cluster's Group Table for this endpoint. + if !Self::group_in_table(ctx, fab_idx, endpoint_id, group_id)? { + return response + .status(SC_INVALID_COMMAND)? + .group_id(group_id)? + .scene_id(scene_id)? + .transition_time(None)? + .scene_name(None)? + .extension_field_set_structs()? + .none() + .end(); + } + // Build the response *inside* the lock so we can splice the // stored `extension_fields` blob (a `&[u8]` borrow into the // table) without cloning it onto the stack. The TLV builder @@ -1089,15 +1210,11 @@ where /// the destination tag and then write the stored bytes via /// `TLVWrite::write_raw_data`. Empty blob ⇒ skip the field /// entirely via `OptionalBuilder::none`. - fn write_blob_or_none( - mut opt: crate::tlv::OptionalBuilder, - blob: &[u8], - ) -> Result + fn write_blob_or_none(mut opt: OptionalBuilder, blob: &[u8]) -> Result where P: TLVBuilderParent, - T: crate::tlv::TLVBuilder

, + Q: TLVBuilder

, { - use crate::tlv::{TLVTag, TLVWrite}; if !blob.is_empty() { // Tag is hard-coded as the spec field number for both // `ViewSceneResponse.ExtensionFieldSetStructs` and other @@ -1125,6 +1242,25 @@ where let group_id = request.group_id()?; let scene_id = request.scene_id()?; + // Spec: `CONSTRAINT_ERROR` for the reserved `SceneID = 0xFF`. + if scene_id == RESERVED_SCENE_ID { + return response + .status(SC_CONSTRAINT_ERROR)? + .group_id(group_id)? + .scene_id(scene_id)? + .end(); + } + + // Spec: `INVALID_COMMAND` when `group_id != 0` is absent from + // the Groups cluster's Group Table for this endpoint. + if !Self::group_in_table(ctx, fab_idx, endpoint_id, group_id)? { + return response + .status(SC_INVALID_COMMAND)? + .group_id(group_id)? + .scene_id(scene_id)? + .end(); + } + let status: u8 = self.state.with(|inner| { if let Some(pos) = inner .table @@ -1160,6 +1296,15 @@ where let endpoint_id = ctx.cmd().endpoint_id; let group_id = request.group_id()?; + // Spec: `INVALID_COMMAND` if `group_id != 0` is absent from + // the Groups cluster's Group Table for this endpoint. + if !Self::group_in_table(ctx, fab_idx, endpoint_id, group_id)? { + return response + .status(SC_INVALID_COMMAND)? + .group_id(group_id)? + .end(); + } + let removed = self.state.with(|inner| { let before = inner.table.len(); inner.table.retain(|e| { @@ -1200,6 +1345,25 @@ where let group_id = request.group_id()?; let scene_id = request.scene_id()?; + // Spec: `CONSTRAINT_ERROR` for the reserved `SceneID = 0xFF`. + if scene_id == RESERVED_SCENE_ID { + return response + .status(SC_CONSTRAINT_ERROR)? + .group_id(group_id)? + .scene_id(scene_id)? + .end(); + } + + // Spec: `INVALID_COMMAND` when `group_id != 0` is absent from + // the Groups cluster's Group Table for this endpoint. + if !Self::group_in_table(ctx, fab_idx, endpoint_id, group_id)? { + return response + .status(SC_INVALID_COMMAND)? + .group_id(group_id)? + .scene_id(scene_id)? + .end(); + } + // Capture the EFS blob on the stack via the cluster registry. // Doing this *before* the mutex acquire keeps async IO // (`ctx.handler().read()`) out of the critical section. @@ -1210,7 +1374,8 @@ where // the "contents + 0x18 terminator" shape that // [`SceneEntry::extension_fields`] stores — no leading byte // to strip, no `MAX_EXT_FIELDS_LEN + 1` slack needed. - let sctx = SceneContext::new(ctx); + //let sctx = SceneContext::new(ctx, &self.handler); + let sctx = SceneContext::new(ctx, &self.handler); let mut scratch = [0u8; M]; let total_len = { let mut wb = WriteBuf::new(&mut scratch); @@ -1286,6 +1451,23 @@ where let group_id = request.group_id()?; let scene_id = request.scene_id()?; + // `RecallScene` has no response struct (returns `()`), so we + // surface the status via `set_cluster_status` and bubble out + // an error. + + // Spec: `CONSTRAINT_ERROR` for the reserved `SceneID = 0xFF`. + if scene_id == RESERVED_SCENE_ID { + ctx.cmd().set_cluster_status(SC_CONSTRAINT_ERROR); + return Err(ErrorCode::Failure.into()); + } + + // Spec: `INVALID_COMMAND` when `group_id != 0` is absent from + // the Groups cluster's Group Table for this endpoint. + if !Self::group_in_table(ctx, fab_idx, endpoint_id, group_id)? { + ctx.cmd().set_cluster_status(SC_INVALID_COMMAND); + return Err(ErrorCode::Failure.into()); + } + // RecallScene's request carries an optional+nullable // transition-time override (ms). Present-and-non-null wins // over the stored record's transition time; otherwise fall @@ -1325,8 +1507,8 @@ where let effective_tt_ms = override_tt_ms.unwrap_or(stored_tt_ms); - let sctx = SceneContext::new(ctx); - for efs_element in crate::tlv::TLVSequence(&blob[..blob_len]).iter() { + let sctx = SceneContext::new(ctx, &self.handler); + for efs_element in TLVSequence(&blob[..blob_len]).iter() { let efs = ExtensionFieldSetStruct::new(efs_element?); let cluster_id = efs.cluster_id()?; let avp_list = efs.attribute_value_list()?; @@ -1357,29 +1539,39 @@ where let endpoint_id = ctx.cmd().endpoint_id; let group_id = request.group_id()?; + // Spec: `INVALID_COMMAND` when `group_id != 0` isn't in the + // Groups cluster's Group Table on this endpoint. Capacity is + // reported as `null` in that case (per the + // `Test_TC_S_2_2` spec table — `anyOf [fabricCapacity, 0xfe, + // null]`, we pick `null`). + if !Self::group_in_table(ctx, fab_idx, endpoint_id, group_id)? { + return response + .status(SC_INVALID_COMMAND)? + .capacity(Nullable::none())? + .group_id(group_id)? + .scene_list()? + .none() + .end(); + } + // Build the response directly inside the lock — the TLV // builder is purely synchronous (no `.await`), so holding the // lock for the write is cheap. This avoids snapshotting scene // IDs into a stack `Vec` (could be ~N bytes; // matters on small-stack MCUs). self.state.with(|inner| -> Result { - let remaining = (N.saturating_sub(inner.table.len())).min(0xFF) as u8; - let group_has_scenes = inner.table.iter().any(|e| { - e.fab_idx == fab_idx && e.endpoint_id == endpoint_id && e.group_id == group_id - }); + let remaining = Self::remaining_capacity_for_fab(inner, fab_idx); let resp = response .status(0)? - .capacity(crate::tlv::Nullable::some(remaining))? + .capacity(Nullable::some(remaining))? .group_id(group_id)?; - // Per the `GetSceneMembership` command spec: when GroupID - // has no scenes on this device, SceneList SHALL be - // omitted (None). - if !group_has_scenes { - return resp.scene_list()?.none().end(); - } - + // The `SceneList` optional field is *always present* on + // the success path — empty when the group has no scenes + // on this device, populated otherwise. The chip-tool + // certification suites (`Test_TC_S_2_3` step 1f) assert + // the field exists rather than being omitted. let list = resp.scene_list()?.some()?; let list = inner .table @@ -1411,6 +1603,30 @@ where // are ignored when set). let copy_all = (mode.bits() & 0x01) != 0; + // Spec: `CONSTRAINT_ERROR` for the reserved `SceneID = 0xFF` + // on either `scene_from` or `scene_to` — but only when the + // single-scene mode actually uses them. + if !copy_all && (scene_from == RESERVED_SCENE_ID || scene_to == RESERVED_SCENE_ID) { + return response + .status(SC_CONSTRAINT_ERROR)? + .group_identifier_from(group_from)? + .scene_identifier_from(scene_from)? + .end(); + } + + // Spec: `INVALID_COMMAND` when EITHER `group_from` or + // `group_to` (when non-zero) is absent from the Groups + // cluster's Group Table for this endpoint. + if !Self::group_in_table(ctx, fab_idx, endpoint_id, group_from)? + || !Self::group_in_table(ctx, fab_idx, endpoint_id, group_to)? + { + return response + .status(SC_INVALID_COMMAND)? + .group_identifier_from(group_from)? + .scene_identifier_from(scene_from)? + .end(); + } + // The whole "look up source + copy entries" operation runs // under one lock so the table can't change mid-copy. let status = self.state.with(|inner| { @@ -1492,8 +1708,9 @@ impl Reply for CaptureReplyWriter<'_, '_> { } } -impl ClusterAsyncHandler for ScenesHandler<'_, N, R, M> +impl ClusterAsyncHandler for ScenesHandler<'_, N, T, R, M> where + T: AsyncHandler, R: SceneClusters, { /// FULL_CLUSTER minus the SceneNames feature (we accept the field diff --git a/xtask/src/itest.rs b/xtask/src/itest.rs index ddc70dda9..5e510967d 100644 --- a/xtask/src/itest.rs +++ b/xtask/src/itest.rs @@ -480,6 +480,38 @@ pub(crate) const LIGHT_TESTS: &[&str] = &[ // "Test_TC_OO_2_7", // TODO: not yet passing ]; +/// Scenes Management YAML tests — run against the `scenes_tests` example. +/// +/// Targets the Scenes Management cluster (`0x0062`) on EP1 of a +/// dimmable-light-style endpoint with OnOff + LevelControl + Scenes wired +/// together. The non-`Test_TC_S_*` entries are the chip-tool composite +/// YAML suites (multi-fabric, fabric removal, max capacity, fabric scene +/// info) which exercise less obvious end-to-end behaviour. +/// +/// Listed tests are expected to pass; commented entries with a `TODO` +/// are known-failing or not-yet-verified and tracked separately. +pub(crate) const SCENES_TESTS: &[&str] = &[ + // Attribute-read coverage (SceneTableSize, FabricSceneInfo). + "Test_TC_S_2_1", + // `Test_TC_S_2_2` — TODO: passes through step 3 but step 4 + // reboots the DUT and expects the stored scene to survive, + // which requires scene-table persistence to KV storage. Tracked + // separately; re-enable once persistence lands. + // "Test_TC_S_2_2", + "Test_TC_S_2_3", + "Test_TC_S_2_4", + "Test_TC_S_2_5", + "Test_TC_S_2_6", + // Effect-on-receipt cross-cluster checks (RecallScene actually + // applies OnOff / LevelControl state via cross-cluster commands). + "Test_TC_S_3_1", + // Composite suites — uncomment once the per-test cases above pass. + // "TestScenesMultiFabric", // TODO: not yet verified + // "TestScenesFabricRemoval", // TODO: not yet verified + // "TestScenesFabricSceneInfo", // TODO: not yet verified + // "TestScenesMaxCapacity", // TODO: not yet verified +]; + /// A pre-canned test suite. Selects a default test list, the example /// binary they run against, the cargo features it must be built with, /// and a per-test timeout suitable for that suite. @@ -502,6 +534,13 @@ pub(crate) enum TestSuite { Camera, /// OnOff + LevelControl, exercising the dimmable_light example. Light, + /// Scenes Management (`0x0062`) — exercises Add/View/Remove/Store/ + /// Recall/GetSceneMembership/CopyScene plus cross-cluster apply + /// (RecallScene actually invokes OnOff/LevelControl commands). Runs + /// against the `scenes_tests` example which wires Scenes onto EP1 + /// of an OnOff+LevelControl device, with the per-cluster + /// `SceneClusterHandler` impls registered. + Scenes, /// **Inverted** suite — rs-matter as the **commissioner** driving /// upstream `chip-all-clusters-app` (the device under test). Builds /// both binaries, spawns the CHIP app on `[::1]:` with known @@ -531,6 +570,7 @@ impl TestSuite { .collect(), Self::Camera => CAMERA_TESTS.to_vec(), Self::Light => LIGHT_TESTS.to_vec(), + Self::Scenes => SCENES_TESTS.to_vec(), // One synthetic case — the dispatch in `ITests::run` picks // this up and routes to `run_commissioner_suite`, which // ignores the test list (there's nothing to parameterise yet). @@ -544,6 +584,7 @@ impl TestSuite { Self::System | Self::SystemPython | Self::SystemYaml => "chip_tool_tests", Self::Camera => "camera_tests", Self::Light => "dimmable_light", + Self::Scenes => "scenes_tests", Self::Commissioner => "commissioner_tests", } } @@ -551,7 +592,12 @@ impl TestSuite { /// Cargo features the example binary must be built with for this suite. pub(crate) fn default_features(&self) -> &'static [&'static str] { match self { - Self::System | Self::SystemPython | Self::SystemYaml | Self::Camera => &[], + // `scenes_tests` is a pure test binary (like + // `chip_tool_tests` / `camera_tests`) — no `chip-test` + // feature gate. + Self::System | Self::SystemPython | Self::SystemYaml | Self::Camera | Self::Scenes => { + &[] + } Self::Light => &["chip-test"], Self::Commissioner => &[], } @@ -563,6 +609,9 @@ impl TestSuite { Self::System | Self::SystemPython | Self::SystemYaml => 120, Self::Camera => 180, Self::Light => 500, + // Scenes tests include some long composite YAML suites + // (multi-fabric/max-capacity); match `Light`'s budget. + Self::Scenes => 500, Self::Commissioner => 120, } } From ef5152c78b349d62f63b7c2c1c0a771d05b39916 Mon Sep 17 00:00:00 2001 From: ivmarkov Date: Sat, 30 May 2026 07:11:57 +0000 Subject: [PATCH 05/15] Persistence --- examples/src/bin/scenes_tests.rs | 7 + .../src/dm/clusters/app/color_control.rs | 25 +- .../src/dm/clusters/app/level_control.rs | 8 +- rs-matter/src/dm/clusters/app/on_off.rs | 8 +- rs-matter/src/dm/clusters/scenes.rs | 274 ++++++++++++++++-- rs-matter/src/persist.rs | 12 + xtask/src/itest.rs | 9 +- 7 files changed, 314 insertions(+), 29 deletions(-) diff --git a/examples/src/bin/scenes_tests.rs b/examples/src/bin/scenes_tests.rs index 541ac32ce..28e70aea9 100644 --- a/examples/src/bin/scenes_tests.rs +++ b/examples/src/bin/scenes_tests.rs @@ -200,6 +200,13 @@ fn run() -> Result<(), Error> { // LevelControl ship a ZST impl in their respective modules // (`app::on_off::scenes` / `app::level_control::scenes`). let scenes_state = SCENES_STATE.uninit().init_with(ScenesState::init()); + // Restore the scene table + per-fabric `CurrentScene` bookkeeping + // from KV — re-applies any scenes a previous run of this binary + // stored under `SCENES_KEY`. Must run before the data model goes + // live so `RecallScene` on the very first commission step (after + // a reboot in the middle of `Test_TC_S_2_2`) sees the persisted + // entries. + futures_lite::future::block_on(scenes_state.load_persist(&mut kv, kv_buf))?; let scenes_handler = ScenesHandler::new( Dataver::new_rand(&mut rand), scenes_state, diff --git a/rs-matter/src/dm/clusters/app/color_control.rs b/rs-matter/src/dm/clusters/app/color_control.rs index ebc107358..4a8b50cc5 100644 --- a/rs-matter/src/dm/clusters/app/color_control.rs +++ b/rs-matter/src/dm/clusters/app/color_control.rs @@ -60,7 +60,7 @@ pub mod scenes { AttributeValuePairStruct, AttributeValuePairStructArrayBuilder, }; use crate::dm::clusters::scenes::{SceneClusterHandler, SceneContext}; - use crate::dm::{AsyncHandler, ClusterId, EndptId, InvokeContext}; + use crate::dm::{AsyncHandler, AttrId, ClusterId, EndptId, InvokeContext}; use crate::error::Error; use crate::tlv::{TLVArray, TLVBuilderParent, TLVTag, TLVWriteParent}; use crate::utils::storage::WriteBuf; @@ -139,6 +139,29 @@ pub mod scenes { impl SceneClusterHandler for ColorControlSceneClusterHandler<'_> { const CLUSTER_ID: ClusterId = FULL_CLUSTER.id; + fn is_scenable_attribute(attribute_id: AttrId) -> bool { + // Per Matter App Cluster spec §3.2.10 the scenable + // attributes for ColorControl are: `CurrentX`, `CurrentY`, + // `EnhancedCurrentHue`, `CurrentSaturation`, + // `ColorLoopActive`, `ColorLoopDirection`, `ColorLoopTime`, + // `ColorTemperatureMireds`, `EnhancedColorMode`. Feature + // availability is checked at recall time (see `apply`), + // not here — `is_scenable_attribute` only validates the + // shape of an `AddScene` payload. + matches!( + attribute_id, + a if a == AttributeId::CurrentX as AttrId + || a == AttributeId::CurrentY as AttrId + || a == AttributeId::EnhancedCurrentHue as AttrId + || a == AttributeId::CurrentSaturation as AttrId + || a == AttributeId::ColorLoopActive as AttrId + || a == AttributeId::ColorLoopDirection as AttrId + || a == AttributeId::ColorLoopTime as AttrId + || a == AttributeId::ColorTemperatureMireds as AttrId + || a == AttributeId::EnhancedColorMode as AttrId + ) + } + async fn capture( &self, sctx: &SceneContext, diff --git a/rs-matter/src/dm/clusters/app/level_control.rs b/rs-matter/src/dm/clusters/app/level_control.rs index f8b4fa067..a7936ca2c 100644 --- a/rs-matter/src/dm/clusters/app/level_control.rs +++ b/rs-matter/src/dm/clusters/app/level_control.rs @@ -1818,7 +1818,7 @@ pub mod scenes { AttributeValuePairStruct, AttributeValuePairStructArrayBuilder, }; use crate::dm::clusters::scenes::{SceneClusterHandler, SceneContext}; - use crate::dm::{AsyncHandler, ClusterId, EndptId, InvokeContext}; + use crate::dm::{AsyncHandler, AttrId, ClusterId, EndptId, InvokeContext}; use crate::error::Error; use crate::tlv::{Nullable, TLVArray, TLVBuilderParent, TLVTag, TLVWriteParent}; use crate::utils::storage::WriteBuf; @@ -1858,6 +1858,12 @@ pub mod scenes { impl SceneClusterHandler for LevelControlSceneClusterHandler { const CLUSTER_ID: ClusterId = FULL_CLUSTER.id; + fn is_scenable_attribute(attribute_id: AttrId) -> bool { + // Per Matter App Cluster spec, only `CurrentLevel` is the + // scenable attribute on LevelControl. + attribute_id == AttributeId::CurrentLevel as AttrId + } + async fn capture( &self, sctx: &SceneContext, diff --git a/rs-matter/src/dm/clusters/app/on_off.rs b/rs-matter/src/dm/clusters/app/on_off.rs index 58ec1f34f..d45a94b33 100644 --- a/rs-matter/src/dm/clusters/app/on_off.rs +++ b/rs-matter/src/dm/clusters/app/on_off.rs @@ -1111,7 +1111,7 @@ pub mod scenes { AttributeValuePairStruct, AttributeValuePairStructArrayBuilder, }; use crate::dm::clusters::scenes::{SceneClusterHandler, SceneContext}; - use crate::dm::{AsyncHandler, ClusterId, EndptId, InvokeContext}; + use crate::dm::{AsyncHandler, AttrId, ClusterId, EndptId, InvokeContext}; use crate::error::Error; use crate::tlv::{TLVArray, TLVBuilderParent}; @@ -1129,6 +1129,12 @@ pub mod scenes { impl SceneClusterHandler for OnOffSceneClusterHandler { const CLUSTER_ID: ClusterId = FULL_CLUSTER.id; + fn is_scenable_attribute(attribute_id: AttrId) -> bool { + // Per Matter App Cluster spec, only `OnOff` (the cluster's + // namesake) is the scenable attribute. + attribute_id == AttributeId::OnOff as AttrId + } + async fn capture( &self, sctx: &SceneContext, diff --git a/rs-matter/src/dm/clusters/scenes.rs b/rs-matter/src/dm/clusters/scenes.rs index 0e4ab6951..bf42b9e94 100644 --- a/rs-matter/src/dm/clusters/scenes.rs +++ b/rs-matter/src/dm/clusters/scenes.rs @@ -77,13 +77,14 @@ use core::num::NonZeroU8; use crate::dm::{ ArrayAttributeRead, AsyncHandler, AttrDetails, AttrId, Cluster, ClusterId, CmdDetails, CmdId, - Dataver, EmptyHandler, EndptId, InvokeContext, InvokeContextInstance, InvokeReplyInstance, - Metadata, ReadContext, ReadContextInstance, ReadReply, Reply, SceneId, + Dataver, EmptyHandler, EndptId, HandlerContext, InvokeContext, InvokeContextInstance, + InvokeReplyInstance, Metadata, ReadContext, ReadContextInstance, ReadReply, Reply, SceneId, }; use crate::error::{Error, ErrorCode}; +use crate::persist::{KvBlobStore, Persist}; use crate::tlv::{ FromTLV, Nullable, OptionalBuilder, TLVArray, TLVBuilder, TLVBuilderParent, TLVElement, - TLVSequence, TLVTag, TLVWrite, TLVWriteParent, TagType, ToTLV, + TLVSequence, TLVTag, TLVWrite, TLVWriteParent, TagType, ToTLV, TLV, }; use crate::utils::cell::RefCell; use crate::utils::init::{init, Init}; @@ -91,6 +92,7 @@ use crate::utils::storage::{Vec, WriteBuf}; use crate::utils::sync::blocking::Mutex; pub use crate::dm::clusters::decl::scenes_management::*; +pub use crate::persist::SCENES_KEY; /// IM status codes specific to the Scenes Management cluster (see /// "Generic Usage Notes" in the Matter Application Cluster spec). @@ -107,6 +109,11 @@ const SC_INVALID_COMMAND: u8 = 0x85; const SC_CONSTRAINT_ERROR: u8 = 0x87; /// Reserved (invalid) `SceneID` value per Matter App Cluster spec. const RESERVED_SCENE_ID: SceneId = 0xFF; +/// Maximum legal `TransitionTime` value on `AddScene`, per Matter App +/// Cluster spec §1.4.7.1 "AddScene Command": +/// > The maximum value SHALL be 60 000 000 (1000 minutes). +/// Anything larger MUST be rejected with `CONSTRAINT_ERROR`. +const MAX_TRANSITION_TIME_MS: u32 = 60_000_000; /// Max length of the serialized `ExtensionFieldSetStructs` payload /// carried on a single scene record. Per chip's notes a Color Control @@ -134,6 +141,19 @@ pub trait SceneClusterHandler { /// to route apply dispatch. const CLUSTER_ID: ClusterId; + /// Return `true` if `attribute_id` is a scenable attribute of this + /// cluster per the Matter Application Cluster spec. Walked by + /// [`SceneClusters::check_scenable`] during `AddScene` to reject + /// `ExtensionFieldSetStructs` referencing non-scenable attributes + /// (the spec requires `INVALID_COMMAND` in that case; + /// `Test_TC_S_2_2` step 8g exercises it). + /// + /// Default impl rejects every attribute — concrete cluster + /// handlers MUST override. + fn is_scenable_attribute(_attribute_id: AttrId) -> bool { + false + } + /// Read this cluster's scene-able attributes via /// `sctx.read(...)` and emit zero-or-more /// `AttributeValuePairStruct` elements into `avp_array` (use @@ -204,6 +224,21 @@ pub trait SceneClusters { T: AsyncHandler, P: TLVBuilderParent; + /// Walk the registry looking for `cluster_id`. Returns: + /// + /// - `Some(true)` — `cluster_id` is registered and + /// `attribute_id` is scenable on that cluster (per + /// [`SceneClusterHandler::is_scenable_attribute`]). + /// - `Some(false)` — `cluster_id` is registered but + /// `attribute_id` is **not** scenable (`AddScene` MUST + /// reject with `INVALID_COMMAND`). + /// - `None` — `cluster_id` is not registered with the + /// Scenes handler. `AddScene` treats this as lenient (store + /// the bytes; `RecallScene` will silently skip them on + /// replay), matching chip's behaviour on a firmware + /// downgrade that drops a previously-scenable cluster. + fn check_scenable(&self, cluster_id: ClusterId, attribute_id: AttrId) -> Option; + /// Find the registered cluster matching `cluster_id` and let it /// apply `avp_list`. Returns `Ok(true)` if a cluster handled it, /// `Ok(false)` if no registered cluster matches (the entry is @@ -236,6 +271,10 @@ impl SceneClusters for () { ready(Ok(parent)) } + fn check_scenable(&self, _cluster_id: ClusterId, _attribute_id: AttrId) -> Option { + None + } + fn apply( &self, _sctx: &SceneContext, @@ -257,6 +296,14 @@ where H: SceneClusterHandler, T: SceneClusters, { + fn check_scenable(&self, cluster_id: ClusterId, attribute_id: AttrId) -> Option { + if cluster_id == H::CLUSTER_ID { + Some(H::is_scenable_attribute(attribute_id)) + } else { + self.1.check_scenable(cluster_id, attribute_id) + } + } + async fn capture( &self, sctx: &SceneContext, @@ -569,7 +616,11 @@ impl SceneEntry { /// Per-fabric "last recalled scene" pointer feeding /// `FabricSceneInfo.CurrentScene` / `CurrentGroup` / `SceneValid`. -#[derive(Debug, Clone, Copy)] +/// +/// `FromTLV` / `ToTLV` are derived (the type has no const generics, +/// unlike [`SceneEntry`]) — the persisted shape is a struct with +/// context-tagged fields auto-numbered 0..2 in source order. +#[derive(Debug, Clone, Copy, FromTLV, ToTLV)] #[cfg_attr(feature = "defmt", derive(defmt::Format))] struct CurrentScene { fab_idx: NonZeroU8, @@ -665,6 +716,143 @@ impl Default for ScenesState { } } +// --------------------------------------------------------------------- +// TLV round-trip used by the persistence layer. +// +// The whole [`ScenesStateInner`] is persisted as a single TLV struct +// under [`SCENES_KEY`] — the cross-fabric scene table plus the +// per-fabric `CurrentScene` bookkeeping. `info_dataver` is *not* +// persisted: the public `Dataver` on the handler is re-randomized at +// boot anyway, so any client cache will already see a new dataver and +// re-fetch. +// +// Hand-rolled rather than `#[derive(FromTLV, ToTLV)]` because the inner +// types are const-generic and the macro doesn't yet support that +// (same reason `EndpointLabels` in `user_label.rs` is hand-rolled). +// The persisted wire shape is private to this module; it only needs to +// round-trip between successive runs of the same firmware. + +impl ToTLV for SceneEntry { + fn to_tlv(&self, tag: &TLVTag, mut tw: W) -> Result<(), Error> { + tw.start_struct(tag)?; + self.fab_idx.to_tlv(&TLVTag::Context(0), &mut tw)?; + self.endpoint_id.to_tlv(&TLVTag::Context(1), &mut tw)?; + self.group_id.to_tlv(&TLVTag::Context(2), &mut tw)?; + self.scene_id.to_tlv(&TLVTag::Context(3), &mut tw)?; + self.transition_time.to_tlv(&TLVTag::Context(4), &mut tw)?; + // The captured EFS bytes go on the wire as a single octet + // string, rather than as an array-of-u8 (which is what the + // blanket `Vec: ToTLV` would emit). + tw.str(&TLVTag::Context(5), &self.extension_fields)?; + tw.end_container() + } + + fn tlv_iter(&self, _tag: TLVTag) -> impl Iterator, Error>> { + // Only `Persist::store_tlv` exercises persistence and it goes + // through `to_tlv` above. Leave `tlv_iter` empty to satisfy the + // trait bound without dragging extra machinery in. + core::iter::empty() + } +} + +impl<'a, const M: usize> FromTLV<'a> for SceneEntry { + fn from_tlv(element: &TLVElement<'a>) -> Result { + let s = element.structure()?; + let mut extension_fields = Vec::::new(); + extension_fields + .extend_from_slice(s.ctx(5)?.str()?) + .map_err(|_| ErrorCode::NoSpace)?; + Ok(Self { + fab_idx: NonZeroU8::from_tlv(&s.ctx(0)?)?, + endpoint_id: EndptId::from_tlv(&s.ctx(1)?)?, + group_id: u16::from_tlv(&s.ctx(2)?)?, + scene_id: SceneId::from_tlv(&s.ctx(3)?)?, + transition_time: u32::from_tlv(&s.ctx(4)?)?, + extension_fields, + }) + } +} + +impl ToTLV for ScenesStateInner { + fn to_tlv(&self, tag: &TLVTag, mut tw: W) -> Result<(), Error> { + tw.start_struct(tag)?; + self.table.to_tlv(&TLVTag::Context(0), &mut tw)?; + self.current_per_fabric + .to_tlv(&TLVTag::Context(1), &mut tw)?; + tw.end_container() + } + + fn tlv_iter(&self, _tag: TLVTag) -> impl Iterator, Error>> { + core::iter::empty() + } +} + +impl<'a, const N: usize, const M: usize> FromTLV<'a> for ScenesStateInner { + fn from_tlv(element: &TLVElement<'a>) -> Result { + let s = element.structure()?; + Ok(Self { + table: Vec::, N>::from_tlv(&s.ctx(0)?)?, + current_per_fabric: Vec::::from_tlv(&s.ctx(1)?)?, + // Always boot at 0 — see the persistence note above. + info_dataver: 0, + }) + } +} + +impl ScenesState { + /// Re-hydrate the scene table and per-fabric `CurrentScene` + /// bookkeeping from `store` under [`SCENES_KEY`]. Call once at + /// application startup, before exposing the data model to + /// commissioners, so subsequent `RecallScene` / `GetSceneMembership` + /// commands see scenes that were stored before the last reboot. + /// + /// Missing key (first boot, or persistence cleared) is not an + /// error — the registry stays empty. + pub async fn load_persist( + &self, + mut store: S, + buf: &mut [u8], + ) -> Result<(), Error> { + let Some(data) = store.load(SCENES_KEY, buf)? else { + // No prior persistence — reset to empty so re-calling + // `load_persist` after a `remove` of the key behaves + // deterministically. + self.with(|inner| { + inner.table.clear(); + inner.current_per_fabric.clear(); + }); + return Ok(()); + }; + + let loaded = ScenesStateInner::::from_tlv(&TLVElement::new(data))?; + let entries = loaded.table.len(); + + self.with(|inner| { + inner.table = loaded.table; + inner.current_per_fabric = loaded.current_per_fabric; + inner.bump_info_dataver(); + }); + + info!("Loaded Scenes state from storage ({} entries)", entries); + + Ok(()) + } + + /// Serialise the current state to `ctx.kv()` under [`SCENES_KEY`]. + /// Called from every mutating handler path after the in-memory + /// change is committed. + fn store_persist(&self, ctx: &C) -> Result<(), Error> { + let mut persist = Persist::new(ctx.kv()); + + self.inner.lock(|cell| { + let inner = cell.borrow(); + persist.store_tlv(SCENES_KEY, &*inner) + })?; + + persist.run() + } +} + /// Scenes Management cluster handler. /// /// Generic over a tuple-recursive registry `R: SceneClusters` that @@ -974,9 +1162,12 @@ where let scene_id = request.scene_id()?; let transition_time = request.transition_time()?; - // Spec: `CONSTRAINT_ERROR` for the reserved `SceneID = 0xFF`. - // Checked before the group-table existence check. - if scene_id == RESERVED_SCENE_ID { + // Spec: `CONSTRAINT_ERROR` for the reserved `SceneID = 0xFF`, + // and also for `TransitionTime` exceeding the spec maximum + // (`Test_TC_S_2_2` steps 8d/8e). Both are checked before the + // group-table existence check so a bad request shape is + // rejected even if the target group is absent. + if scene_id == RESERVED_SCENE_ID || transition_time > MAX_TRANSITION_TIME_MS { return response .status(SC_CONSTRAINT_ERROR)? .group_id(group_id)? @@ -1007,11 +1198,41 @@ where // `upsert_scene`'s fill closure copies the request's raw EFS // bytes directly into the table slot's `extension_fields` // Vec, skipping an intermediate stack-allocated Vec. - let raw = match request.extension_field_set_structs() { - Ok(array) => array.element().raw_value()?, - Err(_) => &[], + let efs_array_opt = request.extension_field_set_structs().ok(); + let raw = match efs_array_opt { + Some(ref array) => array.element().raw_value()?, + None => &[], }; + // Spec-conformance check: every AVP in the EFS payload whose + // `cluster_id` is registered with this Scenes handler must + // reference a scenable attribute on that cluster. Mixing in an + // unscenable attribute MUST be rejected with `INVALID_COMMAND` + // (Matter App Cluster spec §1.4.7.1; exercised by + // `Test_TC_S_2_2` step 8g). + // + // For unregistered clusters we stay lenient (silently store the + // bytes) — matches chip's behaviour on firmware downgrades + // where a previously-scenable cluster is dropped from the + // registry. + if let Some(ref efs_array) = efs_array_opt { + for efs in efs_array.iter() { + let efs = efs?; + let cid = efs.cluster_id()?; + for avp in efs.attribute_value_list()?.iter() { + let avp = avp?; + let aid = avp.attribute_id()?; + if let Some(false) = self.clusters.check_scenable(cid, aid) { + return response + .status(SC_INVALID_COMMAND)? + .group_id(group_id)? + .scene_id(scene_id)? + .end(); + } + } + } + } + // Insert / replace + invalidate SceneValid for this fabric — all // under a single lock. Per the `SceneValid` field rules, // adding/storing a scene that doesn't match the current @@ -1036,6 +1257,7 @@ where })?; if status_code == 0 { + self.state.store_persist(ctx)?; ctx.notify_own_attr_changed(AttributeId::FabricSceneInfo as _); } @@ -1276,6 +1498,7 @@ where }); if status == 0 { + self.state.store_persist(ctx)?; ctx.notify_own_attr_changed(AttributeId::FabricSceneInfo as _); } @@ -1318,6 +1541,7 @@ where }); if removed { + self.state.store_persist(ctx)?; ctx.notify_own_attr_changed(AttributeId::FabricSceneInfo as _); } @@ -1421,6 +1645,7 @@ where })?; if status_code == 0 { + self.state.store_persist(ctx)?; ctx.notify_own_attr_changed(AttributeId::FabricSceneInfo as _); } @@ -1451,21 +1676,26 @@ where let group_id = request.group_id()?; let scene_id = request.scene_id()?; - // `RecallScene` has no response struct (returns `()`), so we - // surface the status via `set_cluster_status` and bubble out - // an error. + // `RecallScene` has no response struct (returns `()`), so the + // status must be surfaced as an IM-level `CommandStatusIB.status` + // — i.e. returned via `Err(ErrorCode::*)`. The + // [`ErrorCode`] → [`IMStatusCode`] mapping in `im.rs` turns + // these into the spec-mandated wire codes + // (`ConstraintError = 0x87`, `InvalidCommand = 0x85`, + // `NotFound = 0x8b`). Surfacing them via `set_cluster_status` + // would produce `FAILURE` with a cluster-status side-channel, + // which chip-tool's certification suites correctly reject + // (see `Test_TC_S_2_2` step 4e). // Spec: `CONSTRAINT_ERROR` for the reserved `SceneID = 0xFF`. if scene_id == RESERVED_SCENE_ID { - ctx.cmd().set_cluster_status(SC_CONSTRAINT_ERROR); - return Err(ErrorCode::Failure.into()); + return Err(ErrorCode::ConstraintError.into()); } // Spec: `INVALID_COMMAND` when `group_id != 0` is absent from // the Groups cluster's Group Table for this endpoint. if !Self::group_in_table(ctx, fab_idx, endpoint_id, group_id)? { - ctx.cmd().set_cluster_status(SC_INVALID_COMMAND); - return Err(ErrorCode::Failure.into()); + return Err(ErrorCode::InvalidCommand.into()); } // RecallScene's request carries an optional+nullable @@ -1499,10 +1729,10 @@ where Ok((Some(len), Some(e.transition_time))) })?; let (Some(blob_len), Some(stored_tt_ms)) = (blob_len, stored_tt_ms) else { - // Spec: NotFound when no matching scene exists. The codegen - // turns the IM error into a NotFound status response. - ctx.cmd().set_cluster_status(SC_NOT_FOUND); - return Err(ErrorCode::Failure.into()); + // Spec: `NOT_FOUND` when no matching scene exists. Surfaced + // at IM level (see the comment above on `ConstraintError` + // / `InvalidCommand`). + return Err(ErrorCode::NotFound.into()); }; let effective_tt_ms = override_tt_ms.unwrap_or(stored_tt_ms); @@ -1525,6 +1755,7 @@ where self.state .with(|inner| Self::remember_current(inner, fab_idx, group_id, scene_id)); + self.state.store_persist(ctx)?; ctx.notify_own_attr_changed(AttributeId::FabricSceneInfo as _); Ok(()) } @@ -1643,6 +1874,7 @@ where }); if status == 0 { + self.state.store_persist(ctx)?; ctx.notify_own_attr_changed(AttributeId::FabricSceneInfo as _); } diff --git a/rs-matter/src/persist.rs b/rs-matter/src/persist.rs index 4a930c923..0176ed848 100644 --- a/rs-matter/src/persist.rs +++ b/rs-matter/src/persist.rs @@ -64,6 +64,18 @@ pub const LKG_UTC_KEY: u16 = BINDINGS_KEY + 1; /// The key is absent on disk when no trusted source is configured. pub const TRUSTED_TIME_SOURCE_KEY: u16 = LKG_UTC_KEY + 1; +/// The key used for storing the entire Scenes Management cluster +/// state — the cross-fabric scene table plus the per-fabric +/// "last recalled scene" bookkeeping — as a single TLV blob. +/// Re-persisted on every successful mutation (AddScene / StoreScene / +/// RemoveScene / RemoveAllScenes / CopyScene / RecallScene). +/// +/// The blob's size is bounded by `N * (M + ~14)` bytes plus per-fabric +/// overhead — for the in-tree defaults (`N=16, M=128`) that is well +/// under the 4 KiB KvBlobStore working-buffer assumed by most MCU +/// targets. +pub const SCENES_KEY: u16 = TRUSTED_TIME_SOURCE_KEY + 1; + /// A trait representing a key-value BLOB storage. /// /// NOTE: For now, the trait is deliberately modeled as non-async, so that it can be used from diff --git a/xtask/src/itest.rs b/xtask/src/itest.rs index 5e510967d..0a5d965fe 100644 --- a/xtask/src/itest.rs +++ b/xtask/src/itest.rs @@ -493,11 +493,10 @@ pub(crate) const LIGHT_TESTS: &[&str] = &[ pub(crate) const SCENES_TESTS: &[&str] = &[ // Attribute-read coverage (SceneTableSize, FabricSceneInfo). "Test_TC_S_2_1", - // `Test_TC_S_2_2` — TODO: passes through step 3 but step 4 - // reboots the DUT and expects the stored scene to survive, - // which requires scene-table persistence to KV storage. Tracked - // separately; re-enable once persistence lands. - // "Test_TC_S_2_2", + // `Test_TC_S_2_2` reboots the DUT in step 4 and expects scenes + // stored before the reboot to be re-applied by a `RecallScene` + // afterwards — covered by [`ScenesState::load_persist`]. + "Test_TC_S_2_2", "Test_TC_S_2_3", "Test_TC_S_2_4", "Test_TC_S_2_5", From 5ee62a13ab1d10468a06c251d6e62c5ec883b54a Mon Sep 17 00:00:00 2001 From: ivmarkov Date: Sat, 30 May 2026 07:53:47 +0000 Subject: [PATCH 06/15] More tests --- examples/src/bin/scenes_tests.pics | 8 ++ examples/src/bin/scenes_tests.rs | 24 ++++ rs-matter/src/dm/clusters/scenes.rs | 172 +++++++++++++++++++++++----- xtask/src/itest.rs | 33 +++++- 4 files changed, 203 insertions(+), 34 deletions(-) diff --git a/examples/src/bin/scenes_tests.pics b/examples/src/bin/scenes_tests.pics index 27b795634..54a396fed 100644 --- a/examples/src/bin/scenes_tests.pics +++ b/examples/src/bin/scenes_tests.pics @@ -1,4 +1,10 @@ PICS_SDK_CI_ONLY=1 +# Top-level gates required by the `TestScenesFabricSceneInfo` / +# `TestScenesMultiFabric` / `TestScenesMaxCapacity` / `TestScenesFabricRemoval` +# composite suites — the test framework skips them entirely if absent. +MCORE.ROLE.COMMISSIONEE=1 +APPDEVICE.S=1 + # PICS values to be used when running the `scenes_tests` executable. # This is a superset of `dimmable_light.pics` (OnOff + LevelControl) # plus the Scenes Management cluster on EP1 and the @@ -55,6 +61,7 @@ GRPKEY.C.C01.Tx=1 S.S=1 S.S.A0001=1 S.S.A0002=1 +S.S.A0007=1 # FabricSceneInfo — read in TestScenesFabricSceneInfo S.S.C00.Rsp=1 # AddScene S.S.C01.Rsp=1 # ViewScene S.S.C02.Rsp=1 # RemoveScene @@ -66,6 +73,7 @@ S.S.C40.Rsp=1 # CopyScene (Optional but exposed for TestScenesMultiFabric / Te S.S.AM=0 S.S.AO=0 S.S.F00=0 # SceneNames feature — accepted on wire but not stored; see scenes.rs module docs +S.S.F03=1 # SceneTable feature — gated by TestScenesFabricSceneInfo S.C=0 S.C.C00.Tx=0 S.C.C01.Tx=0 diff --git a/examples/src/bin/scenes_tests.rs b/examples/src/bin/scenes_tests.rs index 28e70aea9..ceca31d2c 100644 --- a/examples/src/bin/scenes_tests.rs +++ b/examples/src/bin/scenes_tests.rs @@ -58,6 +58,9 @@ use rs_matter::dm::clusters::desc::{self, ClusterHandler as _}; use rs_matter::dm::clusters::groups::{self, ClusterHandler as _}; use rs_matter::dm::clusters::net_comm::SharedNetworks; use rs_matter::dm::clusters::scenes::{ScenesHandler, ScenesState}; +use rs_matter::dm::clusters::unit_testing::{ + ClusterHandler as _, UnitTestingHandler, UnitTestingHandlerData, +}; use rs_matter::dm::devices::test::{DAC_PRIVKEY, TEST_DEV_ATT, TEST_DEV_COMM, TEST_DEV_DET}; use rs_matter::dm::devices::DEV_TYPE_DIMMABLE_LIGHT; use rs_matter::dm::events::Events; @@ -77,6 +80,7 @@ use rs_matter::respond::DefaultResponder; use rs_matter::sc::pase::MAX_COMM_WINDOW_TIMEOUT_SECS; use rs_matter::tlv::Nullable; use rs_matter::transport::MATTER_SOCKET_BIND_ADDR; +use rs_matter::utils::cell::RefCell; use rs_matter::utils::init::InitMaybeUninit; use rs_matter::utils::select::Coalesce; use rs_matter::utils::storage::pooled::PooledBuffers; @@ -102,6 +106,12 @@ static KV_BUF: StaticCell<[u8; 4096]> = StaticCell::new(); const SCENES_CAPACITY: usize = 16; static SCENES_STATE: StaticCell> = StaticCell::new(); +// `UnitTesting` is wired on EP1 for the chip-tool `TestScenes*` +// composite YAML suites — they use the cluster's `TestAddArguments` +// command to do in-test arithmetic on attribute reads. Adds ~no +// runtime cost when the suites aren't running. +static UNIT_TESTING_DATA: StaticCell> = StaticCell::new(); + fn main() -> Result<(), Error> { let thread = std::thread::Builder::new() // Increase the stack size until the example can work without stack blowups. @@ -199,6 +209,10 @@ fn run() -> Result<(), Error> { // per-cluster `SceneClusterHandler` impls. Both OnOff and // LevelControl ship a ZST impl in their respective modules // (`app::on_off::scenes` / `app::level_control::scenes`). + let unit_testing_data = UNIT_TESTING_DATA + .uninit() + .init_with(RefCell::init(UnitTestingHandlerData::init())); + let scenes_state = SCENES_STATE.uninit().init_with(ScenesState::init()); // Restore the scene table + per-fabric `CurrentScene` bookkeeping // from KV — re-applies any scenes a previous run of this binary @@ -229,6 +243,7 @@ fn run() -> Result<(), Error> { &on_off_handler, &level_control_handler, scenes_handler, + unit_testing_data, ), SharedKvBlobStore::new(kv, kv_buf), SharedNetworks::new(EthNetwork::new_default()), @@ -314,6 +329,7 @@ const NODE: Node<'static> = Node { OnOffDeviceLogic::CLUSTER, LevelControlDeviceLogic::CLUSTER, SCENES_FULL_CLUSTER, + UnitTestingHandler::CLUSTER, ), ), ], @@ -327,6 +343,7 @@ fn dm_handler<'a, LH: LevelControlHooks, OH: OnOffHooks, R>( on_off: &'a on_off::OnOffHandler<'a, OH, LH>, level_control: &'a level_control::LevelControlHandler<'a, LH, OH>, scenes: ScenesHandler<'a, SCENES_CAPACITY, impl AsyncHandler + 'a, R>, + unit_testing_data: &'a RefCell, ) -> impl DataModelHandler + 'a where R: rs_matter::dm::clusters::scenes::SceneClusters + 'a, @@ -355,6 +372,13 @@ where .chain( EpClMatcher::new(Some(1), Some(SCENES_FULL_CLUSTER.id)), scenes.adapt(), + ) + .chain( + EpClMatcher::new(Some(1), Some(UnitTestingHandler::CLUSTER.id)), + Async( + UnitTestingHandler::new(Dataver::new_rand(&mut rand), unit_testing_data) + .adapt(), + ), ), ) } diff --git a/rs-matter/src/dm/clusters/scenes.rs b/rs-matter/src/dm/clusters/scenes.rs index bf42b9e94..1e23ba165 100644 --- a/rs-matter/src/dm/clusters/scenes.rs +++ b/rs-matter/src/dm/clusters/scenes.rs @@ -988,14 +988,49 @@ where inner.bump_info_dataver(); } - /// Drop the recalled-scene tracker for this fabric — called after - /// operations that change the scene table in ways that may make - /// the previously-recalled scene no longer represent the current - /// attribute state (per the `SceneValid` field rules in the spec). + /// Drop the recalled-scene tracker for `fab_idx` **only** when its + /// stored `(group_id, scene_id)` matches the one passed in — i.e. + /// when the operation that just happened (`AddScene` / + /// `StoreScene` / `RemoveScene` / `CopyScene` single-target case) + /// actually targeted the currently-recalled scene. Other scenes + /// changing leaves `SceneValid` alone, per Matter App Cluster + /// spec §1.4.6.5: + /// > Successful `CopyScene` or `AddScene` operations SHALL + /// > preserve the `SceneValid` attribute when the affected scene + /// > is not the currently recalled scene. + /// /// Operates on already-locked inner state. - fn invalidate_current(inner: &mut ScenesStateInner, fab_idx: NonZeroU8) { - inner.current_per_fabric.retain(|c| c.fab_idx != fab_idx); - inner.bump_info_dataver(); + fn invalidate_current_if_match_scene( + inner: &mut ScenesStateInner, + fab_idx: NonZeroU8, + group_id: u16, + scene_id: SceneId, + ) { + let before = inner.current_per_fabric.len(); + inner.current_per_fabric.retain(|c| { + !(c.fab_idx == fab_idx && c.group_id == group_id && c.scene_id == scene_id) + }); + if before != inner.current_per_fabric.len() { + inner.bump_info_dataver(); + } + } + + /// Drop the recalled-scene tracker for `fab_idx` when its stored + /// `group_id` matches the one passed in — used by + /// `RemoveAllScenes(group_id)` and the `COPY_ALL` mode of + /// `CopyScene` (both of which can affect any scene in the group). + fn invalidate_current_if_match_group( + inner: &mut ScenesStateInner, + fab_idx: NonZeroU8, + group_id: u16, + ) { + let before = inner.current_per_fabric.len(); + inner + .current_per_fabric + .retain(|c| !(c.fab_idx == fab_idx && c.group_id == group_id)); + if before != inner.current_per_fabric.len() { + inner.bump_info_dataver(); + } } /// Internal copy helper — runs against an already-locked @@ -1077,7 +1112,15 @@ where return SC_NOT_FOUND; } - Self::invalidate_current(inner, fab_idx); + // Only invalidate `CurrentScene` if this copy actually touched + // the currently-recalled scene. Single-target mode targets + // exactly `(group_to, scene_to)`; `COPY_ALL` mode targets the + // whole destination group. + if copy_all { + Self::invalidate_current_if_match_group(inner, fab_idx, group_to); + } else { + Self::invalidate_current_if_match_scene(inner, fab_idx, group_to, scene_to); + } 0 } @@ -1119,9 +1162,15 @@ where .iter() .find(|c| c.fab_idx == accessor_fab_idx) .copied(); + // Per chip's reference behaviour, `CurrentScene` and + // `CurrentGroup` are always emitted on the wire (even when + // `SceneValid=false`); we just set them to 0 in that case. + // `TestScenesFabricSceneInfo` asserts equality against a + // fully-populated struct rather than against an + // omitted-fields shape. let (g, s, v) = match current { Some(c) => (Some(c.group_id), Some(c.scene_id), true), - None => (None, None, false), + None => (Some(0u16), Some(0u8), false), }; let rem = Self::remaining_capacity_for_fab(inner, accessor_fab_idx); (count.min(0xFF) as u8, g, s, v, rem) @@ -1311,7 +1360,7 @@ where inner.table[pos].transition_time = transition_time; inner.table[pos].extension_fields.clear(); fill(&mut inner.table[pos].extension_fields)?; - Self::invalidate_current(inner, fab_idx); + Self::invalidate_current_if_match_scene(inner, fab_idx, group_id, scene_id); Ok(0) } else if inner.table.len() >= N { Ok(SC_INSUFFICIENT_SPACE) @@ -1338,7 +1387,7 @@ where let _ = inner.table.pop(); return Err(e); } - Self::invalidate_current(inner, fab_idx); + Self::invalidate_current_if_match_scene(inner, fab_idx, group_id, scene_id); Ok(0) } } @@ -1490,7 +1539,7 @@ where .position(|e| e.matches(fab_idx, endpoint_id, group_id, scene_id)) { inner.table.swap_remove(pos); - Self::invalidate_current(inner, fab_idx); + Self::invalidate_current_if_match_scene(inner, fab_idx, group_id, scene_id); 0 } else { SC_NOT_FOUND @@ -1535,7 +1584,7 @@ where }); let changed = before != inner.table.len(); if changed { - Self::invalidate_current(inner, fab_idx); + Self::invalidate_current_if_match_group(inner, fab_idx, group_id); } changed }); @@ -2415,22 +2464,42 @@ mod tests { // ---- side effect: SceneValid invalidation ---- #[test] - fn successful_copy_invalidates_current_scene() { + fn successful_copy_invalidates_current_scene_on_match() { let mut inner = ScenesStateInner::<8>::new(); push(&mut inner, entry(fab(1), 1, 10, 5, 100)); - // Stamp a "current scene" for fab 1, then assert it gets - // cleared after the copy. - ScenesHandler::<8>::remember_current(&mut inner, fab(1), 99, 99); + // Stamp "current scene" at the copy's TARGET (20, 7). After + // the copy overwrites that slot, `SceneValid` MUST become + // false because the recalled-scene data just changed + // underneath the recall. + ScenesHandler::<8>::remember_current(&mut inner, fab(1), 20, 7); assert_eq!(inner.current_per_fabric.len(), 1); let status = ScenesHandler::<8>::copy_scenes_inner(&mut inner, fab(1), 1, 10, 5, 20, 7, false); assert_eq!(status, 0); - // current_per_fabric for fab(1) was cleared. assert!(inner.current_per_fabric.iter().all(|c| c.fab_idx != fab(1))); } + #[test] + fn successful_copy_preserves_current_scene_when_target_doesnt_match() { + let mut inner = ScenesStateInner::<8>::new(); + push(&mut inner, entry(fab(1), 1, 10, 5, 100)); + // Stamp "current scene" at (99, 99) — disjoint from the + // copy's target (20, 7). Per Matter spec §1.4.6.5, + // `SceneValid` must be preserved when the copy doesn't touch + // the currently-recalled scene. + ScenesHandler::<8>::remember_current(&mut inner, fab(1), 99, 99); + + let status = + ScenesHandler::<8>::copy_scenes_inner(&mut inner, fab(1), 1, 10, 5, 20, 7, false); + + assert_eq!(status, 0); + assert_eq!(inner.current_per_fabric.len(), 1); + assert_eq!(inner.current_per_fabric[0].group_id, 99); + assert_eq!(inner.current_per_fabric[0].scene_id, 99); + } + #[test] fn failed_copy_does_not_invalidate_current_scene() { let mut inner = ScenesStateInner::<8>::new(); @@ -2482,14 +2551,38 @@ mod tests { } #[test] - fn invalidate_current_only_clears_target_fabric() { + fn invalidate_match_scene_only_clears_exact_match() { let mut inner = ScenesStateInner::<8>::new(); ScenesHandler::<8>::remember_current(&mut inner, fab(1), 10, 1); ScenesHandler::<8>::remember_current(&mut inner, fab(2), 20, 2); - ScenesHandler::<8>::invalidate_current(&mut inner, fab(1)); + // Non-matching (group, scene) leaves the entry alone — this is + // the spec-preserve-SceneValid path used by `AddScene` / + // `RemoveScene` / `CopyScene` when they target a non-current + // scene. + ScenesHandler::<8>::invalidate_current_if_match_scene(&mut inner, fab(1), 99, 99); + assert_eq!(inner.current_per_fabric.len(), 2); - // fab(2)'s entry survives. + // Matching (group, scene) on fab(1) drops only fab(1)'s entry. + ScenesHandler::<8>::invalidate_current_if_match_scene(&mut inner, fab(1), 10, 1); + assert_eq!(inner.current_per_fabric.len(), 1); + assert_eq!(inner.current_per_fabric[0].fab_idx, fab(2)); + } + + #[test] + fn invalidate_match_group_clears_any_scene_in_group() { + let mut inner = ScenesStateInner::<8>::new(); + ScenesHandler::<8>::remember_current(&mut inner, fab(1), 10, 7); + ScenesHandler::<8>::remember_current(&mut inner, fab(2), 20, 2); + + // Wrong group: no-op. + ScenesHandler::<8>::invalidate_current_if_match_group(&mut inner, fab(1), 99); + assert_eq!(inner.current_per_fabric.len(), 2); + + // Right group on fab(1), regardless of scene id, drops fab(1) + // — exercising the `RemoveAllScenes(group)` / `CopyScene COPY_ALL` + // path. + ScenesHandler::<8>::invalidate_current_if_match_group(&mut inner, fab(1), 10); assert_eq!(inner.current_per_fabric.len(), 1); assert_eq!(inner.current_per_fabric[0].fab_idx, fab(2)); } @@ -2609,13 +2702,14 @@ mod tests { } #[test] - fn upsert_invalidates_current_scene_for_target_fabric() { - // Per `SceneValid` rules: any AddScene/StoreScene mutates - // table state in a way that may no longer match the recalled - // attributes, so CurrentScene gets cleared for the originating - // fabric. + fn upsert_invalidates_current_scene_when_upsert_targets_it() { + // Per Matter App Cluster spec §1.4.6.5, `SceneValid` is only + // invalidated when the upsert (`AddScene` / `StoreScene`) + // overwrites the currently-recalled scene. Stamp the current + // scene at the same `(group, scene)` the upsert targets and + // verify it gets dropped. let mut inner = ScenesStateInner::<8>::new(); - ScenesHandler::<8>::remember_current(&mut inner, fab(1), 99, 99); + ScenesHandler::<8>::remember_current(&mut inner, fab(1), 10, 5); let _ = ScenesHandler::<8>::upsert_scene(&mut inner, fab(1), 1, 10, 5, 100, fill_with(&[0x18])) @@ -2624,11 +2718,31 @@ mod tests { assert!(!inner.current_per_fabric.iter().any(|c| c.fab_idx == fab(1))); } + #[test] + fn upsert_preserves_current_scene_when_upsert_targets_a_different_scene() { + // Non-matching upsert MUST leave `SceneValid` intact (the + // spec-conformance regression that `TestScenesFabricSceneInfo` + // step 21 catches when violated). + let mut inner = ScenesStateInner::<8>::new(); + ScenesHandler::<8>::remember_current(&mut inner, fab(1), 1, 1); + + let _ = + // Upsert in a *different* group/scene than the current one. + ScenesHandler::<8>::upsert_scene(&mut inner, fab(1), 1, 2, 1, 100, fill_with(&[0x18])) + .unwrap(); + + assert_eq!(inner.current_per_fabric.len(), 1); + assert_eq!(inner.current_per_fabric[0].group_id, 1); + assert_eq!(inner.current_per_fabric[0].scene_id, 1); + } + #[test] fn upsert_keeps_other_fabrics_current_scene_intact() { let mut inner = ScenesStateInner::<8>::new(); - ScenesHandler::<8>::remember_current(&mut inner, fab(1), 99, 99); - ScenesHandler::<8>::remember_current(&mut inner, fab(2), 88, 88); + // Both fabrics have a current scene matching what we're about + // to upsert in fab(1) — only fab(1)'s entry should drop. + ScenesHandler::<8>::remember_current(&mut inner, fab(1), 10, 5); + ScenesHandler::<8>::remember_current(&mut inner, fab(2), 10, 5); let _ = ScenesHandler::<8>::upsert_scene(&mut inner, fab(1), 1, 10, 5, 100, fill_with(&[0x18])) diff --git a/xtask/src/itest.rs b/xtask/src/itest.rs index 0a5d965fe..c50b1cd46 100644 --- a/xtask/src/itest.rs +++ b/xtask/src/itest.rs @@ -504,11 +504,34 @@ pub(crate) const SCENES_TESTS: &[&str] = &[ // Effect-on-receipt cross-cluster checks (RecallScene actually // applies OnOff / LevelControl state via cross-cluster commands). "Test_TC_S_3_1", - // Composite suites — uncomment once the per-test cases above pass. - // "TestScenesMultiFabric", // TODO: not yet verified - // "TestScenesFabricRemoval", // TODO: not yet verified - // "TestScenesFabricSceneInfo", // TODO: not yet verified - // "TestScenesMaxCapacity", // TODO: not yet verified + // Composite suites — each requires implementation pieces beyond + // what's wired today. Re-enable as those land. + // + // `TestScenesFabricSceneInfo` — 24 of 33 steps pass; step 25 + // expects `SceneValid → false` after a `LevelControl.MoveToLevelWithOnOff` + // changes the scenable attribute state out from under a previously + // recalled scene. Requires "scene drift detection": every scenable + // attribute mutation (writes + apply-via-command) must call back + // into `ScenesState` to invalidate `SceneValid` for that endpoint + // / fabric. Step 27 additionally expects `StoreScene` to set + // `CurrentScene = (group, scene)` with `SceneValid=true`. Both are + // spec-conformant behaviours that warrant a small cross-cluster + // wiring pass (`SceneClusterHandler` callers notify `ScenesState` + // on state changes); deferred so persistence ships independently. + // "TestScenesFabricSceneInfo", + // + // `TestScenesMultiFabric` / `TestScenesFabricRemoval` / + // `TestScenesMaxCapacity` all begin with a multi-fabric setup + // step that calls `AdministratorCommissioning.OpenCommissioningWindow` + // with a 16-byte PAKE salt. `rs-matter`'s `Spake2pVerifierSalt` + // is currently a fixed `[u8; 32]` (see + // `sc::pase::spake2p::SPAKE2P_VERIFIER_SALT_LEN`), so the salt + // copy fails with `InvalidData` and the commissioning window + // never opens. Re-enable once variable-length salts + // (16–32 B per Matter Core spec) are supported end-to-end. + // "TestScenesMultiFabric", + // "TestScenesFabricRemoval", + // "TestScenesMaxCapacity", ]; /// A pre-canned test suite. Selects a default test list, the example From 7e526913f49dc169ebc6a5579c4a4d5c57c7bef9 Mon Sep 17 00:00:00 2001 From: ivmarkov Date: Sat, 30 May 2026 11:01:28 +0000 Subject: [PATCH 07/15] Fix PASE responder to accept variable salt len from 16 to 32 bytes --- rs-matter/src/dm/clusters/adm_comm.rs | 4 +- rs-matter/src/lib.rs | 2 +- rs-matter/src/sc/pase.rs | 33 +++++++++++--- rs-matter/src/sc/pase/initiator.rs | 22 +++++----- rs-matter/src/sc/pase/responder.rs | 9 ++-- rs-matter/src/sc/pase/spake2p.rs | 62 +++++++++++++++++++-------- 6 files changed, 90 insertions(+), 42 deletions(-) diff --git a/rs-matter/src/dm/clusters/adm_comm.rs b/rs-matter/src/dm/clusters/adm_comm.rs index f2f5cf2cd..272c26582 100644 --- a/rs-matter/src/dm/clusters/adm_comm.rs +++ b/rs-matter/src/dm/clusters/adm_comm.rs @@ -200,7 +200,7 @@ impl ClusterHandler for AdminCommHandler { .open_comm_window( mdns_id, verifier.0.try_into()?, - salt.0.try_into()?, + salt.0, iterations, request.discriminator()?, request.commissioning_timeout()?, @@ -240,7 +240,7 @@ impl ClusterHandler for AdminCommHandler { .pase .open_basic_comm_window( mdns_id, - salt.reference(), + salt.access(), dev_comm.password.reference(), dev_comm.discriminator, request.commissioning_timeout()?, diff --git a/rs-matter/src/lib.rs b/rs-matter/src/lib.rs index 117daebe4..636a28902 100644 --- a/rs-matter/src/lib.rs +++ b/rs-matter/src/lib.rs @@ -393,7 +393,7 @@ impl<'a> Matter<'a> { state.pase.open_basic_comm_window( mdns_id, - salt.reference(), + salt.access(), self.dev_comm.password.reference(), self.dev_comm.discriminator, timeout_secs, diff --git a/rs-matter/src/sc/pase.rs b/rs-matter/src/sc/pase.rs index 0b7d21da9..bcaed2bdd 100644 --- a/rs-matter/src/sc/pase.rs +++ b/rs-matter/src/sc/pase.rs @@ -29,7 +29,8 @@ use crate::dm::endpoints::ROOT_ENDPOINT_ID; use crate::error::{Error, ErrorCode}; use crate::im::{ClusterId, EndptId}; use crate::sc::pase::spake2p::{ - Spake2pVerifierData, Spake2pVerifierSaltRef, Spake2pVerifierStrRef, + Spake2pVerifierData, Spake2pVerifierStrRef, SPAKE2P_VERIFIER_SALT_LEN, + SPAKE2P_VERIFIER_SALT_MIN_LEN, }; use crate::sc::SessionParameters; use crate::tlv::{FromTLV, OctetStr, ToTLV}; @@ -107,6 +108,7 @@ impl CommWindow { /// # Arguments /// - `mdns_id` - The mDNS identifier /// - `password` - The passcode + /// - `salt` - The salt bytes (16..=32 bytes, validated upstream) /// - `discriminator` - The discriminator /// - `opener` - The opener info /// - `window_expiry` - The window expiry instant @@ -114,7 +116,7 @@ impl CommWindow { fn init_with_pw<'a>( mdns_id: u64, password: Spake2pVerifierPasswordRef<'a>, - salt: Spake2pVerifierSaltRef<'a>, + salt: &'a [u8], discriminator: u16, opener: Option, window_expiry: Instant, @@ -133,7 +135,7 @@ impl CommWindow { /// /// # Arguments /// - `verifier` - The verifier bytes - /// - `salt` - The salt bytes + /// - `salt` - The salt bytes (16..=32 bytes, validated upstream) /// - `count` - The iteration count /// - `discriminator` - The discriminator /// - `opener` - The opener info @@ -141,7 +143,7 @@ impl CommWindow { fn init<'a>( mdns_id: u64, verifier: Spake2pVerifierStrRef<'a>, - salt: Spake2pVerifierSaltRef<'a>, + salt: &'a [u8], count: u32, discriminator: u16, opener: Option, @@ -239,9 +241,21 @@ impl Pase { self.comm_window.as_opt_ref() } + /// Reject a salt that's outside the spec's 16..=32 B range + /// (Matter Core spec, Cryptographic Building Blocks). + fn validate_salt_len(salt: &[u8]) -> Result<(), Error> { + if !(SPAKE2P_VERIFIER_SALT_MIN_LEN..=SPAKE2P_VERIFIER_SALT_LEN).contains(&salt.len()) { + Err(ErrorCode::ConstraintError)?; + } + + Ok(()) + } + /// Open a basic commissioning window using a passcode /// /// # Arguments + /// - `mdns_id` - The mDNS identifier + /// - `salt` - The salt bytes (16..=32 bytes, validated upstream) /// - `password` - The passcode /// - `discriminator` - The discriminator /// - `timeout_secs` - The timeout in seconds of the validity of the window @@ -257,7 +271,7 @@ impl Pase { pub fn open_basic_comm_window( &mut self, mdns_id: u64, - salt: Spake2pVerifierSaltRef<'_>, + salt: &[u8], password: Spake2pVerifierPasswordRef<'_>, discriminator: u16, timeout_secs: u16, @@ -273,6 +287,8 @@ impl Pase { Err(ErrorCode::InvalidCommand)?; } + Self::validate_salt_len(salt)?; + let window_expiry = Instant::now().saturating_add(Duration::from_secs(timeout_secs as _)); self.comm_window @@ -296,8 +312,9 @@ impl Pase { /// Open an enhanced commissioning window using a verifier /// /// # Arguments + /// - `mdns_id` - The mDNS identifier /// - `verifier` - The verifier bytes - /// - `salt` - The salt bytes + /// - `salt` - The salt bytes (16..=32 bytes, validated upstream) /// - `count` - The iteration count /// - `discriminator` - The discriminator /// - `timeout_secs` - The timeout in seconds of the validity of the window @@ -314,7 +331,7 @@ impl Pase { &mut self, mdns_id: u64, verifier: Spake2pVerifierStrRef<'_>, - salt: Spake2pVerifierSaltRef<'_>, + salt: &[u8], count: u32, discriminator: u16, timeout_secs: u16, @@ -330,6 +347,8 @@ impl Pase { Err(ErrorCode::InvalidCommand)?; } + Self::validate_salt_len(salt)?; + let window_expiry = Instant::now().saturating_add(Duration::from_secs(timeout_secs as _)); self.comm_window.reinit(Maybe::init_some(CommWindow::init( diff --git a/rs-matter/src/sc/pase/initiator.rs b/rs-matter/src/sc/pase/initiator.rs index b8b2cddb0..7ac713f9b 100644 --- a/rs-matter/src/sc/pase/initiator.rs +++ b/rs-matter/src/sc/pase/initiator.rs @@ -29,7 +29,7 @@ use crate::crypto::{ use crate::error::{Error, ErrorCode}; use crate::sc::pase::spake2p::{ ProverContext, Spake2P, Spake2pRandom, Spake2pSessionKeys, Spake2pVerifierPasswordRef, - SPAKE2P_VERIFIER_SALT_LEN, + SPAKE2P_VERIFIER_SALT_LEN, SPAKE2P_VERIFIER_SALT_MIN_LEN, }; use crate::sc::{complete_with_status, GeneralCode, OpCode, SCStatusCodes, StatusReport}; use crate::tlv::{FromTLV, OctetStr, TLVElement, TagType, ToTLV}; @@ -101,7 +101,7 @@ impl PaseInitiator { let mut initiator = Self::new(crypto); // Step 1: Send PBKDFParamRequest, receive PBKDFParamResponse - let (salt, iterations) = match initiator.exchange_pbkdf_params(exchange).await { + let (salt, salt_len, iterations) = match initiator.exchange_pbkdf_params(exchange).await { Ok(result) => result, Err(e) => { // Send status report to notify responder of failure @@ -112,7 +112,7 @@ impl PaseInitiator { // Step 2: Send Pake1, receive Pake2 if let Err(e) = initiator - .exchange_pake1_pake2(exchange, password, &salt, iterations) + .exchange_pake1_pake2(exchange, password, &salt[..salt_len], iterations) .await { // Send status report to notify responder of failure (e.g., wrong password) @@ -130,11 +130,11 @@ impl PaseInitiator { /// Exchange PBKDFParamRequest/Response /// - /// Returns (salt, iterations) on success + /// Returns (salt, salt_len, iterations) on success async fn exchange_pbkdf_params( &mut self, exchange: &mut Exchange<'_>, - ) -> Result<([u8; SPAKE2P_VERIFIER_SALT_LEN], u32), Error> { + ) -> Result<([u8; SPAKE2P_VERIFIER_SALT_LEN], usize, u32), Error> { // Generate random and session ID let mut rand = self.crypto.rand()?; rand.fill_bytes(self.initiator_random.access_mut()); @@ -215,19 +215,17 @@ impl PaseInitiator { })?; let mut salt = [0u8; SPAKE2P_VERIFIER_SALT_LEN]; - if params.salt.0.len() != SPAKE2P_VERIFIER_SALT_LEN { - error!( - "PBKDFParamResponse: invalid salt length {}", - params.salt.0.len() - ); + let salt_len = params.salt.0.len(); + if !(SPAKE2P_VERIFIER_SALT_MIN_LEN..=SPAKE2P_VERIFIER_SALT_LEN).contains(&salt_len) { + error!("PBKDFParamResponse: invalid salt length {}", salt_len); return Err(ErrorCode::Invalid.into()); } - salt.copy_from_slice(params.salt.0); + salt[..salt_len].copy_from_slice(params.salt.0); // Finish context hash with response payload self.spake2p.finish_context::<&C>(context, rx.payload())?; - Ok((salt, params.iterations)) + Ok((salt, salt_len, params.iterations)) } /// Exchange Pake1/Pake2 diff --git a/rs-matter/src/sc/pase/responder.rs b/rs-matter/src/sc/pase/responder.rs index 08b189e49..ea229184c 100644 --- a/rs-matter/src/sc/pase/responder.rs +++ b/rs-matter/src/sc/pase/responder.rs @@ -162,7 +162,8 @@ impl<'a, C: Crypto> PaseResponder<'a, C> { let rx = exchange.rx()?; - let mut salt = super::spake2p::SPAKE2P_VERIFIER_SALT_ZEROED; + let mut salt = [0u8; super::spake2p::SPAKE2P_VERIFIER_SALT_LEN]; + let mut salt_len = 0usize; let mut count = 0; let notify_mdns = || exchange.matter().notify_mdns_changed(); @@ -176,7 +177,9 @@ impl<'a, C: Crypto> PaseResponder<'a, C> { .check_comm_window_timeout(notify_mdns, notify_change)?; if let Some(comm_window) = state.pase.comm_window() { - salt.load(comm_window.verifier.salt.reference()); + let src = comm_window.verifier.salt_bytes(); + salt[..src.len()].copy_from_slice(src); + salt_len = src.len(); count = comm_window.verifier.count; Ok(true) @@ -213,7 +216,7 @@ impl<'a, C: Crypto> PaseResponder<'a, C> { responder_ssid: local_sessid, params: (!req.has_params).then(|| PBKDFParamRespParams { iterations: count, - salt: OctetStr::new(salt.access()), + salt: OctetStr::new(&salt[..salt_len]), }), session_parameters: Some(crate::sc::SessionParameters { max_paths_per_invoke: Some(max_paths), diff --git a/rs-matter/src/sc/pase/spake2p.rs b/rs-matter/src/sc/pase/spake2p.rs index 27463462e..a14a46695 100644 --- a/rs-matter/src/sc/pase/spake2p.rs +++ b/rs-matter/src/sc/pase/spake2p.rs @@ -45,8 +45,15 @@ pub const SPAKE2P_VERIFIER_PASSWORD_LEN: usize = 4; pub const SPAKE2P_VERIFIER_STR_LEN: usize = EC_CANON_SCALAR_LEN + EC_CANON_POINT_LEN; +/// Maximum size of the PASE salt buffer, in bytes. +/// +/// The Matter Core spec (Cryptographic Building Blocks) defines the +/// PASE salt as a variable-length octet string in **the range 16..=32**. pub const SPAKE2P_VERIFIER_SALT_LEN: usize = 32; +/// Minimum PASE salt length per Matter Core spec. +pub const SPAKE2P_VERIFIER_SALT_MIN_LEN: usize = 16; + pub const SPAKE2P_RANDOM_LEN: usize = 32; pub const SPAKE2P_SESSION_KEYS_LEN: usize = AEAD_CANON_KEY_LEN * 3; @@ -93,24 +100,25 @@ pub struct Spake2pVerifierData { // For the VerifierOption::Verifier, the following fields only serve // information purposes pub salt: Spake2pVerifierSalt, + /// Number of valid bytes in the salt. + pub salt_len: u8, pub count: u32, } impl Spake2pVerifierData { pub fn init_with_pw<'a>( password: Spake2pVerifierPasswordRef<'a>, - salt: Spake2pVerifierSaltRef<'a>, + salt: &'a [u8], ) -> impl Init + 'a { Self::init_empty().chain(move |this| { this.configure_pw(password, salt); - Ok(()) }) } pub fn init<'a>( verifier: Spake2pVerifierStrRef<'a>, - salt: Spake2pVerifierSaltRef<'a>, + salt: &'a [u8], count: u32, ) -> impl Init + 'a { Self::init_empty().chain(move |this| { @@ -125,32 +133,50 @@ impl Spake2pVerifierData { password: None, verifier <- Spake2pVerifierStr::init(), salt <- Spake2pVerifierSalt::init(), + salt_len: SPAKE2P_VERIFIER_SALT_LEN as u8, count: SPAKE2P_ITERATION_COUNT, }) } - fn configure_pw( - &mut self, - password: Spake2pVerifierPasswordRef<'_>, - salt: Spake2pVerifierSaltRef<'_>, - ) { + fn configure_pw(&mut self, password: Spake2pVerifierPasswordRef<'_>, salt: &[u8]) { self.password = Some(password.into()); - self.salt.load(salt); + self.set_salt(salt); self.verifier.zeroize(); self.count = SPAKE2P_ITERATION_COUNT; } - fn configure_verifier( - &mut self, - verifier: Spake2pVerifierStrRef<'_>, - salt: Spake2pVerifierSaltRef<'_>, - count: u32, - ) { + fn configure_verifier(&mut self, verifier: Spake2pVerifierStrRef<'_>, salt: &[u8], count: u32) { self.password = None; - self.salt.load(salt); + self.set_salt(salt); self.verifier.load(verifier); self.count = count; } + + /// Stamp the variable-length PASE salt into the fixed-size storage. + /// Truncates anything past [`SPAKE2P_VERIFIER_SALT_LEN`] (32 B) so a + /// caller bug can't out-of-bounds the buffer. + fn set_salt(&mut self, salt: &[u8]) { + debug_assert!( + (SPAKE2P_VERIFIER_SALT_MIN_LEN..=SPAKE2P_VERIFIER_SALT_LEN).contains(&salt.len()), + "PASE salt out of range: {} not in {}..={}", + salt.len(), + SPAKE2P_VERIFIER_SALT_MIN_LEN, + SPAKE2P_VERIFIER_SALT_LEN + ); + + let n = salt.len().min(SPAKE2P_VERIFIER_SALT_LEN); + + let storage = self.salt.access_mut(); + storage.fill(0); + storage[..n].copy_from_slice(&salt[..n]); + + self.salt_len = n as u8; + } + + /// Return the in-use slice of the PASE salt (`salt_len` bytes). + pub fn salt_bytes(&self) -> &[u8] { + &self.salt.access()[..self.salt_len as usize] + } } /// Context for the prover side of SPAKE2+, returned by `setup_prover()`. @@ -257,7 +283,7 @@ impl Spake2P { &crypto, pw, verifier.count, - verifier.salt.access(), + verifier.salt_bytes(), &mut w0s_w1s, )?; @@ -938,6 +964,7 @@ mod tests { password: Some(password_ref.into()), verifier: Spake2pVerifierStr::new(), salt: Spake2pVerifierSalt::new(), + salt_len: SPAKE2P_VERIFIER_SALT_LEN as u8, count: iterations, }; verifier_data.salt.load(Spake2pVerifierSaltRef::new(&salt)); @@ -1026,6 +1053,7 @@ mod tests { password: Some(wrong_password_ref.into()), verifier: Spake2pVerifierStr::new(), salt: Spake2pVerifierSalt::new(), + salt_len: SPAKE2P_VERIFIER_SALT_LEN as u8, count: iterations, }; verifier_data.salt.load(Spake2pVerifierSaltRef::new(&salt)); From 04428e79a73651df2fb13cc58116187bd5cdcbb4 Mon Sep 17 00:00:00 2001 From: ivmarkov Date: Sat, 30 May 2026 11:01:50 +0000 Subject: [PATCH 08/15] Bugfixes to the integration tests --- rs-matter/src/dm/clusters/scenes.rs | 283 ++++++++++++++++++++-------- xtask/src/itest.rs | 16 +- 2 files changed, 208 insertions(+), 91 deletions(-) diff --git a/rs-matter/src/dm/clusters/scenes.rs b/rs-matter/src/dm/clusters/scenes.rs index 1e23ba165..ca20a58a8 100644 --- a/rs-matter/src/dm/clusters/scenes.rs +++ b/rs-matter/src/dm/clusters/scenes.rs @@ -617,15 +617,24 @@ impl SceneEntry { /// Per-fabric "last recalled scene" pointer feeding /// `FabricSceneInfo.CurrentScene` / `CurrentGroup` / `SceneValid`. /// +/// The entry persists once a fabric has interacted with scenes (so +/// `FabricSceneInfo` keeps emitting a row for it even after its only +/// scene is removed) — `valid` carries `SceneValid` directly. +/// `TestScenesMultiFabric` step 36 asserts this lifecycle: TH2 removes +/// its only scene and then reads `FabricSceneInfo`, expecting +/// `SceneCount=0` with `CurrentScene`/`CurrentGroup` preserved and +/// `SceneValid=false`. +/// /// `FromTLV` / `ToTLV` are derived (the type has no const generics, /// unlike [`SceneEntry`]) — the persisted shape is a struct with -/// context-tagged fields auto-numbered 0..2 in source order. +/// context-tagged fields auto-numbered 0..3 in source order. #[derive(Debug, Clone, Copy, FromTLV, ToTLV)] #[cfg_attr(feature = "defmt", derive(defmt::Format))] struct CurrentScene { fab_idx: NonZeroU8, group_id: u16, scene_id: SceneId, + valid: bool, } /// All mutable Scenes state, held behind a single mutex via @@ -927,11 +936,20 @@ where /// `N - 1` slack reserves one row for inter-fabric arbitration, /// `/ 2` splits the remaining budget evenly across the /// (typically two) fabrics the spec expects to share the table. - /// The result is saturated at 0 and clamped to `0xFF` for u8. + /// + /// Result is then clamped by the *total free slots* across all + /// fabrics — once fab A and B have consumed their shares, fab C's + /// remaining must drop below its `(N-1)/2` allotment as the global + /// budget shrinks. `TestScenesMaxCapacity` step that asserts + /// `RemainingCapacity == 1` after fabs 1+2 fill 14 of 16 slots + /// catches the unclamped version. Final value is clamped to + /// `0xFF` to fit the u8 wire field. fn remaining_capacity_for_fab(inner: &ScenesStateInner, fab_idx: NonZeroU8) -> u8 { let per_fab_budget = N.saturating_sub(1) / 2; let used = inner.table.iter().filter(|e| e.fab_idx == fab_idx).count(); - per_fab_budget.saturating_sub(used).min(0xFF) as u8 + let per_fab_remaining = per_fab_budget.saturating_sub(used); + let global_remaining = N.saturating_sub(inner.table.len()); + per_fab_remaining.min(global_remaining).min(0xFF) as u8 } /// Check whether `group_id` is present in the Groups cluster's @@ -960,8 +978,8 @@ where } /// Stamp `(group, scene)` as the current recalled scene for this - /// fabric. Bumps `FabricSceneInfo` dataver. Operates on already- - /// locked inner state. + /// fabric with `SceneValid = true`. Bumps `FabricSceneInfo` + /// dataver. Operates on already-locked inner state. fn remember_current( inner: &mut ScenesStateInner, fab_idx: NonZeroU8, @@ -975,6 +993,7 @@ where { slot.group_id = group_id; slot.scene_id = scene_id; + slot.valid = true; } else { // Best-effort push; if the slab is full we silently stop // tracking CurrentScene for this fabric (the spec permits @@ -983,6 +1002,7 @@ where fab_idx, group_id, scene_id, + valid: true, }); } inner.bump_info_dataver(); @@ -1006,29 +1026,37 @@ where group_id: u16, scene_id: SceneId, ) { - let before = inner.current_per_fabric.len(); - inner.current_per_fabric.retain(|c| { - !(c.fab_idx == fab_idx && c.group_id == group_id && c.scene_id == scene_id) - }); - if before != inner.current_per_fabric.len() { + let mut bumped = false; + for c in inner.current_per_fabric.iter_mut() { + if c.valid && c.fab_idx == fab_idx && c.group_id == group_id && c.scene_id == scene_id { + c.valid = false; + bumped = true; + } + } + if bumped { inner.bump_info_dataver(); } } - /// Drop the recalled-scene tracker for `fab_idx` when its stored - /// `group_id` matches the one passed in — used by - /// `RemoveAllScenes(group_id)` and the `COPY_ALL` mode of - /// `CopyScene` (both of which can affect any scene in the group). + /// Flip `SceneValid → false` for `fab_idx` when its remembered + /// `group_id` matches — used by `RemoveAllScenes(group_id)` and + /// the `COPY_ALL` mode of `CopyScene` (both of which can affect + /// any scene in the group). The slot keeps `CurrentScene` / + /// `CurrentGroup` populated for the next read so the fabric stays + /// "known" in `FabricSceneInfo`. fn invalidate_current_if_match_group( inner: &mut ScenesStateInner, fab_idx: NonZeroU8, group_id: u16, ) { - let before = inner.current_per_fabric.len(); - inner - .current_per_fabric - .retain(|c| !(c.fab_idx == fab_idx && c.group_id == group_id)); - if before != inner.current_per_fabric.len() { + let mut bumped = false; + for c in inner.current_per_fabric.iter_mut() { + if c.valid && c.fab_idx == fab_idx && c.group_id == group_id { + c.valid = false; + bumped = true; + } + } + if bumped { inner.bump_info_dataver(); } } @@ -1055,6 +1083,18 @@ where scene_to: SceneId, copy_all: bool, ) -> u8 { + // Per-fab capacity gate up front: when the originating fabric + // is already at its `(N-1)/2` allotment (or the global table + // is full), the copy MUST be rejected with `INSUFFICIENT_SPACE` + // even when the destination scene already exists and would + // otherwise be a no-growth overwrite. Mirrors chip's reference + // handler (`TestScenesMaxCapacity` step 56 asserts this: TH2 + // is at-cap and copies onto an already-existing destination, + // but the test expects `0x89` regardless). + if Self::remaining_capacity_for_fab(inner, fab_idx) == 0 { + return SC_INSUFFICIENT_SPACE; + } + let mut found_source = false; let mut idx = 0; while idx < inner.table.len() { @@ -1082,19 +1122,27 @@ where { inner.table[pos].transition_time = src_transition_time; inner.table[pos].extension_fields = src_extension_fields; - } else if inner - .table - .push(SceneEntry { - fab_idx, - endpoint_id, - group_id: group_to, - scene_id: target_scene_id, - transition_time: src_transition_time, - extension_fields: src_extension_fields, - }) - .is_err() - { - return SC_INSUFFICIENT_SPACE; + } else { + if Self::remaining_capacity_for_fab(inner, fab_idx) == 0 { + // Reject when the originating fabric + // has reached its per-fab budget ((N-1)/2 entries. + return SC_INSUFFICIENT_SPACE; + } + + if inner + .table + .push(SceneEntry { + fab_idx, + endpoint_id, + group_id: group_to, + scene_id: target_scene_id, + transition_time: src_transition_time, + extension_fields: src_extension_fields, + }) + .is_err() + { + return SC_INSUFFICIENT_SPACE; + } } // Single-scene mode copies exactly one entry — bail @@ -1151,33 +1199,44 @@ where // Snapshot the relevant scalars under a single lock, then build // the response outside the lock. - let (scene_count, cur_group, cur_scene, valid, remaining) = self.state.with(|inner| { - let count = inner - .table - .iter() - .filter(|e| e.fab_idx == accessor_fab_idx && e.endpoint_id == endpoint_id) - .count(); - let current = inner - .current_per_fabric - .iter() - .find(|c| c.fab_idx == accessor_fab_idx) - .copied(); - // Per chip's reference behaviour, `CurrentScene` and - // `CurrentGroup` are always emitted on the wire (even when - // `SceneValid=false`); we just set them to 0 in that case. - // `TestScenesFabricSceneInfo` asserts equality against a - // fully-populated struct rather than against an - // omitted-fields shape. - let (g, s, v) = match current { - Some(c) => (Some(c.group_id), Some(c.scene_id), true), - None => (Some(0u16), Some(0u8), false), - }; - let rem = Self::remaining_capacity_for_fab(inner, accessor_fab_idx); - (count.min(0xFF) as u8, g, s, v, rem) - }); + let (has_state, scene_count, cur_group, cur_scene, valid, remaining) = + self.state.with(|inner| { + let count = inner + .table + .iter() + .filter(|e| e.fab_idx == accessor_fab_idx && e.endpoint_id == endpoint_id) + .count(); + let current = inner + .current_per_fabric + .iter() + .find(|c| c.fab_idx == accessor_fab_idx) + .copied(); + // A fabric is "known" to the cluster — i.e. gets a + // `FabricSceneInfo` row — once it owns at least one + // scene OR has ever recalled one. `current_per_fabric` + // entries persist past invalidation (carrying + // `valid=false`) so the row stays present after the + // last scene is removed (`TestScenesMultiFabric` + // step 36). + let has_state = count > 0 || current.is_some(); + // When a row IS emitted, `CurrentScene` / + // `CurrentGroup` are always populated — set to 0 when + // the fabric has never recalled a scene (i.e. no + // `current` slot at all). + let (g, s, v) = match current { + Some(c) => (Some(c.group_id), Some(c.scene_id), c.valid), + None => (Some(0u16), Some(0u8), false), + }; + let rem = Self::remaining_capacity_for_fab(inner, accessor_fab_idx); + (has_state, count.min(0xFF) as u8, g, s, v, rem) + }); match builder { ArrayAttributeRead::ReadAll(arr) => { + if !has_state { + return arr.end(); + } + let arr = arr .push()? .scene_count(scene_count)? @@ -1675,7 +1734,7 @@ where let transition_time = prior_tt.unwrap_or(0); let status_code = self.state.with(|inner| { - Self::upsert_scene( + let status = Self::upsert_scene( inner, fab_idx, endpoint_id, @@ -1690,7 +1749,22 @@ where } Ok(()) }, - ) + )?; + // StoreScene captures the device's *current* attribute + // state into the table, so the stored scene by definition + // matches the current state. Per Matter App Cluster spec + // §1.4.6.5 / chip's reference, that promotes + // `(group, scene)` to the recalled scene with + // `SceneValid=true` — `TestScenesMultiFabric` / + // `TestScenesMaxCapacity` / `TestScenesFabricSceneInfo` + // all assert this behaviour. `upsert_scene` may have just + // flipped the slot invalid (when overwriting the previously + // recalled entry); the `remember_current` below stamps it + // back to valid with the freshly-stored ID. + if status == 0 { + Self::remember_current(inner, fab_idx, group_id, scene_id); + } + Ok::<_, Error>(status) })?; if status_code == 0 { @@ -2224,14 +2298,16 @@ mod tests { #[test] fn copy_all_preserves_each_source_blob() { + // `N=16` so per-fab cap `(N-1)/2 = 7` comfortably absorbs the + // 2-source + 2-copy = 4 rows for fab(1). let blob_a = &[0xAA, 0xBB, 0x18]; let blob_b = &[0xCC, 0x18]; - let mut inner = ScenesStateInner::<8>::new(); + let mut inner = ScenesStateInner::<16>::new(); push(&mut inner, entry_with_blob(fab(1), 1, 10, 1, 100, blob_a)); push(&mut inner, entry_with_blob(fab(1), 1, 10, 2, 200, blob_b)); let status = - ScenesHandler::<8>::copy_scenes_inner(&mut inner, fab(1), 1, 10, 0, 20, 0, true); + ScenesHandler::<16>::copy_scenes_inner(&mut inner, fab(1), 1, 10, 0, 20, 0, true); assert_eq!(status, 0); assert_eq!(find_blob(&inner, fab(1), 1, 20, 1), Some(&blob_a[..])); @@ -2323,12 +2399,14 @@ mod tests { #[test] fn copy_all_copies_every_source_scene() { - let mut inner = ScenesStateInner::<8>::new(); + // `N=16` so per-fab cap `(N-1)/2 = 7` comfortably absorbs the + // 3-source + 3-copy = 6 rows for fab(1). + let mut inner = ScenesStateInner::<16>::new(); push(&mut inner, entry(fab(1), 1, 10, 1, 100)); push(&mut inner, entry(fab(1), 1, 10, 2, 200)); push(&mut inner, entry(fab(1), 1, 10, 3, 300)); - let status = ScenesHandler::<8>::copy_scenes_inner( + let status = ScenesHandler::<16>::copy_scenes_inner( &mut inner, fab(1), 1, @@ -2478,7 +2556,14 @@ mod tests { ScenesHandler::<8>::copy_scenes_inner(&mut inner, fab(1), 1, 10, 5, 20, 7, false); assert_eq!(status, 0); - assert!(inner.current_per_fabric.iter().all(|c| c.fab_idx != fab(1))); + // The slot persists (so `FabricSceneInfo` keeps emitting a row + // for this fabric) but `valid` flips to false. + let slot = inner + .current_per_fabric + .iter() + .find(|c| c.fab_idx == fab(1)) + .expect("slot kept"); + assert!(!slot.valid); } #[test] @@ -2498,6 +2583,7 @@ mod tests { assert_eq!(inner.current_per_fabric.len(), 1); assert_eq!(inner.current_per_fabric[0].group_id, 99); assert_eq!(inner.current_per_fabric[0].scene_id, 99); + assert!(inner.current_per_fabric[0].valid); } #[test] @@ -2562,11 +2648,25 @@ mod tests { // scene. ScenesHandler::<8>::invalidate_current_if_match_scene(&mut inner, fab(1), 99, 99); assert_eq!(inner.current_per_fabric.len(), 2); + assert!(inner.current_per_fabric.iter().all(|c| c.valid)); - // Matching (group, scene) on fab(1) drops only fab(1)'s entry. + // Matching (group, scene) on fab(1) flips just fab(1)'s valid + // bit — entries always persist so `FabricSceneInfo` still + // emits a row for the fabric. ScenesHandler::<8>::invalidate_current_if_match_scene(&mut inner, fab(1), 10, 1); - assert_eq!(inner.current_per_fabric.len(), 1); - assert_eq!(inner.current_per_fabric[0].fab_idx, fab(2)); + assert_eq!(inner.current_per_fabric.len(), 2); + let f1 = inner + .current_per_fabric + .iter() + .find(|c| c.fab_idx == fab(1)) + .unwrap(); + assert!(!f1.valid); + let f2 = inner + .current_per_fabric + .iter() + .find(|c| c.fab_idx == fab(2)) + .unwrap(); + assert!(f2.valid); } #[test] @@ -2577,14 +2677,24 @@ mod tests { // Wrong group: no-op. ScenesHandler::<8>::invalidate_current_if_match_group(&mut inner, fab(1), 99); - assert_eq!(inner.current_per_fabric.len(), 2); + assert!(inner.current_per_fabric.iter().all(|c| c.valid)); - // Right group on fab(1), regardless of scene id, drops fab(1) - // — exercising the `RemoveAllScenes(group)` / `CopyScene COPY_ALL` - // path. + // Right group on fab(1), regardless of scene id, flips fab(1)'s + // valid bit — exercising the `RemoveAllScenes(group)` / + // `CopyScene COPY_ALL` path. ScenesHandler::<8>::invalidate_current_if_match_group(&mut inner, fab(1), 10); - assert_eq!(inner.current_per_fabric.len(), 1); - assert_eq!(inner.current_per_fabric[0].fab_idx, fab(2)); + let f1 = inner + .current_per_fabric + .iter() + .find(|c| c.fab_idx == fab(1)) + .unwrap(); + assert!(!f1.valid); + let f2 = inner + .current_per_fabric + .iter() + .find(|c| c.fab_idx == fab(2)) + .unwrap(); + assert!(f2.valid); } // ---- Phase D: AddScene / StoreScene shared `upsert_scene` path ---- @@ -2715,7 +2825,14 @@ mod tests { ScenesHandler::<8>::upsert_scene(&mut inner, fab(1), 1, 10, 5, 100, fill_with(&[0x18])) .unwrap(); - assert!(!inner.current_per_fabric.iter().any(|c| c.fab_idx == fab(1))); + // The slot stays — the fabric is still "known" to the cluster + // — but `valid` flips false. + let f1 = inner + .current_per_fabric + .iter() + .find(|c| c.fab_idx == fab(1)) + .expect("slot kept"); + assert!(!f1.valid); } #[test] @@ -2734,6 +2851,7 @@ mod tests { assert_eq!(inner.current_per_fabric.len(), 1); assert_eq!(inner.current_per_fabric[0].group_id, 1); assert_eq!(inner.current_per_fabric[0].scene_id, 1); + assert!(inner.current_per_fabric[0].valid); } #[test] @@ -2748,11 +2866,20 @@ mod tests { ScenesHandler::<8>::upsert_scene(&mut inner, fab(1), 1, 10, 5, 100, fill_with(&[0x18])) .unwrap(); - assert!(!inner.current_per_fabric.iter().any(|c| c.fab_idx == fab(1))); - assert!( - inner.current_per_fabric.iter().any(|c| c.fab_idx == fab(2)), - "fab(2)'s CurrentScene must not be touched" - ); + // fab(1) is invalidated (valid=false) but the slot stays. + // fab(2) is untouched. + let f1 = inner + .current_per_fabric + .iter() + .find(|c| c.fab_idx == fab(1)) + .expect("fab(1) slot kept"); + assert!(!f1.valid); + let f2 = inner + .current_per_fabric + .iter() + .find(|c| c.fab_idx == fab(2)) + .expect("fab(2) slot kept"); + assert!(f2.valid, "fab(2)'s CurrentScene must not be touched"); } #[test] diff --git a/xtask/src/itest.rs b/xtask/src/itest.rs index c50b1cd46..d2fa558b7 100644 --- a/xtask/src/itest.rs +++ b/xtask/src/itest.rs @@ -519,19 +519,9 @@ pub(crate) const SCENES_TESTS: &[&str] = &[ // wiring pass (`SceneClusterHandler` callers notify `ScenesState` // on state changes); deferred so persistence ships independently. // "TestScenesFabricSceneInfo", - // - // `TestScenesMultiFabric` / `TestScenesFabricRemoval` / - // `TestScenesMaxCapacity` all begin with a multi-fabric setup - // step that calls `AdministratorCommissioning.OpenCommissioningWindow` - // with a 16-byte PAKE salt. `rs-matter`'s `Spake2pVerifierSalt` - // is currently a fixed `[u8; 32]` (see - // `sc::pase::spake2p::SPAKE2P_VERIFIER_SALT_LEN`), so the salt - // copy fails with `InvalidData` and the commissioning window - // never opens. Re-enable once variable-length salts - // (16–32 B per Matter Core spec) are supported end-to-end. - // "TestScenesMultiFabric", - // "TestScenesFabricRemoval", - // "TestScenesMaxCapacity", + "TestScenesMultiFabric", + "TestScenesFabricRemoval", + "TestScenesMaxCapacity", ]; /// A pre-canned test suite. Selects a default test list, the example From eb83f94dcb6eb7f40c224f5aecfb685b25621ec2 Mon Sep 17 00:00:00 2001 From: ivmarkov Date: Sun, 31 May 2026 19:57:23 +0000 Subject: [PATCH 09/15] Refactor scenes not to use the generic AsyncHandler --- examples/src/bin/scenes_tests.rs | 69 +-- .../src/dm/clusters/app/color_control.rs | 516 +--------------- .../src/dm/clusters/app/level_control.rs | 305 ++++++---- rs-matter/src/dm/clusters/app/on_off.rs | 241 +++++--- rs-matter/src/dm/clusters/scenes.rs | 563 +++++++----------- xtask/src/itest.rs | 20 +- 6 files changed, 589 insertions(+), 1125 deletions(-) diff --git a/examples/src/bin/scenes_tests.rs b/examples/src/bin/scenes_tests.rs index ceca31d2c..7c5261a81 100644 --- a/examples/src/bin/scenes_tests.rs +++ b/examples/src/bin/scenes_tests.rs @@ -45,9 +45,7 @@ use futures_lite::StreamExt; use rand::RngCore; use rs_matter::crypto::{default_crypto, Crypto}; -use rs_matter::dm::clusters::app::level_control::scenes::LevelControlSceneClusterHandler; use rs_matter::dm::clusters::app::level_control::{self, LevelControlHooks}; -use rs_matter::dm::clusters::app::on_off::scenes::OnOffSceneClusterHandler; use rs_matter::dm::clusters::app::on_off::{self, OnOffHooks, StartUpOnOffEnum}; use rs_matter::dm::clusters::decl::level_control::{ AttributeId, CommandId, OptionsBitmap, FULL_CLUSTER as LEVEL_CONTROL_FULL_CLUSTER, @@ -63,12 +61,12 @@ use rs_matter::dm::clusters::unit_testing::{ }; use rs_matter::dm::devices::test::{DAC_PRIVKEY, TEST_DEV_ATT, TEST_DEV_COMM, TEST_DEV_DET}; use rs_matter::dm::devices::DEV_TYPE_DIMMABLE_LIGHT; +use rs_matter::dm::endpoints; use rs_matter::dm::events::Events; use rs_matter::dm::networks::eth::EthNetwork; use rs_matter::dm::networks::SysNetifs; use rs_matter::dm::subscriptions::Subscriptions; use rs_matter::dm::IMBuffer; -use rs_matter::dm::{endpoints, AsyncHandler, EmptyHandler}; use rs_matter::dm::{ Async, Cluster, DataModel, DataModelHandler, Dataver, Endpoint, EpClMatcher, Node, }; @@ -172,9 +170,30 @@ fn run() -> Result<(), Error> { let mut rand = crypto.rand()?; + // `ScenesState` must be live BEFORE the scenable cluster handlers + // are constructed: each handler's `with_scene_invalidator` builder + // wires a `&ScenesState` reference into the handler so any + // command-driven mutation of its scenable attributes (`OnOff`, + // `CurrentLevel`) flips `SceneValid` to false for the recalled + // scene on this endpoint (see `TestScenesFabricSceneInfo` + // step 25). + let unit_testing_data = UNIT_TESTING_DATA + .uninit() + .init_with(RefCell::init(UnitTestingHandlerData::init())); + + let scenes_state = SCENES_STATE.uninit().init_with(ScenesState::init()); + // Restore the scene table + per-fabric `CurrentScene` bookkeeping + // from KV — re-applies any scenes a previous run of this binary + // stored under `SCENES_KEY`. Must run before the data model goes + // live so `RecallScene` on the very first commission step (after + // a reboot in the middle of `Test_TC_S_2_2`) sees the persisted + // entries. + futures_lite::future::block_on(scenes_state.load_persist(&mut kv, kv_buf))?; + // OnOff cluster setup let on_off_handler = - on_off::OnOffHandler::new(Dataver::new_rand(&mut rand), 1, OnOffDeviceLogic::new()); + on_off::OnOffHandler::new(Dataver::new_rand(&mut rand), 1, OnOffDeviceLogic::new()) + .with_scene_invalidator(scenes_state); // LevelControl cluster setup let level_control_handler = level_control::LevelControlHandler::new( @@ -186,49 +205,23 @@ fn run() -> Result<(), Error> { options: OptionsBitmap::from_bits(OptionsBitmap::EXECUTE_IF_OFF.bits()).unwrap(), ..Default::default() }, - ); + ) + .with_scene_invalidator(scenes_state); // Cluster wiring, validation and initialisation on_off_handler.init(Some(&level_control_handler)); level_control_handler.init(Some(&on_off_handler)); - let scenes_handler0 = EmptyHandler - .chain( - EpClMatcher::new(Some(1), Some(OnOffDeviceLogic::CLUSTER.id)), - on_off::HandlerAsyncAdaptor(&on_off_handler), - ) - .chain( - EpClMatcher::new(Some(1), Some(LevelControlDeviceLogic::CLUSTER.id)), - level_control::HandlerAsyncAdaptor(&level_control_handler), - ); - // Scenes Management cluster setup. // - // `ScenesState` holds the scene table; `ScenesHandler` is generic - // over a tuple-recursive `SceneClusters` registry that names the - // per-cluster `SceneClusterHandler` impls. Both OnOff and - // LevelControl ship a ZST impl in their respective modules - // (`app::on_off::scenes` / `app::level_control::scenes`). - let unit_testing_data = UNIT_TESTING_DATA - .uninit() - .init_with(RefCell::init(UnitTestingHandlerData::init())); - - let scenes_state = SCENES_STATE.uninit().init_with(ScenesState::init()); - // Restore the scene table + per-fabric `CurrentScene` bookkeeping - // from KV — re-applies any scenes a previous run of this binary - // stored under `SCENES_KEY`. Must run before the data model goes - // live so `RecallScene` on the very first commission step (after - // a reboot in the middle of `Test_TC_S_2_2`) sees the persisted - // entries. - futures_lite::future::block_on(scenes_state.load_persist(&mut kv, kv_buf))?; + // `OnOffHandler` and `LevelControlHandler` implement + // `SceneClusterHandler` directly — the same `&handler` value the + // data-model chain uses doubles as the scenes-registry entry via + // the blanket `impl SceneClusterHandler for &T`. let scenes_handler = ScenesHandler::new( Dataver::new_rand(&mut rand), scenes_state, - scenes_handler0, - ( - OnOffSceneClusterHandler, - (LevelControlSceneClusterHandler, ()), - ), + (&on_off_handler, (&level_control_handler, ())), ); // Create the Data Model instance @@ -342,7 +335,7 @@ fn dm_handler<'a, LH: LevelControlHooks, OH: OnOffHooks, R>( mut rand: impl RngCore + Copy, on_off: &'a on_off::OnOffHandler<'a, OH, LH>, level_control: &'a level_control::LevelControlHandler<'a, LH, OH>, - scenes: ScenesHandler<'a, SCENES_CAPACITY, impl AsyncHandler + 'a, R>, + scenes: ScenesHandler<'a, SCENES_CAPACITY, R>, unit_testing_data: &'a RefCell, ) -> impl DataModelHandler + 'a where diff --git a/rs-matter/src/dm/clusters/app/color_control.rs b/rs-matter/src/dm/clusters/app/color_control.rs index 4a8b50cc5..dc66a2874 100644 --- a/rs-matter/src/dm/clusters/app/color_control.rs +++ b/rs-matter/src/dm/clusters/app/color_control.rs @@ -15,505 +15,19 @@ * limitations under the License. */ -//! ColorControl cluster (placeholder). +//! ColorControl cluster — placeholder. //! -//! A full ColorControl cluster handler is not yet shipped — this -//! module currently only provides the [`scenes`] submodule, which -//! lets ColorControl participate in scene capture / recall via -//! Scenes Management. Downstream apps that ship their own -//! ColorControl handler can register the [`scenes::ColorControlSceneClusterHandler`] -//! alongside it with `ScenesHandler::new`. - -/// Scenes Management integration for the ColorControl cluster. -/// -/// # Why ColorControl needs special wiring -/// -/// Unlike OnOff / LevelControl (each one read-only scene-able -/// attribute, one apply command), ColorControl has: -/// -/// - **Up to 9 scene-able attributes** (`CurrentX`, `CurrentY`, -/// `EnhancedCurrentHue`, `CurrentSaturation`, `ColorLoopActive`, -/// `ColorLoopDirection`, `ColorLoopTime`, `ColorTemperatureMireds`, -/// `EnhancedColorMode`). All are read-only at the attribute level. -/// - **Feature-conditional capture**: which attributes are stored -/// depends on the device's `FeatureMap` (`XY`, `HUE_AND_SATURATION`, -/// `ENHANCED_HUE`, `COLOR_LOOP`, `COLOR_TEMPERATURE`). -/// - **Mode-dependent apply**: the captured `EnhancedColorMode` -/// selects which `MoveTo*` command to invoke (`MoveToColor` for XY, -/// `MoveToColorTemperature` for `ColorTemperatureMireds`, etc.). If -/// `ColorLoopActive` is captured as `1`, apply instead starts a -/// color loop via `ColorLoopSet`. -/// -/// Reading the FeatureMap attribute at runtime is possible but adds -/// one cross-cluster read per scene operation. Instead we let the -/// application inject a [`ColorControlFeatureLookup`] that maps -/// `EndptId` → `Feature` bits — the app knows which ColorControl -/// features it enabled on which endpoints, so this is free. -pub mod scenes { - use crate::dm::clusters::decl::color_control::{ - AttributeId, ColorLoopActionEnum, ColorLoopDirectionEnum, ColorLoopSetRequestBuilder, - CommandId, EnhancedColorModeEnum, EnhancedMoveToHueAndSaturationRequestBuilder, Feature, - MoveToColorRequestBuilder, MoveToColorTemperatureRequestBuilder, - MoveToHueAndSaturationRequestBuilder, OptionsBitmap, UpdateFlagsBitmap, FULL_CLUSTER, - }; - use crate::dm::clusters::decl::scenes_management::{ - AttributeValuePairStruct, AttributeValuePairStructArrayBuilder, - }; - use crate::dm::clusters::scenes::{SceneClusterHandler, SceneContext}; - use crate::dm::{AsyncHandler, AttrId, ClusterId, EndptId, InvokeContext}; - use crate::error::Error; - use crate::tlv::{TLVArray, TLVBuilderParent, TLVTag, TLVWriteParent}; - use crate::utils::storage::WriteBuf; - - /// Worst-case TLV-encoded size of any command this scene impl - /// sends. The largest is `ColorLoopSet` (7 fields): - /// - /// ```text - /// 0x15 (struct start, anon) 1 B - /// updateFlags bitmap8 @ Ctx 0 3 B - /// action enum8 @ Ctx 1 3 B - /// direction enum8 @ Ctx 2 3 B - /// time u16 @ Ctx 3 4 B - /// startHue u16 @ Ctx 4 4 B - /// optionsMask bitmap8 @ Ctx 5 3 B - /// optionsOverride bitmap8 @ Ctx 6 3 B - /// 0x18 (end_container) 1 B - /// ----------------------------------------------------------- 25 B - /// ``` - /// - /// The 4 Move-To commands all fit in ≤ 20 B. 32 leaves a - /// comfortable margin without over-committing. - const MAX_REQUEST_BUF: usize = 32; - - /// Application-supplied feature lookup for ColorControl. - /// - /// The Matter spec lets ColorControl be deployed with any subset - /// of {`XY`, `HUE_AND_SATURATION`, `ENHANCED_HUE`, `COLOR_LOOP`, - /// `COLOR_TEMPERATURE`}. The Scenes integration needs to know - /// which features are active on each endpoint so it can decide - /// which attributes to capture and which `MoveTo*` / - /// `ColorLoopSet` command to invoke on recall. - /// - /// The application implements this on whatever per-endpoint - /// state it already has (often a static lookup), and passes a - /// reference into [`ColorControlSceneClusterHandler::new`]. - pub trait ColorControlFeatureLookup { - /// Return the `Feature` bitmap enabled for `endpoint_id`. - /// Empty for endpoints where ColorControl is not installed - /// (callers should not invoke this for endpoints without - /// ColorControl). - fn features(&self, endpoint_id: EndptId) -> Feature; - } - - impl ColorControlFeatureLookup for &T { - fn features(&self, endpoint_id: EndptId) -> Feature { - (**self).features(endpoint_id) - } - } - - /// [`SceneClusterHandler`] for ColorControl. - /// - /// Holds a reference to a [`ColorControlFeatureLookup`]; not a - /// ZST because the cluster's behaviour is feature-conditional. - /// - /// ```ignore - /// let cc_lookup = MyFeatureLookup; - /// let scenes = ScenesHandler::new( - /// dataver, &scenes_state, - /// (OnOffSceneClusterHandler, - /// (LevelControlSceneClusterHandler, - /// (ColorControlSceneClusterHandler::new(&cc_lookup), ()))), - /// ); - /// ``` - #[derive(Copy, Clone)] - pub struct ColorControlSceneClusterHandler<'a> { - features: &'a dyn ColorControlFeatureLookup, - } - - impl<'a> ColorControlSceneClusterHandler<'a> { - pub const fn new(features: &'a dyn ColorControlFeatureLookup) -> Self { - Self { features } - } - } - - impl SceneClusterHandler for ColorControlSceneClusterHandler<'_> { - const CLUSTER_ID: ClusterId = FULL_CLUSTER.id; - - fn is_scenable_attribute(attribute_id: AttrId) -> bool { - // Per Matter App Cluster spec §3.2.10 the scenable - // attributes for ColorControl are: `CurrentX`, `CurrentY`, - // `EnhancedCurrentHue`, `CurrentSaturation`, - // `ColorLoopActive`, `ColorLoopDirection`, `ColorLoopTime`, - // `ColorTemperatureMireds`, `EnhancedColorMode`. Feature - // availability is checked at recall time (see `apply`), - // not here — `is_scenable_attribute` only validates the - // shape of an `AddScene` payload. - matches!( - attribute_id, - a if a == AttributeId::CurrentX as AttrId - || a == AttributeId::CurrentY as AttrId - || a == AttributeId::EnhancedCurrentHue as AttrId - || a == AttributeId::CurrentSaturation as AttrId - || a == AttributeId::ColorLoopActive as AttrId - || a == AttributeId::ColorLoopDirection as AttrId - || a == AttributeId::ColorLoopTime as AttrId - || a == AttributeId::ColorTemperatureMireds as AttrId - || a == AttributeId::EnhancedColorMode as AttrId - ) - } - - async fn capture( - &self, - sctx: &SceneContext, - endpoint_id: EndptId, - avp_array: AttributeValuePairStructArrayBuilder

, - ) -> Result, Error> - where - C: InvokeContext, - T: AsyncHandler, - P: TLVBuilderParent, - { - let features = self.features.features(endpoint_id); - - // Capture order mirrors chip's - // `DefaultColorControlSceneHandler::SerializeSave`. - // `EnhancedColorMode` is captured unconditionally — apply - // dispatches on it. - let avp_array = if features.contains(Feature::XY) { - let x: u16 = sctx - .read(endpoint_id, FULL_CLUSTER.id, AttributeId::CurrentX as _) - .await?; - let avp_array = avp_array.push_u16(AttributeId::CurrentX as _, x)?; - let y: u16 = sctx - .read(endpoint_id, FULL_CLUSTER.id, AttributeId::CurrentY as _) - .await?; - avp_array.push_u16(AttributeId::CurrentY as _, y)? - } else { - avp_array - }; - - let avp_array = if features.contains(Feature::ENHANCED_HUE) { - let h: u16 = sctx - .read( - endpoint_id, - FULL_CLUSTER.id, - AttributeId::EnhancedCurrentHue as _, - ) - .await?; - avp_array.push_u16(AttributeId::EnhancedCurrentHue as _, h)? - } else { - avp_array - }; - - let avp_array = if features.contains(Feature::HUE_AND_SATURATION) { - let s: u8 = sctx - .read( - endpoint_id, - FULL_CLUSTER.id, - AttributeId::CurrentSaturation as _, - ) - .await?; - avp_array.push_u8(AttributeId::CurrentSaturation as _, s)? - } else { - avp_array - }; - - let avp_array = if features.contains(Feature::COLOR_LOOP) { - let active: u8 = sctx - .read( - endpoint_id, - FULL_CLUSTER.id, - AttributeId::ColorLoopActive as _, - ) - .await?; - let avp_array = avp_array.push_u8(AttributeId::ColorLoopActive as _, active)?; - let direction: u8 = sctx - .read( - endpoint_id, - FULL_CLUSTER.id, - AttributeId::ColorLoopDirection as _, - ) - .await?; - let avp_array = - avp_array.push_u8(AttributeId::ColorLoopDirection as _, direction)?; - let time: u16 = sctx - .read( - endpoint_id, - FULL_CLUSTER.id, - AttributeId::ColorLoopTime as _, - ) - .await?; - avp_array.push_u16(AttributeId::ColorLoopTime as _, time)? - } else { - avp_array - }; - - let avp_array = if features.contains(Feature::COLOR_TEMPERATURE) { - let mireds: u16 = sctx - .read( - endpoint_id, - FULL_CLUSTER.id, - AttributeId::ColorTemperatureMireds as _, - ) - .await?; - avp_array.push_u16(AttributeId::ColorTemperatureMireds as _, mireds)? - } else { - avp_array - }; - - // `EnhancedColorMode` is always captured. The enum is - // `enum8`, so serialized as `valueUnsigned8`. - let mode_u8: u8 = sctx - .read( - endpoint_id, - FULL_CLUSTER.id, - AttributeId::EnhancedColorMode as _, - ) - .await?; - avp_array.push_u8(AttributeId::EnhancedColorMode as _, mode_u8) - } - - async fn apply( - &self, - sctx: &SceneContext, - endpoint_id: EndptId, - transition_time_ms: u32, - avp_list: &TLVArray<'_, AttributeValuePairStruct<'_>>, - ) -> Result<(), Error> - where - C: InvokeContext, - T: AsyncHandler, - { - // Sweep the AVP list once and stash each known value. We - // need EnhancedColorMode *and* the mode-specific values - // before we can decide which command to invoke. - let mut mode: Option = None; - let mut current_x: Option = None; - let mut current_y: Option = None; - let mut current_saturation: Option = None; - let mut enhanced_current_hue: Option = None; - let mut color_temperature_mireds: Option = None; - let mut color_loop_active: Option = None; - let mut color_loop_direction: Option = None; - let mut color_loop_time: Option = None; - - for avp in avp_list.iter() { - let avp = avp?; - let attr_id = avp.attribute_id()?; - if attr_id == AttributeId::EnhancedColorMode as _ { - if let Some(v) = avp.value_unsigned_8()? { - mode = enhanced_color_mode_from_u8(v); - } - } else if attr_id == AttributeId::CurrentX as _ { - current_x = avp.value_unsigned_16()?; - } else if attr_id == AttributeId::CurrentY as _ { - current_y = avp.value_unsigned_16()?; - } else if attr_id == AttributeId::CurrentSaturation as _ { - current_saturation = avp.value_unsigned_8()?; - } else if attr_id == AttributeId::EnhancedCurrentHue as _ { - enhanced_current_hue = avp.value_unsigned_16()?; - } else if attr_id == AttributeId::ColorTemperatureMireds as _ { - color_temperature_mireds = avp.value_unsigned_16()?; - } else if attr_id == AttributeId::ColorLoopActive as _ { - color_loop_active = avp.value_unsigned_8()?; - } else if attr_id == AttributeId::ColorLoopDirection as _ { - color_loop_direction = avp.value_unsigned_8()?; - } else if attr_id == AttributeId::ColorLoopTime as _ { - color_loop_time = avp.value_unsigned_16()?; - } - } - - // `MoveTo*` and `ColorLoopSet` carry `transitionTime` / - // `time` as `int16u`. Recall passes `int32u` milliseconds - // — convert with saturation. (`ColorLoopSet.time` is - // already in seconds in the spec; we pass through the - // captured `ColorLoopTime` value unchanged because that - // attribute is already in seconds per the spec.) - let transition_ds = (transition_time_ms / 100).min(u16::MAX as u32) as u16; - - // If the scene captured an active color loop, hand off to - // ColorLoopSet and ignore the Move-To dispatch — mirrors - // chip's behavior in `ColorControl::ApplyScene`. - if color_loop_active == Some(1) { - let direction = color_loop_direction - .and_then(color_loop_direction_from_u8) - .unwrap_or(ColorLoopDirectionEnum::Increment); - let time = color_loop_time.unwrap_or(0x0019); - - let mut data_buf = [0u8; MAX_REQUEST_BUF]; - let data_len = { - let mut wb = WriteBuf::new(&mut data_buf); - let parent = TLVWriteParent::new("Scene/ColorLoopSet", &mut wb); - ColorLoopSetRequestBuilder::new(parent, &TLVTag::Anonymous)? - .update_flags( - UpdateFlagsBitmap::UPDATE_ACTION - | UpdateFlagsBitmap::UPDATE_DIRECTION - | UpdateFlagsBitmap::UPDATE_TIME, - )? - .action(ColorLoopActionEnum::ActivateFromColorLoopStartEnhancedHue)? - .direction(direction)? - .time(time)? - // `StartHue` isn't updated here (no - // UPDATE_START_HUE flag set) but the field is - // mandatory on the wire — pass 0. - .start_hue(0)? - .options_mask(OptionsBitmap::empty())? - .options_override(OptionsBitmap::empty())? - .end()?; - wb.get_tail() - }; - return sctx - .invoke( - endpoint_id, - FULL_CLUSTER.id, - CommandId::ColorLoopSet as _, - &data_buf[..data_len], - ) - .await; - } - - let Some(mode) = mode else { - // No mode captured (perhaps an older firmware's blob - // that didn't include it) — nothing to do. - return Ok(()); - }; - - match mode { - EnhancedColorModeEnum::CurrentXAndCurrentY => { - let (Some(x), Some(y)) = (current_x, current_y) else { - return Ok(()); - }; - let mut data_buf = [0u8; MAX_REQUEST_BUF]; - let data_len = { - let mut wb = WriteBuf::new(&mut data_buf); - let parent = TLVWriteParent::new("Scene/MoveToColor", &mut wb); - MoveToColorRequestBuilder::new(parent, &TLVTag::Anonymous)? - .color_x(x)? - .color_y(y)? - .transition_time(transition_ds)? - .options_mask(OptionsBitmap::empty())? - .options_override(OptionsBitmap::empty())? - .end()?; - wb.get_tail() - }; - sctx.invoke( - endpoint_id, - FULL_CLUSTER.id, - CommandId::MoveToColor as _, - &data_buf[..data_len], - ) - .await - } - EnhancedColorModeEnum::ColorTemperatureMireds => { - let Some(mireds) = color_temperature_mireds else { - return Ok(()); - }; - let mut data_buf = [0u8; MAX_REQUEST_BUF]; - let data_len = { - let mut wb = WriteBuf::new(&mut data_buf); - let parent = TLVWriteParent::new("Scene/MoveToColorTemperature", &mut wb); - MoveToColorTemperatureRequestBuilder::new(parent, &TLVTag::Anonymous)? - .color_temperature_mireds(mireds)? - .transition_time(transition_ds)? - .options_mask(OptionsBitmap::empty())? - .options_override(OptionsBitmap::empty())? - .end()?; - wb.get_tail() - }; - sctx.invoke( - endpoint_id, - FULL_CLUSTER.id, - CommandId::MoveToColorTemperature as _, - &data_buf[..data_len], - ) - .await - } - EnhancedColorModeEnum::CurrentHueAndCurrentSaturation => { - // Non-enhanced hue is u8; if only EnhancedHue was - // captured but the mode says non-enhanced, take the - // low byte. (Chip's behavior is similar — it stashes - // into `colorHueTransitionState->finalEnhancedHue` - // which is then truncated on apply.) - let (Some(hue), Some(sat)) = ( - enhanced_current_hue.map(|h| (h & 0xFF) as u8), - current_saturation, - ) else { - return Ok(()); - }; - let mut data_buf = [0u8; MAX_REQUEST_BUF]; - let data_len = { - let mut wb = WriteBuf::new(&mut data_buf); - let parent = TLVWriteParent::new("Scene/MoveToHueAndSaturation", &mut wb); - MoveToHueAndSaturationRequestBuilder::new(parent, &TLVTag::Anonymous)? - .hue(hue)? - .saturation(sat)? - .transition_time(transition_ds)? - .options_mask(OptionsBitmap::empty())? - .options_override(OptionsBitmap::empty())? - .end()?; - wb.get_tail() - }; - sctx.invoke( - endpoint_id, - FULL_CLUSTER.id, - CommandId::MoveToHueAndSaturation as _, - &data_buf[..data_len], - ) - .await - } - EnhancedColorModeEnum::EnhancedCurrentHueAndCurrentSaturation => { - let (Some(hue), Some(sat)) = (enhanced_current_hue, current_saturation) else { - return Ok(()); - }; - let mut data_buf = [0u8; MAX_REQUEST_BUF]; - let data_len = { - let mut wb = WriteBuf::new(&mut data_buf); - let parent = - TLVWriteParent::new("Scene/EnhancedMoveToHueAndSaturation", &mut wb); - EnhancedMoveToHueAndSaturationRequestBuilder::new( - parent, - &TLVTag::Anonymous, - )? - .enhanced_hue(hue)? - .saturation(sat)? - .transition_time(transition_ds)? - .options_mask(OptionsBitmap::empty())? - .options_override(OptionsBitmap::empty())? - .end()?; - wb.get_tail() - }; - sctx.invoke( - endpoint_id, - FULL_CLUSTER.id, - CommandId::EnhancedMoveToHueAndSaturation as _, - &data_buf[..data_len], - ) - .await - } - } - } - } - - /// Convert a stored `valueUnsigned8` to an - /// `EnhancedColorModeEnum`, returning `None` for unknown values - /// rather than failing the apply. - fn enhanced_color_mode_from_u8(v: u8) -> Option { - match v { - 0 => Some(EnhancedColorModeEnum::CurrentHueAndCurrentSaturation), - 1 => Some(EnhancedColorModeEnum::CurrentXAndCurrentY), - 2 => Some(EnhancedColorModeEnum::ColorTemperatureMireds), - 3 => Some(EnhancedColorModeEnum::EnhancedCurrentHueAndCurrentSaturation), - _ => None, - } - } - - /// Convert a stored `valueUnsigned8` to a `ColorLoopDirectionEnum`, - /// returning `None` for unknown values. - fn color_loop_direction_from_u8(v: u8) -> Option { - match v { - 0 => Some(ColorLoopDirectionEnum::Decrement), - 1 => Some(ColorLoopDirectionEnum::Increment), - _ => None, - } - } -} +//! A full ColorControl cluster handler is not yet shipped. The +//! previous wrapper-based Scenes integration (`pub mod scenes` with +//! a `ColorControlSceneClusterHandler<'a>` that proxied through the +//! `SceneContext` IM-routed read/invoke shim) is removed: the Scenes +//! cluster now talks to scene-able cluster handlers via direct +//! typed method calls on the handler type itself (see +//! [`crate::dm::clusters::scenes::SceneClusterHandler`]). +//! +//! When a real `ColorControlHandler` lands here, the scenes +//! integration will be added back as +//! `impl SceneClusterHandler for ColorControlHandler<...>`, +//! mirroring how [`crate::dm::clusters::app::on_off::OnOffHandler`] +//! and [`crate::dm::clusters::app::level_control::LevelControlHandler`] +//! do it today. diff --git a/rs-matter/src/dm/clusters/app/level_control.rs b/rs-matter/src/dm/clusters/app/level_control.rs index a7936ca2c..868e7de3a 100644 --- a/rs-matter/src/dm/clusters/app/level_control.rs +++ b/rs-matter/src/dm/clusters/app/level_control.rs @@ -39,11 +39,16 @@ use embassy_time::{Duration, Instant}; use crate::dm::clusters::app::on_off::{OnOffHooks, FULL_CLUSTER as ON_OFF_FULL_CLUSTER}; use crate::dm::clusters::app::{level_control, on_off::OnOffHandler}; pub use crate::dm::clusters::decl::level_control::*; +use crate::dm::clusters::decl::scenes_management::{ + AttributeValuePairStruct, AttributeValuePairStructArrayBuilder, +}; +use crate::dm::clusters::scenes::{SceneClusterHandler, SceneInvalidator}; use crate::dm::{ - Cluster, Dataver, EndptId, HandlerContext, InvokeContext, ReadContext, WriteContext, + AttrId, Cluster, ClusterId, Dataver, EndptId, HandlerContext, InvokeContext, ReadContext, + WriteContext, }; use crate::error::{Error, ErrorCode}; -use crate::tlv::Nullable; +use crate::tlv::{Nullable, TLVArray, TLVBuilderParent}; use crate::utils::cell::RefCell; use crate::utils::sync::blocking::Mutex; use crate::utils::sync::Signal; @@ -95,6 +100,16 @@ enum Task { with_on_off: bool, target: u8, transition_time: u16, + /// True when this task was queued by + /// [`SceneClusterHandler::apply`] — every + /// `set_level` call this transition triggers will skip + /// [`notify_scenable_changed`] so the Scenes cluster's + /// `SceneValid` bookkeeping stays true throughout the + /// recall (the state we're transitioning *toward* IS the + /// recalled-scene state). False for regular + /// command-initiated moves, where each step is genuine + /// drift and must invalidate `SceneValid`. + scene_apply: bool, }, Move { with_on_off: bool, @@ -190,6 +205,10 @@ pub struct LevelControlHandler<'a, H: LevelControlHooks, OH: OnOffHooks> { endpoint_id: EndptId, hooks: H, on_off_handler: Mutex>>>, + /// See `OnOffHandler::scene_invalidator` — same role, fired + /// whenever `CurrentLevel` (the cluster's only scenable attribute) + /// mutates. + scene_invalidator: Mutex>>, state: Mutex>, task_signal: Signal>, } @@ -276,11 +295,35 @@ impl<'a, H: LevelControlHooks, OH: OnOffHooks> LevelControlHandler<'a, H, OH> { endpoint_id, hooks, on_off_handler: Mutex::new(Cell::new(None)), + scene_invalidator: Mutex::new(Cell::new(None)), state: Mutex::new(RefCell::new(LevelControlState::new(attribute_defaults))), task_signal: Signal::new(None), } } + /// Attach a [`SceneInvalidator`] (typically the + /// [`crate::dm::clusters::scenes::ScenesState`] that backs the + /// Scenes Management cluster on the same endpoint). When set, + /// every successful mutation of `CurrentLevel` calls the + /// invalidator so the Scenes cluster can flip `SceneValid` to + /// false for any currently-recalled scene on this endpoint. No-op + /// when unset, so non-scenes deployments incur zero overhead. + pub fn with_scene_invalidator(self, invalidator: &'a dyn SceneInvalidator) -> Self { + self.scene_invalidator + .lock(|cell| cell.set(Some(invalidator))); + self + } + + /// Internal: notify the wired [`SceneInvalidator`], if any, that + /// `CurrentLevel` just changed on this endpoint. Called from + /// `set_level` so every Move / Step / MoveTo / Stop command path + /// converges through a single notification site. + fn notify_scenable_changed(&self) { + if let Some(inv) = self.scene_invalidator.lock(|cell| cell.get()) { + inv.scenable_attribute_changed(self.endpoint_id); + } + } + /// Checks that the cluster is correctly configured, including required attributes, commands, and feature dependencies. /// /// # Panics @@ -449,6 +492,7 @@ impl<'a, H: LevelControlHooks, OH: OnOffHooks> LevelControlHandler<'a, H, OH> { level: u8, is_end_of_transition: bool, set_device: bool, + scene_apply: bool, ) -> Result<(Option, bool), Error> { // Store the previous current level before updating, for quiet reporting logic. state.previous_current_level = self.hooks.current_level(); @@ -460,6 +504,21 @@ impl<'a, H: LevelControlHooks, OH: OnOffHooks> LevelControlHandler<'a, H, OH> { false => Some(level), }; self.hooks.set_current_level(current_level); + // Every Move / Step / MoveTo / Stop command path lands here, + // so `set_level` is the single notification point we need to + // hook for the Scenes cluster's `SceneValid` drift logic — + // EXCEPT when this mutation was queued by + // [`SceneClusterHandler::apply`]. In the scene-apply case the + // device is transitioning *toward* the recalled-scene state, + // so SceneValid must stay true throughout (including + // intermediate steps of a non-instant transition). The + // caller passes `scene_apply=true` to suppress drift + // notification on every step; the final step's + // `SceneValid=true` is then set by the Scenes handler's + // `remember_current` after `apply` returns. + if !scene_apply { + self.notify_scenable_changed(); + } let last_notification = Instant::now() - state.last_current_level_notification; // CurrentLevel Quiet report conditions: @@ -532,9 +591,16 @@ impl<'a, H: LevelControlHooks, OH: OnOffHooks> LevelControlHandler<'a, H, OH> { with_on_off, target, transition_time, + scene_apply, } => { if let Err(e) = self - .move_to_level_transition(ctx, with_on_off, target, transition_time) + .move_to_level_transition( + ctx, + with_on_off, + target, + transition_time, + scene_apply, + ) .await { error!("Task::MoveToLevel: {:?}", e); @@ -596,8 +662,10 @@ impl<'a, H: LevelControlHooks, OH: OnOffHooks> LevelControlHandler<'a, H, OH> { // This attribute SHALL indicate the time taken to move to or from the target level when On or Off // commands are received by an On/Off cluster on the same endpoint. if on { + // OnOff-coupling driven: user toggled OnOff and + // LC follows. Not a scene apply. let (level, should_notify) = - self.set_level(state, H::MIN_LEVEL, false, true)?; + self.set_level(state, H::MIN_LEVEL, false, true, false)?; if should_notify { ctx.notify_attr_changed( self.endpoint_id, @@ -748,6 +816,7 @@ impl<'a, H: LevelControlHooks, OH: OnOffHooks> LevelControlHandler<'a, H, OH> { transition_time: Option, options_mask: OptionsBitmap, options_override: OptionsBitmap, + scene_apply: bool, ) -> Result<(), Error> { if let Ok(false) = self.move_to_level_validation(&mut level, with_on_off, options_mask, options_override) @@ -774,6 +843,7 @@ impl<'a, H: LevelControlHooks, OH: OnOffHooks> LevelControlHandler<'a, H, OH> { with_on_off, target: level, transition_time: t_time, + scene_apply, }); Ok(()) @@ -818,7 +888,10 @@ impl<'a, H: LevelControlHooks, OH: OnOffHooks> LevelControlHandler<'a, H, OH> { let t_time = transition_time.unwrap_or(0); - self.move_to_level_transition(ctx, with_on_off, level, t_time) + // `move_to_level_blocking` is only used by command-driven + // paths (MoveToLevel-with-blocking) — never by scene apply + // — so `scene_apply=false`. + self.move_to_level_transition(ctx, with_on_off, level, t_time, false) .await?; Ok(()) @@ -832,6 +905,7 @@ impl<'a, H: LevelControlHooks, OH: OnOffHooks> LevelControlHandler<'a, H, OH> { with_on_off: bool, target_level: u8, transition_time: u16, + scene_apply: bool, ) -> Result<(), Error> { let event_start_time = Instant::now(); @@ -873,7 +947,7 @@ impl<'a, H: LevelControlHooks, OH: OnOffHooks> LevelControlHandler<'a, H, OH> { current_level ); let (current_level, should_notify) = self.with_state(|state| { - self.set_level(state, current_level, is_transition_end, true) + self.set_level(state, current_level, is_transition_end, true, scene_apply) })?; let current_level = match current_level { Some(level) => level, @@ -1026,8 +1100,11 @@ impl<'a, H: LevelControlHooks, OH: OnOffHooks> LevelControlHandler<'a, H, OH> { let is_end_of_transition = (new_level == H::MAX_LEVEL) || (new_level == H::MIN_LEVEL); - let (new_level, should_notify) = self - .with_state(|state| self.set_level(state, new_level, is_end_of_transition, true))?; + // `Move` command path is command-driven only — no scene + // recall queues a `Move` task. + let (new_level, should_notify) = self.with_state(|state| { + self.set_level(state, new_level, is_end_of_transition, true, false) + })?; if should_notify { ctx.notify_attr_changed( self.endpoint_id, @@ -1112,6 +1189,7 @@ impl<'a, H: LevelControlHooks, OH: OnOffHooks> LevelControlHandler<'a, H, OH> { Some(transition_time), options_mask, options_override, + false, ) } @@ -1146,7 +1224,9 @@ impl<'a, H: LevelControlHooks, OH: OnOffHooks> LevelControlHandler<'a, H, OH> { OutOfBandMessage::Update(current_level) => { self.task_signal.signal(Task::Stop); - match self.set_level(state, current_level, true, false) { + // OOB "device level changed under us" — genuine + // drift, never a scene apply. + match self.set_level(state, current_level, true, false, false) { Ok((_, should_notify)) => { if should_notify || state.write_remaining_time_quietly(Duration::from_millis(0), false) @@ -1176,6 +1256,7 @@ impl<'a, H: LevelControlHooks, OH: OnOffHooks> LevelControlHandler<'a, H, OH> { transition_time, options_mask, options_override, + false, ) { error!( "Device initiated MoveToLevel failed: {} | with_on_off: {}, level: {}, transition_time: {:?}, options_mask: {:?}, options_override: {:?}", @@ -1504,6 +1585,7 @@ impl ClusterAsyncHandler for LevelControlH transition_time, options_mask, options_override, + false, ) }) } @@ -1602,7 +1684,14 @@ impl ClusterAsyncHandler for LevelControlH Ok(v) => v, Err(e) => break 'a Err(e), }; - self.move_to_level(true, level, transition_time, options_mask, options_override) + self.move_to_level( + true, + level, + transition_time, + options_mask, + options_override, + false, + ) }) } @@ -1807,133 +1896,89 @@ impl OnOffHooks for NoOnOff { /// Per Matter Application Cluster Spec §1.5, LevelControl exposes a /// single scene-able attribute (`CurrentLevel`, nullable u8) that is /// **read-only** at the attribute level. Scene apply therefore goes -/// through the `MoveToLevel` command with the captured level + the -/// scene's transition time (ms → deciseconds, saturating). See -/// [`crate::dm::clusters::scenes::SceneClusterHandler`]. -pub mod scenes { - use crate::dm::clusters::decl::level_control::{ - AttributeId, CommandId, MoveToLevelRequestBuilder, OptionsBitmap, FULL_CLUSTER, - }; - use crate::dm::clusters::decl::scenes_management::{ - AttributeValuePairStruct, AttributeValuePairStructArrayBuilder, - }; - use crate::dm::clusters::scenes::{SceneClusterHandler, SceneContext}; - use crate::dm::{AsyncHandler, AttrId, ClusterId, EndptId, InvokeContext}; - use crate::error::Error; - use crate::tlv::{Nullable, TLVArray, TLVBuilderParent, TLVTag, TLVWriteParent}; - use crate::utils::storage::WriteBuf; +/// through the `MoveToLevel` path with the captured level + the +/// scene's transition time (ms → deciseconds, saturating). +/// +/// Implemented directly on [`LevelControlHandler`] — the same handler +/// value the application keeps for the normal data-model chain +/// doubles as the scenes-registry entry, via the blanket +/// `impl SceneClusterHandler for &T`. +impl SceneClusterHandler for LevelControlHandler<'_, H, OH> +where + H: LevelControlHooks, + OH: OnOffHooks, +{ + const CLUSTER_ID: ClusterId = FULL_CLUSTER.id; - /// Worst-case TLV-encoded size of any command this scene impl - /// sends. The only command is `MoveToLevel`: - /// - /// ```text - /// 0x15 (struct start, anon) 1 B - /// level u8 @ Ctx 0 (ctrl + tag + 1 B value) 3 B - /// transitionTime nullable u16 @ Ctx 1 (worst case: 4 B) 4 B - /// optionsMask bitmap8 @ Ctx 2 (ctrl + tag + 1 B value) 3 B - /// optionsOverride bitmap8 @ Ctx 3 (ctrl + tag + 1 B value) 3 B - /// 0x18 (end_container) 1 B - /// ----------------------------------------------------------- 15 B - /// ``` - /// - /// Rounded up to 16 to keep one byte of slack. - const MAX_REQUEST_BUF: usize = 16; + fn endpoint_id(&self) -> EndptId { + self.endpoint_id + } - /// Zero-sized [`SceneClusterHandler`] impl for the LevelControl - /// cluster. - /// - /// Register with [`crate::dm::clusters::scenes::ScenesHandler::new`]: - /// - /// ```ignore - /// ScenesHandler::new( - /// dataver, &state, - /// (OnOffSceneClusterHandler, - /// (LevelControlSceneClusterHandler, ())), - /// ) - /// ``` - #[derive(Copy, Clone, Debug, Default, Eq, PartialEq, Hash)] - #[cfg_attr(feature = "defmt", derive(defmt::Format))] - pub struct LevelControlSceneClusterHandler; - - impl SceneClusterHandler for LevelControlSceneClusterHandler { - const CLUSTER_ID: ClusterId = FULL_CLUSTER.id; - - fn is_scenable_attribute(attribute_id: AttrId) -> bool { - // Per Matter App Cluster spec, only `CurrentLevel` is the - // scenable attribute on LevelControl. - attribute_id == AttributeId::CurrentLevel as AttrId - } + fn is_scenable_attribute(attribute_id: AttrId) -> bool { + // Per Matter App Cluster spec, only `CurrentLevel` is the + // scenable attribute on LevelControl. + attribute_id == AttributeId::CurrentLevel as AttrId + } - async fn capture( - &self, - sctx: &SceneContext, - endpoint_id: EndptId, - avp_array: AttributeValuePairStructArrayBuilder

, - ) -> Result, Error> - where - C: InvokeContext, - T: AsyncHandler, - P: TLVBuilderParent, - { - // `CurrentLevel` is `nullable int8u`. Null → skip the AVP - // entry; downstream apply has nothing to act on. - let v: Nullable = sctx - .read(endpoint_id, FULL_CLUSTER.id, AttributeId::CurrentLevel as _) - .await?; - if let Some(level) = v.into_option() { - avp_array.push_u8(AttributeId::CurrentLevel as _, level) - } else { - Ok(avp_array) - } + fn capture( + &self, + avp_array: AttributeValuePairStructArrayBuilder

, + ) -> Result, Error> { + // `CurrentLevel` is `nullable int8u`. Null → skip the AVP + // entry; downstream apply has nothing to act on. Read + // directly from device-supplied state — no IM round-trip. + if let Some(level) = self.hooks.current_level() { + avp_array.push_u8(AttributeId::CurrentLevel as _, level) + } else { + Ok(avp_array) } + } - async fn apply( - &self, - sctx: &SceneContext, - endpoint_id: EndptId, - transition_time_ms: u32, - avp_list: &TLVArray<'_, AttributeValuePairStruct<'_>>, - ) -> Result<(), Error> - where - C: InvokeContext, - T: AsyncHandler, - { - for avp in avp_list.iter() { - let avp = avp?; - if avp.attribute_id()? != AttributeId::CurrentLevel as _ { - continue; - } - let Some(level) = avp.value_unsigned_8()? else { - continue; - }; - // `MoveToLevelRequest.transitionTime` is `int16u` - // deciseconds; `RecallScene.transitionTime` is `int32u` - // milliseconds. Convert with saturation. - let transition_ds = (transition_time_ms / 100).min(u16::MAX as u32) as u16; - - let mut data_buf = [0u8; MAX_REQUEST_BUF]; - let data_len = { - let mut wb = WriteBuf::new(&mut data_buf); - let parent = TLVWriteParent::new("Scene/MoveToLevel", &mut wb); - MoveToLevelRequestBuilder::new(parent, &TLVTag::Anonymous)? - .level(level)? - .transition_time(Nullable::some(transition_ds))? - .options_mask(OptionsBitmap::empty())? - .options_override(OptionsBitmap::empty())? - .end()?; - wb.get_tail() - }; - return sctx - .invoke( - endpoint_id, - FULL_CLUSTER.id, - CommandId::MoveToLevel as _, - &data_buf[..data_len], - ) - .await; + async fn apply( + &self, + _ctx: &C, + avp_list: &TLVArray<'_, AttributeValuePairStruct<'_>>, + transition_time_ms: u32, + ) -> Result<(), Error> { + for avp in avp_list.iter() { + let avp = avp?; + if avp.attribute_id()? != AttributeId::CurrentLevel as _ { + continue; } - Ok(()) + let Some(level) = avp.value_unsigned_8()? else { + continue; + }; + // Delegate to the same task-based pipeline that + // command-driven `MoveToLevel` uses, but with + // `scene_apply=true` threaded through. That flag + // suppresses `notify_scenable_changed` on every + // intermediate (and final) `set_level` call this + // transition triggers, so the Scenes cluster's + // `SceneValid` bookkeeping stays true throughout the + // recall — instant *or* smooth-fade — instead of being + // re-clobbered when the (asynchronous) transition task + // lands the final level after `recall_scene` had already + // called `remember_current`. + // + // `with_on_off = false` so this LC apply doesn't + // trigger OnOff coupling: the scene blob's own OnOff + // AVP (if any) lands OnOff independently via + // `OnOffHandler::apply`. + // + // `RecallScene.transitionTime` is `int32u` milliseconds; + // `MoveToLevel.transitionTime` is `int16u` deciseconds. + // Convert with saturation. + let transition_ds = (transition_time_ms / 100).min(u16::MAX as u32) as u16; + return self.move_to_level( + false, + level, + Some(transition_ds), + OptionsBitmap::empty(), + OptionsBitmap::empty(), + true, + ); } + Ok(()) } } diff --git a/rs-matter/src/dm/clusters/app/on_off.rs b/rs-matter/src/dm/clusters/app/on_off.rs index d45a94b33..f48e27bde 100644 --- a/rs-matter/src/dm/clusters/app/on_off.rs +++ b/rs-matter/src/dm/clusters/app/on_off.rs @@ -35,10 +35,17 @@ use core::pin::pin; use embassy_futures::select::{select, select3, Either, Either3}; use crate::dm::clusters::app::level_control::{LevelControlHandler, LevelControlHooks}; +use crate::dm::clusters::decl::scenes_management::{ + AttributeValuePairStruct, AttributeValuePairStructArrayBuilder, +}; use crate::dm::clusters::decl::{level_control, on_off}; +use crate::dm::clusters::scenes::{SceneClusterHandler, SceneInvalidator}; use crate::dm::types::EndptId; -use crate::dm::{Cluster, Dataver, HandlerContext, InvokeContext, ReadContext, WriteContext}; +use crate::dm::{ + AttrId, Cluster, ClusterId, Dataver, HandlerContext, InvokeContext, ReadContext, WriteContext, +}; use crate::error::{Error, ErrorCode}; +use crate::tlv::{TLVArray, TLVBuilderParent}; pub use crate::dm::clusters::decl::on_off::*; @@ -136,6 +143,16 @@ pub struct OnOffHandler<'a, H: OnOffHooks, LH: LevelControlHooks> { endpoint_id: EndptId, hooks: H, level_control_handler: Mutex>>>, + /// Optional notifier for the Scenes Management cluster's + /// `SceneValid` bookkeeping (see + /// [`crate::dm::clusters::scenes::SceneInvalidator`]). Set via + /// [`OnOffHandler::with_scene_invalidator`] when this device hosts + /// Scenes Management on the same endpoint. Every successful + /// command-driven mutation of `OnOff` (the cluster's only scenable + /// attribute) calls back into the invalidator so any + /// currently-recalled scene on this endpoint flips to + /// `SceneValid=false`. + scene_invalidator: Mutex>>, state: Mutex>, state_change_signal: Signal>, } @@ -175,6 +192,7 @@ impl<'a, H: OnOffHooks, LH: LevelControlHooks> OnOffHandler<'a, H, LH> { endpoint_id, hooks, level_control_handler: Mutex::new(Cell::new(None)), + scene_invalidator: Mutex::new(Cell::new(None)), state: Mutex::new(RefCell::new(OnOffState::new(state))), state_change_signal: Signal::new(None), } @@ -284,6 +302,28 @@ impl<'a, H: OnOffHooks, LH: LevelControlHooks> OnOffHandler<'a, H, LH> { HandlerAsyncAdaptor(self) } + /// Attach a [`SceneInvalidator`] (typically the + /// [`crate::dm::clusters::scenes::ScenesState`] that backs the + /// Scenes Management cluster on the same endpoint). When set, + /// every successful mutation of the `OnOff` attribute calls the + /// invalidator so the Scenes cluster can flip `SceneValid` to + /// false for any currently-recalled scene on this endpoint. No-op + /// when unset, so non-scenes deployments incur zero overhead. + pub fn with_scene_invalidator(self, invalidator: &'a dyn SceneInvalidator) -> Self { + self.scene_invalidator + .lock(|cell| cell.set(Some(invalidator))); + self + } + + /// Internal: notify the wired [`SceneInvalidator`], if any, that + /// `OnOff` just changed on this endpoint. Called from every + /// command-driven `OnOff` mutation site. + fn notify_scenable_changed(&self) { + if let Some(inv) = self.scene_invalidator.lock(|cell| cell.get()) { + inv.scenable_attribute_changed(self.endpoint_id); + } + } + /// Request an out-of-band change to the OnOff state. /// This method can be used, for example, when the device state changes due to physical interactions /// or when the device autonomously decides to change its state. @@ -309,6 +349,7 @@ impl<'a, H: OnOffHooks, LH: LevelControlHooks> OnOffHandler<'a, H, LH> { &self, state: &mut OnOffState, level_control_initiated: bool, + scene_apply: bool, ctx: impl HandlerContext, ) { if self.hooks.on_off() { @@ -322,6 +363,13 @@ impl<'a, H: OnOffHooks, LH: LevelControlHooks> OnOffHandler<'a, H, LH> { let lighting_attrs_updated = Self::update_attr_on(state); ctx.notify_attr_changed(self.endpoint_id, Self::CLUSTER.id, AttributeId::OnOff as _); + // Scene-recall-driven mutations transition the device + // *into* a known scene state, so they MUST NOT fire + // drift-detection — Scenes handles `SceneValid` via + // `remember_current` once the recall completes. + if !scene_apply { + self.notify_scenable_changed(); + } if lighting_attrs_updated { // `update_attr_on` may have forced OffWaitTime to 0 and GlobalSceneControl to TRUE ctx.notify_attr_changed( @@ -379,6 +427,7 @@ impl<'a, H: OnOffHooks, LH: LevelControlHooks> OnOffHandler<'a, H, LH> { &self, state: &mut OnOffState, level_control_initiated: bool, + scene_apply: bool, ctx: impl HandlerContext, ) -> bool { if !self.hooks.on_off() { @@ -418,6 +467,10 @@ impl<'a, H: OnOffHooks, LH: LevelControlHooks> OnOffHandler<'a, H, LH> { // On receipt of the Off command, a server SHALL set the OnOff attribute to FALSE. self.hooks.set_on_off(false); ctx.notify_attr_changed(self.endpoint_id, Self::CLUSTER.id, AttributeId::OnOff as _); + // See `set_on` for why this is gated by `scene_apply`. + if !scene_apply { + self.notify_scenable_changed(); + } if on_time_updated { // `update_attr_off` forced OnTime to 0 ctx.notify_attr_changed(self.endpoint_id, Self::CLUSTER.id, AttributeId::OnTime as _); @@ -514,13 +567,13 @@ impl<'a, H: OnOffHooks, LH: LevelControlHooks> OnOffHandler<'a, H, LH> { match state.state { OnOffClusterState::On => match command { OnOffCommand::Off | OnOffCommand::Toggle => { - if self.set_off(state, false, &ctx) { + if self.set_off(state, false, false, &ctx) { state.state = OnOffClusterState::Off; } Outcome::Done } OnOffCommand::CoupledClusterOff => { - self.set_off(state, true, &ctx); + self.set_off(state, true, false, &ctx); state.state = OnOffClusterState::Off; Outcome::Done } @@ -562,12 +615,12 @@ impl<'a, H: OnOffHooks, LH: LevelControlHooks> OnOffHandler<'a, H, LH> { | OnOffCommand::CoupledClusterOff => Outcome::Done, OnOffCommand::On | OnOffCommand::Toggle => { state.state = OnOffClusterState::On; - self.set_on(state, false, &ctx); + self.set_on(state, false, false, &ctx); Outcome::Done } OnOffCommand::CoupledClusterOn => { state.state = OnOffClusterState::On; - self.set_on(state, true, &ctx); + self.set_on(state, true, false, &ctx); Outcome::Done } OnOffCommand::OnWithTimedOff => { @@ -583,7 +636,7 @@ impl<'a, H: OnOffHooks, LH: LevelControlHooks> OnOffHandler<'a, H, LH> { match command { OnOffCommand::Off | OnOffCommand::Toggle => { trace!("Got Off command from TimedOn state"); - if self.set_off(state, false, &ctx) { + if self.set_off(state, false, false, &ctx) { state.state = OnOffClusterState::DelayedOff; Outcome::Continue } else { @@ -592,7 +645,7 @@ impl<'a, H: OnOffHooks, LH: LevelControlHooks> OnOffHandler<'a, H, LH> { } } OnOffCommand::CoupledClusterOff => { - self.set_off(state, true, &ctx); + self.set_off(state, true, false, &ctx); state.state = OnOffClusterState::DelayedOff; Outcome::Done } @@ -629,7 +682,7 @@ impl<'a, H: OnOffHooks, LH: LevelControlHooks> OnOffHandler<'a, H, LH> { Outcome::Delay } else { state.off_wait_time = 0; - if self.set_off(state, false, &ctx) { + if self.set_off(state, false, false, &ctx) { state.state = OnOffClusterState::Off; } Outcome::Done @@ -652,12 +705,12 @@ impl<'a, H: OnOffHooks, LH: LevelControlHooks> OnOffHandler<'a, H, LH> { // prevent another OnWithTimedOff command turning the server back to its On state. OnOffCommand::On | OnOffCommand::Toggle => { state.state = OnOffClusterState::On; - self.set_on(state, false, &ctx); + self.set_on(state, false, false, &ctx); Outcome::Done } OnOffCommand::CoupledClusterOn => { state.state = OnOffClusterState::On; - self.set_on(state, true, &ctx); + self.set_on(state, true, false, &ctx); Outcome::Done } OnOffCommand::Off @@ -697,7 +750,7 @@ impl<'a, H: OnOffHooks, LH: LevelControlHooks> OnOffHandler<'a, H, LH> { self.with_state(|state| { // This is set to true because in this case we do not want to also run the effects from the LevelControl cluster. - let _ = self.set_off(state, true, &ctx); + let _ = self.set_off(state, true, false, &ctx); state.state = final_state; }); @@ -995,7 +1048,7 @@ impl ClusterAsyncHandler for OnOffHandler< ctx.notify_own_attr_changed(AttributeId::OffWaitTime as _); } } - self.set_on(state, false, &ctx); + self.set_on(state, false, false, &ctx); } // If the values of the OnTime and OffWaitTime attributes are both not equal to 0xFFFF, the server @@ -1103,90 +1156,96 @@ impl LevelControlHooks for NoLevelControl { /// Per Matter Application Cluster Spec §1.4 / §1.5, OnOff exposes a /// single scene-able attribute (`OnOff`, bool) that is **read-only** /// at the attribute level. Scene apply therefore goes through the -/// `On` / `Off` commands rather than an attribute write. See -/// [`crate::dm::clusters::scenes::SceneClusterHandler`]. -pub mod scenes { - use crate::dm::clusters::decl::on_off::{AttributeId, CommandId, FULL_CLUSTER}; - use crate::dm::clusters::decl::scenes_management::{ - AttributeValuePairStruct, AttributeValuePairStructArrayBuilder, - }; - use crate::dm::clusters::scenes::{SceneClusterHandler, SceneContext}; - use crate::dm::{AsyncHandler, AttrId, ClusterId, EndptId, InvokeContext}; - use crate::error::Error; - use crate::tlv::{TLVArray, TLVBuilderParent}; +/// `On` / `Off` commands rather than an attribute write. +/// +/// Implemented directly on [`OnOffHandler`] — the same handler value +/// the application keeps for the normal data-model chain doubles as +/// the scenes-registry entry, via the blanket +/// `impl SceneClusterHandler for &T`. +impl SceneClusterHandler for OnOffHandler<'_, H, LH> +where + H: OnOffHooks, + LH: LevelControlHooks, +{ + const CLUSTER_ID: ClusterId = FULL_CLUSTER.id; - /// Zero-sized [`SceneClusterHandler`] impl for the OnOff cluster. - /// - /// Register with [`crate::dm::clusters::scenes::ScenesHandler::new`]: - /// - /// ```ignore - /// ScenesHandler::new(dataver, &state, (OnOffSceneClusterHandler, ())) - /// ``` - #[derive(Copy, Clone, Debug, Default, Eq, PartialEq, Hash)] - #[cfg_attr(feature = "defmt", derive(defmt::Format))] - pub struct OnOffSceneClusterHandler; - - impl SceneClusterHandler for OnOffSceneClusterHandler { - const CLUSTER_ID: ClusterId = FULL_CLUSTER.id; - - fn is_scenable_attribute(attribute_id: AttrId) -> bool { - // Per Matter App Cluster spec, only `OnOff` (the cluster's - // namesake) is the scenable attribute. - attribute_id == AttributeId::OnOff as AttrId - } + fn endpoint_id(&self) -> EndptId { + self.endpoint_id + } - async fn capture( - &self, - sctx: &SceneContext, - endpoint_id: EndptId, - avp_array: AttributeValuePairStructArrayBuilder

, - ) -> Result, Error> - where - C: InvokeContext, - T: AsyncHandler, - P: TLVBuilderParent, - { - // `OnOff.OnOff` is bool; serialize as `valueUnsigned8` - // (0 / 1) per the Scenes spec's AttributeValuePairStruct. - let v: bool = sctx - .read(endpoint_id, FULL_CLUSTER.id, AttributeId::OnOff as _) - .await?; - avp_array.push_u8(AttributeId::OnOff as _, v as u8) - } + fn is_scenable_attribute(attribute_id: AttrId) -> bool { + // Per Matter App Cluster spec, only `OnOff` (the cluster's + // namesake) is the scenable attribute. + attribute_id == AttributeId::OnOff as AttrId + } - async fn apply( - &self, - sctx: &SceneContext, - endpoint_id: EndptId, - _transition_time_ms: u32, - avp_list: &TLVArray<'_, AttributeValuePairStruct<'_>>, - ) -> Result<(), Error> - where - C: InvokeContext, - T: AsyncHandler, - { - for avp in avp_list.iter() { - let avp = avp?; - if avp.attribute_id()? != AttributeId::OnOff as _ { - continue; - } - let Some(value) = avp.value_unsigned_8()? else { - continue; - }; - // Per spec, OnOff doesn't honour a per-scene transition - // time (it's a discrete on/off transition). Invoke the - // matching command with an empty payload. - let cmd_id = if value != 0 { - CommandId::On - } else { - CommandId::Off - }; - return sctx - .invoke(endpoint_id, FULL_CLUSTER.id, cmd_id as _, &[]) - .await; + fn capture( + &self, + avp_array: AttributeValuePairStructArrayBuilder

, + ) -> Result, Error> { + // Read directly from device-supplied state — no IM round-trip. + let v = self.hooks.on_off(); + avp_array.push_u8(AttributeId::OnOff as _, v as u8) + } + + async fn apply( + &self, + ctx: &C, + avp_list: &TLVArray<'_, AttributeValuePairStruct<'_>>, + _transition_time_ms: u32, + ) -> Result<(), Error> { + for avp in avp_list.iter() { + let avp = avp?; + if avp.attribute_id()? != AttributeId::OnOff as _ { + continue; } - Ok(()) + let Some(value) = avp.value_unsigned_8()? else { + continue; + }; + // Per spec, OnOff doesn't honour a per-scene transition + // time (it's a discrete on/off transition). Mutate + // state *inline* via the same `set_on` / `set_off` + // helpers the command state-machine uses — not via the + // `state_change_signal` deferred path. The reason is + // sequencing with `SceneInvalidator`: `set_on` / + // `set_off` fire `notify_scenable_changed` synchronously, + // which flips `SceneValid → false` for the recalled + // scene. The Scenes handler then calls `remember_current` + // after `apply` returns and resets `SceneValid → true`. + // If we deferred via the signal, the invalidation would + // race AFTER `remember_current` and incorrectly clobber + // it (caught by `Test_TC_S_2_2`). + // `level_control_initiated=true` suppresses the + // OnOff → LC coupling (`coupled_on_off_cluster_on_off_state_change`). + // We don't want that coupling during scene recall because: + // (a) the scene blob carries its own `CurrentLevel` AVP + // which LevelControl's `apply` lands directly, so + // any indirect LC mutation OnOff would queue is at + // best redundant and at worst conflicts; + // (b) the coupling is signal-based — the LC task + // consumes `Task::OnOffStateChange` ASYNCHRONOUSLY + // and ends up calling `set_level` long after + // `recall_scene` returns. That `set_level` fires + // `notify_scenable_changed`, flipping + // `SceneValid → false` after `remember_current` + // had restored it to `true` — observable as the + // `Test_TC_S_2_2` post-RecallScene FabricSceneInfo + // read returning the wrong `SceneValid`. + // + // The bool's name is "level_control_initiated" because + // its original intent is "called by LC, don't loop back + // to LC". Reusing it for the structurally-identical + // "called by Scenes" case is the cleanest available hook. + self.with_state(|state| { + if value != 0 { + self.set_on(state, true, true, ctx); + } else { + self.set_off(state, true, true, ctx); + } + }); + return Ok(()); } + Ok(()) } } diff --git a/rs-matter/src/dm/clusters/scenes.rs b/rs-matter/src/dm/clusters/scenes.rs index ca20a58a8..570685095 100644 --- a/rs-matter/src/dm/clusters/scenes.rs +++ b/rs-matter/src/dm/clusters/scenes.rs @@ -71,20 +71,18 @@ //! (cross-cluster reads + invokes), so their wrappers are real //! `async fn`s that call [`Self::store_scene`] / [`Self::recall_scene`]. -use core::cell::Cell; use core::future::{ready, Future}; use core::num::NonZeroU8; use crate::dm::{ - ArrayAttributeRead, AsyncHandler, AttrDetails, AttrId, Cluster, ClusterId, CmdDetails, CmdId, - Dataver, EmptyHandler, EndptId, HandlerContext, InvokeContext, InvokeContextInstance, - InvokeReplyInstance, Metadata, ReadContext, ReadContextInstance, ReadReply, Reply, SceneId, + ArrayAttributeRead, AttrId, Cluster, ClusterId, Dataver, EndptId, HandlerContext, + InvokeContext, ReadContext, SceneId, }; use crate::error::{Error, ErrorCode}; use crate::persist::{KvBlobStore, Persist}; use crate::tlv::{ FromTLV, Nullable, OptionalBuilder, TLVArray, TLVBuilder, TLVBuilderParent, TLVElement, - TLVSequence, TLVTag, TLVWrite, TLVWriteParent, TagType, ToTLV, TLV, + TLVSequence, TLVTag, TLVWrite, TLVWriteParent, ToTLV, TLV, }; use crate::utils::cell::RefCell; use crate::utils::init::{init, Init}; @@ -111,7 +109,7 @@ const SC_CONSTRAINT_ERROR: u8 = 0x87; const RESERVED_SCENE_ID: SceneId = 0xFF; /// Maximum legal `TransitionTime` value on `AddScene`, per Matter App /// Cluster spec §1.4.7.1 "AddScene Command": -/// > The maximum value SHALL be 60 000 000 (1000 minutes). +/// The maximum value SHALL be 60 000 000 (1000 minutes). /// Anything larger MUST be rejected with `CONSTRAINT_ERROR`. const MAX_TRANSITION_TIME_MS: u32 = 60_000_000; @@ -125,22 +123,33 @@ pub const MAX_EXT_FIELDS_LEN: usize = 128; /// Per-cluster scene capture + apply trait. /// -/// Implemented (typically as a zero-sized type) alongside each -/// scene-able cluster's handler — see -/// [`crate::dm::clusters::app::on_off::OnOffSceneClusterHandler`] etc. -/// The user composes a tuple of these and registers it with -/// [`ScenesHandler::new`]; the Scenes handler delegates the per-cluster -/// work via the [`SceneClusters`] tuple-recursive dispatch. +/// Implemented **directly on the cluster's normal handler type** — +/// e.g. `impl SceneClusterHandler for OnOffHandler<'_, H, LH>`. The +/// same `&handler` value the application registers in the data-model +/// chain doubles as a scenes registry entry, so cross-cluster reads +/// and writes during `StoreScene` / `RecallScene` are direct typed +/// method calls on the handler — no IM-layer round-trip, no TLV +/// serde, no recursion-limit games. /// -/// **Invariant**: all cross-cluster I/O goes through -/// `ctx.handler().{read, write, invoke}` — the Scenes handler has no -/// direct reference to other cluster handlers, only the routing layer -/// does. +/// The application composes a tuple-recursive registry +/// (`(&on_off, (&level_control, ()))`, etc.) and passes it to +/// [`ScenesHandler::new`]; the blanket impl +/// `impl SceneClusterHandler for &T` +/// makes the references flow through transparently. +/// +/// Back-direction notifications (a scenable attribute mutated, so +/// `SceneValid` may need to flip) flow through +/// [`SceneInvalidator`], implemented by [`ScenesState`]. pub trait SceneClusterHandler { /// The Matter cluster ID this impl handles. Used by [`SceneClusters`] /// to route apply dispatch. const CLUSTER_ID: ClusterId; + /// Endpoint this handler instance is installed on. The Scenes + /// handler uses this to skip clusters that don't live on the + /// `StoreScene` / `RecallScene` target endpoint. + fn endpoint_id(&self) -> EndptId; + /// Return `true` if `attribute_id` is a scenable attribute of this /// cluster per the Matter Application Cluster spec. Walked by /// [`SceneClusters::check_scenable`] during `AddScene` to reject @@ -154,41 +163,81 @@ pub trait SceneClusterHandler { false } - /// Read this cluster's scene-able attributes via - /// `sctx.read(...)` and emit zero-or-more - /// `AttributeValuePairStruct` elements into `avp_array` (use - /// [`AttributeValuePairStructArrayBuilder::push_u8`] / - /// [`AttributeValuePairStructArrayBuilder::push_u16`] / etc. for a - /// one-line per-attribute API). + /// Emit zero-or-more `AttributeValuePairStruct` elements for this + /// cluster's scenable state into `avp_array`, reading directly + /// from the handler's internal state (no IM round-trip). Returns + /// the (advanced) builder so the caller can close the array. /// - /// Returns the (advanced) builder so the caller can close the array. - fn capture( + /// Synchronous — internal state reads don't block. Use + /// [`AttributeValuePairStructArrayBuilder::push_u8`] / + /// [`AttributeValuePairStructArrayBuilder::push_u16`] / etc. for + /// a one-line per-attribute API. + fn capture( &self, - sctx: &SceneContext, - endpoint_id: EndptId, avp_array: AttributeValuePairStructArrayBuilder

, - ) -> impl Future, Error>> - where - C: InvokeContext, - T: AsyncHandler, - P: TLVBuilderParent; - - /// Apply the captured attribute values by invoking the right - /// cluster commands (via `sctx.invoke(...)`) — or, for clusters - /// with writable scene-able attrs, by attribute writes. - /// `transition_time_ms` is the effective transition for this - /// recall (either the `RecallScene` request override or the stored - /// value). - fn apply( + ) -> Result, Error>; + + /// Apply captured `avp_list` entries to the handler's internal + /// state directly (e.g. by calling the same private helpers that + /// the cluster's own command bodies use). `transition_time_ms` is + /// the effective transition for this recall (either the + /// `RecallScene` request override or the stored value). + /// + /// `ctx` is a [`HandlerContext`] — the same shape the cluster's + /// own long-running `run()` task receives. It gives the impl + /// access to `notify_attr_changed` (for subscribers) and + /// `kv()` (for persisting state mutated by the recall), without + /// exposing the IM-routed `ctx.handler()` recursion path that + /// led to the trait's earlier `T: AsyncHandler` design problem + /// — calling `ctx.handler()` from inside `apply` re-creates that + /// recursion-limit pathology, so impls MUST NOT do that. Clusters + /// whose state mutation runs on a long-running task + /// (signal-driven OnOff / LevelControl) can ignore `ctx` entirely: + /// the task carries its own context and fires its own + /// `notify_attr_changed` when the mutation lands. Clusters that + /// mutate state synchronously inside `apply` use `ctx` to notify + /// subscribers and persist as needed. + /// + /// Async because some clusters (LevelControl) kick off transition + /// tasks; sync-only impls can return [`core::future::ready`]. + fn apply( &self, - sctx: &SceneContext, - endpoint_id: EndptId, + ctx: &C, + avp_list: &TLVArray<'_, AttributeValuePairStruct<'_>>, transition_time_ms: u32, + ) -> impl Future>; +} + +/// Lets the application pass `&on_off_handler` (which it also keeps +/// for the normal data-model handler chain) into the scenes registry +/// without moving it. The trait's associated const + static +/// `is_scenable_attribute` delegate cleanly through the reference. +impl SceneClusterHandler for &T { + const CLUSTER_ID: ClusterId = T::CLUSTER_ID; + + fn endpoint_id(&self) -> EndptId { + T::endpoint_id(*self) + } + + fn is_scenable_attribute(attribute_id: AttrId) -> bool { + T::is_scenable_attribute(attribute_id) + } + + fn capture( + &self, + avp_array: AttributeValuePairStructArrayBuilder

, + ) -> Result, Error> { + T::capture(*self, avp_array) + } + + async fn apply( + &self, + ctx: &C, avp_list: &TLVArray<'_, AttributeValuePairStruct<'_>>, - ) -> impl Future> - where - C: InvokeContext, - T: AsyncHandler; + transition_time_ms: u32, + ) -> Result<(), Error> { + T::apply(*self, ctx, avp_list, transition_time_ms).await + } } /// A tuple-recursive composition of [`SceneClusterHandler`]s, mirroring @@ -200,7 +249,7 @@ pub trait SceneClusterHandler { /// layered on later. pub trait SceneClusters { /// Walk the registry, emitting one `ExtensionFieldSetStruct` per - /// cluster that is actually present on `endpoint_id`. + /// cluster whose handler reports `endpoint_id() == endpoint_id`. /// /// `parent` is a raw [`TLVBuilderParent`] (e.g. wrapping a /// [`crate::utils::storage::WriteBuf`] over the destination @@ -213,16 +262,7 @@ pub trait SceneClusters { /// [`SceneEntry::extension_fields`]'s "contents + 0x18" storage /// shape without needing an extra `+ 1` byte to absorb a leading /// control byte. - fn capture( - &self, - sctx: &SceneContext, - endpoint_id: EndptId, - parent: P, - ) -> impl Future> - where - C: InvokeContext, - T: AsyncHandler, - P: TLVBuilderParent; + fn capture(&self, endpoint_id: EndptId, parent: P) -> Result; /// Walk the registry looking for `cluster_id`. Returns: /// @@ -239,54 +279,37 @@ pub trait SceneClusters { /// downgrade that drops a previously-scenable cluster. fn check_scenable(&self, cluster_id: ClusterId, attribute_id: AttrId) -> Option; - /// Find the registered cluster matching `cluster_id` and let it - /// apply `avp_list`. Returns `Ok(true)` if a cluster handled it, - /// `Ok(false)` if no registered cluster matches (the entry is - /// silently skipped, matching chip's behavior). - fn apply( + /// Find the registered cluster matching `(cluster_id, endpoint_id)` + /// and let it apply `avp_list`. Returns `Ok(true)` if a cluster + /// handled it, `Ok(false)` if no registered cluster matches (the + /// entry is silently skipped, matching chip's behavior). + fn apply( &self, - sctx: &SceneContext, + ctx: &C, endpoint_id: EndptId, cluster_id: ClusterId, - transition_time_ms: u32, avp_list: &TLVArray<'_, AttributeValuePairStruct<'_>>, - ) -> impl Future> - where - C: InvokeContext, - T: AsyncHandler; + transition_time_ms: u32, + ) -> impl Future>; } impl SceneClusters for () { - fn capture( - &self, - _sctx: &SceneContext, - _endpoint_id: EndptId, - parent: P, - ) -> impl Future> - where - C: InvokeContext, - T: AsyncHandler, - P: TLVBuilderParent, - { - ready(Ok(parent)) + fn capture(&self, _endpoint_id: EndptId, parent: P) -> Result { + Ok(parent) } fn check_scenable(&self, _cluster_id: ClusterId, _attribute_id: AttrId) -> Option { None } - fn apply( + fn apply( &self, - _sctx: &SceneContext, + _ctx: &C, _endpoint_id: EndptId, _cluster_id: ClusterId, - _transition_time_ms: u32, _avp_list: &TLVArray<'_, AttributeValuePairStruct<'_>>, - ) -> impl Future> - where - C: InvokeContext, - T: AsyncHandler, - { + _transition_time_ms: u32, + ) -> impl Future> { ready(Ok(false)) } } @@ -304,18 +327,8 @@ where } } - async fn capture( - &self, - sctx: &SceneContext, - endpoint_id: EndptId, - parent: P, - ) -> Result - where - C: InvokeContext, - U: AsyncHandler, - P: TLVBuilderParent, - { - let parent = if sctx.cluster_present(endpoint_id, H::CLUSTER_ID) { + fn capture(&self, endpoint_id: EndptId, parent: P) -> Result { + let parent = if self.0.endpoint_id() == endpoint_id { // Open this cluster's ExtensionFieldSetStruct directly on // the parent (no outer array wrapper), hand the inner // AVP-array builder to the cluster impl, then close both @@ -323,175 +336,37 @@ where let efs = ExtensionFieldSetStructBuilder::new(parent, &TLVTag::Anonymous)?; let efs = efs.cluster_id(H::CLUSTER_ID)?; let avp_array = efs.attribute_value_list()?; - let avp_array = self.0.capture(sctx, endpoint_id, avp_array).await?; + let avp_array = self.0.capture(avp_array)?; let efs = avp_array.end()?; efs.end()? } else { parent }; - self.1.capture(sctx, endpoint_id, parent).await + self.1.capture(endpoint_id, parent) } - async fn apply( + async fn apply( &self, - sctx: &SceneContext, + ctx: &C, endpoint_id: EndptId, cluster_id: ClusterId, - transition_time_ms: u32, avp_list: &TLVArray<'_, AttributeValuePairStruct<'_>>, - ) -> Result - where - C: InvokeContext, - U: AsyncHandler, - { - if H::CLUSTER_ID == cluster_id { - self.0 - .apply(sctx, endpoint_id, transition_time_ms, avp_list) - .await?; + transition_time_ms: u32, + ) -> Result { + if H::CLUSTER_ID == cluster_id && self.0.endpoint_id() == endpoint_id { + self.0.apply(ctx, avp_list, transition_time_ms).await?; Ok(true) } else { self.1 - .apply(sctx, endpoint_id, cluster_id, transition_time_ms, avp_list) + .apply(ctx, endpoint_id, cluster_id, avp_list, transition_time_ms) .await } } } -// --------------------------------------------------------------------- -// SceneContext — wraps the active InvokeContext and gives per-cluster -// scene impls a small, focused API (`read`, `invoke`, `cluster_present`) -// instead of bare `ctx.handler().{read,invoke}` + raw -// `ReadContextInstance` / `InvokeContextInstance` plumbing. -// --------------------------------------------------------------------- - -/// Per-call context handed to [`SceneClusterHandler::capture`] and -/// [`SceneClusterHandler::apply`]. -/// -/// Wraps the live [`InvokeContext`] for the in-flight `StoreScene` / -/// `RecallScene` command and surfaces the operations a scene-able -/// cluster impl actually needs: -/// -/// - [`SceneContext::read`] — cross-cluster attribute read, decoded -/// as a `FromTLV` type. -/// - [`SceneContext::invoke`] — cross-cluster command dispatch with -/// the response discarded. -/// - [`SceneContext::cluster_present`] — metadata-driven check used -/// by the tuple recursion to skip clusters not installed on the -/// host endpoint. -/// -/// All three go through the global handler (`ctx.handler()`), matching -/// the invariant noted on [`SceneClusterHandler`]. -pub struct SceneContext(C, T); - -impl SceneContext { - pub const fn new(ctx: C, handler: T) -> Self { - Self(ctx, handler) - } - - /// The wrapped [`InvokeContext`]. Useful when a cluster impl needs - /// something outside the small scene-focused surface (e.g. - /// `notify_attr_changed`, `set_cluster_status`). - /// - /// Construction takes `C` by value; callers typically pass a - /// reference (e.g. `SceneContext::new(ctx)` where `ctx: &impl - /// InvokeContext`) — `&InvokeContext: InvokeContext` via the - /// blanket impl, so the `'a` lifetime is folded into `C` itself. - pub const fn ctx(&self) -> &C { - &self.0 - } - - /// Read one attribute via the global handler and decode it as `Q`. - /// - /// Drives [`AsyncHandler::read`] with a custom reply that - /// captures the value bytes (TLV-encoded with anonymous tag) into - /// a stack buffer, then decodes them as `Q` via `FromTLV`. The - /// `Q: for<'b> FromTLV<'b>` bound restricts use to types that - /// don't borrow from the TLV bytes (primitives, `Nullable`, - /// enums, …) — which covers all scalar-valued attributes scene - /// capture cares about. - pub async fn read( - &self, - endpoint_id: EndptId, - cluster_id: ClusterId, - attr_id: AttrId, - ) -> Result - where - Q: for<'b> FromTLV<'b>, - { - let mut buf = [0u8; 16]; - let mut wb = WriteBuf::new(&mut buf); - - let attr = AttrDetails { - endpoint_id, - cluster_id, - attr_id, - list_index: None, - list_chunked: false, - // Fabric-scoped attrs are not in the spec'd scene-able set, - // but pass the accessor's fabric in case a future scene-able - // attribute is fabric-scoped. - fab_idx: self.0.exchange().accessor()?.fab_idx()?.get(), - fab_filter: false, - dataver: None, - wildcard: false, - array: false, - cluster_status: Cell::new(0), - }; - - //let handler = self.0.handler(); - let read_ctx = ReadContextInstance::new(self.0.exchange(), &self.0, &attr); - let reply = CaptureReply { wb: &mut wb }; - //handler.read(read_ctx, reply).await?; - self.1.read(read_ctx, reply).await?; - - Q::from_tlv(&TLVElement::new(wb.as_slice())) - } - - /// Dispatch a cross-cluster command through `ctx.handler().invoke()`. - /// The command reply is captured into a small stack buffer and - /// discarded — most cluster-apply paths only care about - /// success/failure, not the echoed `DefaultSuccess` payload. - /// - /// `data` must be a complete TLV-encoded command request struct - /// (anonymous-tagged), or empty for commands with no payload - /// (`On`, `Off`, `Toggle`). - pub async fn invoke( - &self, - endpoint_id: EndptId, - cluster_id: ClusterId, - cmd_id: CmdId, - data: &[u8], - ) -> Result<(), Error> { - let fab_idx = self.0.exchange().accessor()?.fab_idx()?.get(); - let cmd = CmdDetails::new(endpoint_id, cluster_id, cmd_id, fab_idx, false, None); - let data_elem = TLVElement::new(data); - - // 64 B is plenty for a `DefaultSuccess` reply (anonymous outer - // struct + cmd-resp struct + path). - let mut response_buf = [0u8; 64]; - let mut response_wb = WriteBuf::new(&mut response_buf); - let reply = InvokeReplyInstance::new(&cmd, &mut response_wb); - - //let handler = self.0.handler(); - let inv_ctx = InvokeContextInstance::new(self.0.exchange(), &self.0, &cmd, &data_elem); - //handler.invoke(inv_ctx, reply).await - self.1.invoke(inv_ctx, reply).await - } - - /// Check whether `cluster_id` is exposed on `endpoint_id` per the - /// node metadata. Used by the [`SceneClusters`] tuple recursion - /// to skip scene-able cluster impls that the host endpoint - /// doesn't actually install — and available to cluster impls that - /// want to do the same check (e.g. for sibling-cluster - /// dependencies). - pub fn cluster_present(&self, endpoint_id: EndptId, cluster_id: ClusterId) -> bool { - self.0.metadata().access(|node| { - node.endpoint(endpoint_id) - .and_then(|ep| ep.cluster(cluster_id)) - .is_some() - }) - } -} +// `SceneContext` and `CaptureReply` (the IM-routed cross-cluster +// read/invoke shim) were removed in favour of direct method calls on +// the typed cluster handler — see the module-level doc comment. // --------------------------------------------------------------------- // Builder ergonomics — push_u8 / push_u16 etc. on the codegen'd AVP @@ -625,13 +500,19 @@ impl SceneEntry { /// `SceneCount=0` with `CurrentScene`/`CurrentGroup` preserved and /// `SceneValid=false`. /// +/// `endpoint_id` records the endpoint the scene was recalled on so the +/// [`SceneInvalidator`] callback (fired by scenable cluster handlers +/// when their state changes) can flip `valid → false` per-endpoint +/// without touching other endpoints' recalled scenes. +/// /// `FromTLV` / `ToTLV` are derived (the type has no const generics, /// unlike [`SceneEntry`]) — the persisted shape is a struct with -/// context-tagged fields auto-numbered 0..3 in source order. +/// context-tagged fields auto-numbered 0..4 in source order. #[derive(Debug, Clone, Copy, FromTLV, ToTLV)] #[cfg_attr(feature = "defmt", derive(defmt::Format))] struct CurrentScene { fab_idx: NonZeroU8, + endpoint_id: EndptId, group_id: u16, scene_id: SceneId, valid: bool, @@ -725,6 +606,51 @@ impl Default for ScenesState { } } +/// Notified by scenable cluster handlers (OnOff, LevelControl, +/// ColorControl, …) when a scenable attribute on an endpoint changes +/// out from under a previously-recalled scene. Per Matter App Cluster +/// spec §1.4.6.5, that mutation invalidates `SceneValid` for every +/// fabric whose recalled scene lives on that endpoint. +/// +/// `ScenesState` implements this trait directly. Wire the +/// implementation into a scene-able cluster handler at construction +/// (e.g. `OnOffHandler::with_scene_invalidator(&scenes_state)`); the +/// handler then calls +/// [`Self::scenable_attribute_changed`] at every internal mutation +/// site for the cluster's scenable attribute set. +/// +/// Implementations MUST be cheap and re-entrant — they run inline on +/// the command-handler path. +pub trait SceneInvalidator { + /// Flip `SceneValid → false` for every recalled scene that lives + /// on `endpoint_id`, across all fabrics. No-op when no fabric has + /// a scene recalled on that endpoint. + fn scenable_attribute_changed(&self, endpoint_id: EndptId); +} + +impl SceneInvalidator for &T { + fn scenable_attribute_changed(&self, endpoint_id: EndptId) { + (**self).scenable_attribute_changed(endpoint_id); + } +} + +impl SceneInvalidator for ScenesState { + fn scenable_attribute_changed(&self, endpoint_id: EndptId) { + self.with(|inner| { + let mut bumped = false; + for c in inner.current_per_fabric.iter_mut() { + if c.valid && c.endpoint_id == endpoint_id { + c.valid = false; + bumped = true; + } + } + if bumped { + inner.bump_info_dataver(); + } + }); + } +} + // --------------------------------------------------------------------- // TLV round-trip used by the persistence layer. // @@ -885,37 +811,23 @@ impl ScenesState { /// CopyScene) in isolation. `M` mirrors the same parameter on /// [`ScenesState`] (per-scene blob capacity, defaults to /// [`MAX_EXT_FIELDS_LEN`]). -pub struct ScenesHandler< - 'a, - const N: usize, - T = EmptyHandler, - R = (), - const M: usize = MAX_EXT_FIELDS_LEN, -> where - T: AsyncHandler, +pub struct ScenesHandler<'a, const N: usize, R = (), const M: usize = MAX_EXT_FIELDS_LEN> +where R: SceneClusters, { dataver: Dataver, state: &'a ScenesState, - handler: T, clusters: R, } -impl<'a, const N: usize, T, R, const M: usize> ScenesHandler<'a, N, T, R, M> +impl<'a, const N: usize, R, const M: usize> ScenesHandler<'a, N, R, M> where - T: AsyncHandler, R: SceneClusters, { - pub const fn new( - dataver: Dataver, - state: &'a ScenesState, - handler: T, - clusters: R, - ) -> Self { + pub const fn new(dataver: Dataver, state: &'a ScenesState, clusters: R) -> Self { Self { dataver, state, - handler, clusters, } } @@ -977,12 +889,16 @@ where }) } - /// Stamp `(group, scene)` as the current recalled scene for this - /// fabric with `SceneValid = true`. Bumps `FabricSceneInfo` - /// dataver. Operates on already-locked inner state. + /// Stamp `(endpoint, group, scene)` as the current recalled scene + /// for this fabric with `SceneValid = true`. Bumps + /// `FabricSceneInfo` dataver. Operates on already-locked inner + /// state. The `endpoint_id` lets the [`SceneInvalidator`] flip + /// `valid → false` per-endpoint when scenable attributes change + /// on that endpoint (see `TestScenesFabricSceneInfo` step 25). fn remember_current( inner: &mut ScenesStateInner, fab_idx: NonZeroU8, + endpoint_id: EndptId, group_id: u16, scene_id: SceneId, ) { @@ -991,6 +907,7 @@ where .iter_mut() .find(|c| c.fab_idx == fab_idx) { + slot.endpoint_id = endpoint_id; slot.group_id = group_id; slot.scene_id = scene_id; slot.valid = true; @@ -1000,6 +917,7 @@ where // SceneValid=false in such cases). let _ = inner.current_per_fabric.push(CurrentScene { fab_idx, + endpoint_id, group_id, scene_id, valid: true, @@ -1706,13 +1624,14 @@ where // the "contents + 0x18 terminator" shape that // [`SceneEntry::extension_fields`] stores — no leading byte // to strip, no `MAX_EXT_FIELDS_LEN + 1` slack needed. - //let sctx = SceneContext::new(ctx, &self.handler); - let sctx = SceneContext::new(ctx, &self.handler); + // Capture is now synchronous: each scene-aware cluster reads + // its own internal state via its `SceneClusterHandler::capture` + // impl. No IM-layer round-trip. let mut scratch = [0u8; M]; let total_len = { let mut wb = WriteBuf::new(&mut scratch); let parent = TLVWriteParent::new("StoreScene EFS", &mut wb); - let _ = self.clusters.capture(&sctx, endpoint_id, parent).await?; + let _ = self.clusters.capture(endpoint_id, parent)?; wb.end_container()?; wb.get_tail() }; @@ -1762,7 +1681,7 @@ where // recalled entry); the `remember_current` below stamps it // back to valid with the freshly-stored ID. if status == 0 { - Self::remember_current(inner, fab_idx, group_id, scene_id); + Self::remember_current(inner, fab_idx, endpoint_id, group_id, scene_id); } Ok::<_, Error>(status) })?; @@ -1860,7 +1779,6 @@ where let effective_tt_ms = override_tt_ms.unwrap_or(stored_tt_ms); - let sctx = SceneContext::new(ctx, &self.handler); for efs_element in TLVSequence(&blob[..blob_len]).iter() { let efs = ExtensionFieldSetStruct::new(efs_element?); let cluster_id = efs.cluster_id()?; @@ -1871,12 +1789,12 @@ where // different scene-able cluster set). let _ = self .clusters - .apply(&sctx, endpoint_id, cluster_id, effective_tt_ms, &avp_list) + .apply(ctx, endpoint_id, cluster_id, &avp_list, effective_tt_ms) .await?; } self.state - .with(|inner| Self::remember_current(inner, fab_idx, group_id, scene_id)); + .with(|inner| Self::remember_current(inner, fab_idx, endpoint_id, group_id, scene_id)); self.state.store_persist(ctx)?; ctx.notify_own_attr_changed(AttributeId::FabricSceneInfo as _); @@ -2009,63 +1927,8 @@ where } } -// --------------------------------------------------------------------- -// Cross-cluster read plumbing for StoreScene. -// -// `CaptureReply` is a minimal [`ReadReply`] that *only* records the -// attribute value bytes (TLV-encoded with [`TagType::Anonymous`]) into -// a caller-provided [`WriteBuf`]. We deliberately bypass the standard -// `AttrResp::Data` framing (dataver + path + data) used by -// `ReadReplyInstance`, because StoreScene's capture path doesn't need -// any of it — it would just have to be re-parsed back out. -// -// The codegen for an attribute read produces: -// reply.with_dataver(self.dataver())? -// .and_then(|writer| Reply::set(writer, value)) -// `with_dataver` here ignores the dataver entirely (we always want the -// current value) and `Reply::set` writes the value at TAG = Anonymous. -// --------------------------------------------------------------------- - -/// See module-level comment block above. -struct CaptureReply<'b, 'wb> { - wb: &'b mut WriteBuf<'wb>, -} - -impl<'b, 'wb> ReadReply for CaptureReply<'b, 'wb> { - fn with_dataver(self, _dataver: u32) -> Result, Error> { - Ok(Some(CaptureReplyWriter { wb: self.wb })) - } -} - -struct CaptureReplyWriter<'b, 'wb> { - wb: &'b mut WriteBuf<'wb>, -} - -impl Reply for CaptureReplyWriter<'_, '_> { - const TAG: TagType = TagType::Anonymous; - - fn set(self, value: T) -> Result<(), Error> { - value.to_tlv(&Self::TAG, self.wb) - } - - fn reset(&mut self) { - // No-op: the codegen-driven attribute read path calls - // `Reply::set` exactly once per attribute, so a partial-write - // rewind is never needed here. - } - - fn writer(&mut self) -> impl TLVWrite + Send + '_ { - &mut *self.wb - } - - fn complete(self) -> Result<(), Error> { - Ok(()) - } -} - -impl ClusterAsyncHandler for ScenesHandler<'_, N, T, R, M> +impl ClusterAsyncHandler for ScenesHandler<'_, N, R, M> where - T: AsyncHandler, R: SceneClusters, { /// FULL_CLUSTER minus the SceneNames feature (we accept the field @@ -2549,7 +2412,7 @@ mod tests { // the copy overwrites that slot, `SceneValid` MUST become // false because the recalled-scene data just changed // underneath the recall. - ScenesHandler::<8>::remember_current(&mut inner, fab(1), 20, 7); + ScenesHandler::<8>::remember_current(&mut inner, fab(1), 1, 20, 7); assert_eq!(inner.current_per_fabric.len(), 1); let status = @@ -2574,7 +2437,7 @@ mod tests { // copy's target (20, 7). Per Matter spec §1.4.6.5, // `SceneValid` must be preserved when the copy doesn't touch // the currently-recalled scene. - ScenesHandler::<8>::remember_current(&mut inner, fab(1), 99, 99); + ScenesHandler::<8>::remember_current(&mut inner, fab(1), 1, 99, 99); let status = ScenesHandler::<8>::copy_scenes_inner(&mut inner, fab(1), 1, 10, 5, 20, 7, false); @@ -2589,7 +2452,7 @@ mod tests { #[test] fn failed_copy_does_not_invalidate_current_scene() { let mut inner = ScenesStateInner::<8>::new(); - ScenesHandler::<8>::remember_current(&mut inner, fab(1), 99, 99); + ScenesHandler::<8>::remember_current(&mut inner, fab(1), 1, 99, 99); let dv_before = inner.info_dataver; let status = ScenesHandler::<8>::copy_scenes_inner( @@ -2618,8 +2481,8 @@ mod tests { #[test] fn remember_current_replaces_existing_slot_in_place() { let mut inner = ScenesStateInner::<8>::new(); - ScenesHandler::<8>::remember_current(&mut inner, fab(1), 10, 1); - ScenesHandler::<8>::remember_current(&mut inner, fab(1), 20, 2); + ScenesHandler::<8>::remember_current(&mut inner, fab(1), 1, 10, 1); + ScenesHandler::<8>::remember_current(&mut inner, fab(1), 1, 20, 2); // Same fabric ⇒ slot is updated, not duplicated. assert_eq!(inner.current_per_fabric.len(), 1); @@ -2630,8 +2493,8 @@ mod tests { #[test] fn remember_current_keeps_fabrics_independent() { let mut inner = ScenesStateInner::<8>::new(); - ScenesHandler::<8>::remember_current(&mut inner, fab(1), 10, 1); - ScenesHandler::<8>::remember_current(&mut inner, fab(2), 20, 2); + ScenesHandler::<8>::remember_current(&mut inner, fab(1), 1, 10, 1); + ScenesHandler::<8>::remember_current(&mut inner, fab(2), 1, 20, 2); assert_eq!(inner.current_per_fabric.len(), 2); } @@ -2639,8 +2502,8 @@ mod tests { #[test] fn invalidate_match_scene_only_clears_exact_match() { let mut inner = ScenesStateInner::<8>::new(); - ScenesHandler::<8>::remember_current(&mut inner, fab(1), 10, 1); - ScenesHandler::<8>::remember_current(&mut inner, fab(2), 20, 2); + ScenesHandler::<8>::remember_current(&mut inner, fab(1), 1, 10, 1); + ScenesHandler::<8>::remember_current(&mut inner, fab(2), 1, 20, 2); // Non-matching (group, scene) leaves the entry alone — this is // the spec-preserve-SceneValid path used by `AddScene` / @@ -2672,8 +2535,8 @@ mod tests { #[test] fn invalidate_match_group_clears_any_scene_in_group() { let mut inner = ScenesStateInner::<8>::new(); - ScenesHandler::<8>::remember_current(&mut inner, fab(1), 10, 7); - ScenesHandler::<8>::remember_current(&mut inner, fab(2), 20, 2); + ScenesHandler::<8>::remember_current(&mut inner, fab(1), 1, 10, 7); + ScenesHandler::<8>::remember_current(&mut inner, fab(2), 1, 20, 2); // Wrong group: no-op. ScenesHandler::<8>::invalidate_current_if_match_group(&mut inner, fab(1), 99); @@ -2819,7 +2682,7 @@ mod tests { // scene at the same `(group, scene)` the upsert targets and // verify it gets dropped. let mut inner = ScenesStateInner::<8>::new(); - ScenesHandler::<8>::remember_current(&mut inner, fab(1), 10, 5); + ScenesHandler::<8>::remember_current(&mut inner, fab(1), 1, 10, 5); let _ = ScenesHandler::<8>::upsert_scene(&mut inner, fab(1), 1, 10, 5, 100, fill_with(&[0x18])) @@ -2841,7 +2704,7 @@ mod tests { // spec-conformance regression that `TestScenesFabricSceneInfo` // step 21 catches when violated). let mut inner = ScenesStateInner::<8>::new(); - ScenesHandler::<8>::remember_current(&mut inner, fab(1), 1, 1); + ScenesHandler::<8>::remember_current(&mut inner, fab(1), 1, 1, 1); let _ = // Upsert in a *different* group/scene than the current one. @@ -2859,8 +2722,8 @@ mod tests { let mut inner = ScenesStateInner::<8>::new(); // Both fabrics have a current scene matching what we're about // to upsert in fab(1) — only fab(1)'s entry should drop. - ScenesHandler::<8>::remember_current(&mut inner, fab(1), 10, 5); - ScenesHandler::<8>::remember_current(&mut inner, fab(2), 10, 5); + ScenesHandler::<8>::remember_current(&mut inner, fab(1), 1, 10, 5); + ScenesHandler::<8>::remember_current(&mut inner, fab(2), 1, 10, 5); let _ = ScenesHandler::<8>::upsert_scene(&mut inner, fab(1), 1, 10, 5, 100, fill_with(&[0x18])) @@ -2890,7 +2753,7 @@ mod tests { inner.table.push(entry(fab(1), 1, 10, 1, 100)).unwrap(); inner.table.push(entry(fab(1), 1, 10, 2, 100)).unwrap(); inner.table.push(entry(fab(1), 1, 10, 3, 100)).unwrap(); - ScenesHandler::<3>::remember_current(&mut inner, fab(1), 99, 99); + ScenesHandler::<3>::remember_current(&mut inner, fab(1), 1, 99, 99); let status = ScenesHandler::<3>::upsert_scene( &mut inner, diff --git a/xtask/src/itest.rs b/xtask/src/itest.rs index d2fa558b7..c8c401ce6 100644 --- a/xtask/src/itest.rs +++ b/xtask/src/itest.rs @@ -504,21 +504,11 @@ pub(crate) const SCENES_TESTS: &[&str] = &[ // Effect-on-receipt cross-cluster checks (RecallScene actually // applies OnOff / LevelControl state via cross-cluster commands). "Test_TC_S_3_1", - // Composite suites — each requires implementation pieces beyond - // what's wired today. Re-enable as those land. - // - // `TestScenesFabricSceneInfo` — 24 of 33 steps pass; step 25 - // expects `SceneValid → false` after a `LevelControl.MoveToLevelWithOnOff` - // changes the scenable attribute state out from under a previously - // recalled scene. Requires "scene drift detection": every scenable - // attribute mutation (writes + apply-via-command) must call back - // into `ScenesState` to invalidate `SceneValid` for that endpoint - // / fabric. Step 27 additionally expects `StoreScene` to set - // `CurrentScene = (group, scene)` with `SceneValid=true`. Both are - // spec-conformant behaviours that warrant a small cross-cluster - // wiring pass (`SceneClusterHandler` callers notify `ScenesState` - // on state changes); deferred so persistence ships independently. - // "TestScenesFabricSceneInfo", + // `TestScenesFabricSceneInfo` — drives the + // `SceneInvalidator` drift-detection path: scenable attribute + // mutations on OnOff / LevelControl flip `SceneValid → false` for + // any recalled scene on the affected endpoint. + "TestScenesFabricSceneInfo", "TestScenesMultiFabric", "TestScenesFabricRemoval", "TestScenesMaxCapacity", From eae2706ec2b4938b5a7fa6480384037b20c85893 Mon Sep 17 00:00:00 2001 From: ivmarkov Date: Mon, 1 Jun 2026 07:06:29 +0000 Subject: [PATCH 10/15] Color control --- .../src/dm/clusters/app/color_control.rs | 1278 ++++++++++++++++- 1 file changed, 1264 insertions(+), 14 deletions(-) diff --git a/rs-matter/src/dm/clusters/app/color_control.rs b/rs-matter/src/dm/clusters/app/color_control.rs index dc66a2874..6deea8554 100644 --- a/rs-matter/src/dm/clusters/app/color_control.rs +++ b/rs-matter/src/dm/clusters/app/color_control.rs @@ -15,19 +15,1269 @@ * limitations under the License. */ -//! ColorControl cluster — placeholder. +//! ColorControl cluster (skeleton handler — Scenes integration only). //! -//! A full ColorControl cluster handler is not yet shipped. The -//! previous wrapper-based Scenes integration (`pub mod scenes` with -//! a `ColorControlSceneClusterHandler<'a>` that proxied through the -//! `SceneContext` IM-routed read/invoke shim) is removed: the Scenes -//! cluster now talks to scene-able cluster handlers via direct -//! typed method calls on the handler type itself (see -//! [`crate::dm::clusters::scenes::SceneClusterHandler`]). +//! This module ships a `ColorControlHandler` whose **only complete +//! surface today is the [`SceneClusterHandler`] impl**. It does not +//! yet expose the full data-model `ClusterHandler` trait (commands +//! and attribute reads/writes are stubbed out — the +//! command-driven path will be added in a follow-up). The skeleton +//! exists primarily to validate that the +//! [`crate::dm::clusters::scenes::SceneClusterHandler`] architecture +//! gracefully handles ColorControl's shape: //! -//! When a real `ColorControlHandler` lands here, the scenes -//! integration will be added back as -//! `impl SceneClusterHandler for ColorControlHandler<...>`, -//! mirroring how [`crate::dm::clusters::app::on_off::OnOffHandler`] -//! and [`crate::dm::clusters::app::level_control::LevelControlHandler`] -//! do it today. +//! - **Up to 9 scenable attributes** (`CurrentX`, `CurrentY`, +//! `EnhancedCurrentHue`, `CurrentSaturation`, `ColorLoopActive`, +//! `ColorLoopDirection`, `ColorLoopTime`, +//! `ColorTemperatureMireds`, `EnhancedColorMode`) — vs OnOff's 1 +//! and LevelControl's 1. +//! - **Feature-conditional capture**: which subset of the attrs is +//! captured depends on the cluster's `Feature` bitmap +//! (`XY` / `HUE_AND_SATURATION` / `ENHANCED_HUE` / `COLOR_LOOP` / +//! `COLOR_TEMPERATURE`). +//! - **Mode-dependent apply**: `EnhancedColorMode` selects which +//! internal applier runs — `MoveToColor`-equivalent for `XY`, +//! `MoveToColorTemperature` for `ColorTemperatureMireds`, +//! `MoveToHueAndSaturation` / `EnhancedMoveToHueAndSaturation` for +//! the hue/saturation modes. `ColorLoopActive=1` short-circuits to +//! the `ColorLoopSet` path regardless of `EnhancedColorMode`. +//! - **Per-instance feature configuration**: instances on different +//! endpoints may enable different subsets. The handler reads its +//! active feature bitmap from the [`ColorControlHooks`] trait. +//! +//! The handler holds a [`crate::dm::clusters::scenes::SceneInvalidator`] +//! reference (set via [`ColorControlHandler::with_scene_invalidator`]) +//! so command-driven mutations of scenable attributes (when the +//! command path lands) can flip `SceneValid → false` — exactly the +//! same pattern as [`crate::dm::clusters::app::on_off::OnOffHandler`] +//! and [`crate::dm::clusters::app::level_control::LevelControlHandler`]. +//! +//! ## Hooks model +//! +//! [`ColorControlHooks`] exposes per-attribute getters and setters. +//! The application implements it on whatever per-device state it +//! keeps — typically a struct cached in static memory with +//! [`core::cell::Cell`] for each field. Setters MUST be cheap and +//! synchronous; the SceneClusterHandler's `apply` calls them inline +//! and immediately follows up with `notify_attr_changed` for +//! subscribers (unless `scene_apply=true`, in which case the +//! drift-detection notification is skipped — see the OnOff/LC +//! `set_on`/`set_level` `scene_apply` parameter for the rationale). + +use core::cell::Cell; + +use crate::dm::clusters::decl::scenes_management::{ + AttributeValuePairStruct, AttributeValuePairStructArrayBuilder, +}; +use crate::dm::clusters::scenes::{SceneClusterHandler, SceneInvalidator}; +use crate::dm::types::EndptId; +use crate::dm::{AttrChangeNotifier, AttrId, ClusterId, Dataver, HandlerContext}; +use crate::error::Error; +use crate::tlv::{TLVArray, TLVBuilderParent}; +use crate::utils::sync::blocking::Mutex; + +pub use crate::dm::clusters::decl::color_control::*; + +/// Device-supplied state + I/O for the ColorControl cluster. +/// +/// All getters MUST be infallible (return cached state from the +/// device's local store). All setters MUST be synchronous and cheap +/// — `SceneClusterHandler::apply` calls them inline. Both halves +/// are required even for feature subsets the device doesn't +/// implement; unsupported attributes can be backed by stub fields +/// whose setters are no-ops and whose getters return a fixed sentinel +/// (typically `0`). +/// +/// `features` reports the active `Feature` bitmap for THIS endpoint +/// — different ColorControl instances may enable different subsets, +/// so the handler reads it from the hooks rather than from a global +/// constant. +pub trait ColorControlHooks { + /// The `Feature` bitmap active on this endpoint. The Scenes + /// integration uses it to feature-gate capture (`XY` → emit + /// `CurrentX`/`CurrentY`, `COLOR_TEMPERATURE` → emit + /// `ColorTemperatureMireds`, etc.). + fn features(&self) -> Feature; + + fn current_x(&self) -> u16; + fn set_current_x(&self, value: u16); + + fn current_y(&self) -> u16; + fn set_current_y(&self, value: u16); + + fn enhanced_current_hue(&self) -> u16; + fn set_enhanced_current_hue(&self, value: u16); + + fn current_saturation(&self) -> u8; + fn set_current_saturation(&self, value: u8); + + fn color_temperature_mireds(&self) -> u16; + fn set_color_temperature_mireds(&self, value: u16); + + fn color_loop_active(&self) -> bool; + fn set_color_loop_active(&self, value: bool); + + fn color_loop_direction(&self) -> ColorLoopDirectionEnum; + fn set_color_loop_direction(&self, value: ColorLoopDirectionEnum); + + /// Duration of one full color-loop cycle, in seconds (per spec). + fn color_loop_time(&self) -> u16; + fn set_color_loop_time(&self, value: u16); + + /// The recalled-scene's `ColorLoopSet` path uses this as the + /// starting hue when activating the loop. Read-only at this level + /// — there's no scenable setter (the spec's `ColorLoopSet` + /// command sets it, but scene recall doesn't carry a new value + /// for it). + fn color_loop_start_enhanced_hue(&self) -> u16; + + fn enhanced_color_mode(&self) -> EnhancedColorModeEnum; + fn set_enhanced_color_mode(&self, value: EnhancedColorModeEnum); +} + +/// Skeleton ColorControl cluster handler. See module docs for the +/// scope: this currently only implements the scenes-integration +/// surface, not the full data-model `ClusterHandler` trait. +pub struct ColorControlHandler<'a, H: ColorControlHooks> { + #[allow(dead_code)] // wired in when the command-handler path lands + dataver: Dataver, + endpoint_id: EndptId, + hooks: H, + /// Optional scene-drift notifier — see + /// [`crate::dm::clusters::scenes::SceneInvalidator`]. Set via + /// [`Self::with_scene_invalidator`]; defaults to `None`, in which + /// case all internal mutators are no-ops with respect to scene + /// invalidation. + scene_invalidator: Mutex>>, +} + +impl<'a, H: ColorControlHooks> ColorControlHandler<'a, H> { + pub fn new(dataver: Dataver, endpoint_id: EndptId, hooks: H) -> Self { + Self { + dataver, + endpoint_id, + hooks, + scene_invalidator: Mutex::new(Cell::new(None)), + } + } + + /// See [`crate::dm::clusters::app::on_off::OnOffHandler::with_scene_invalidator`]. + pub fn with_scene_invalidator(self, invalidator: &'a dyn SceneInvalidator) -> Self { + self.scene_invalidator + .lock(|cell| cell.set(Some(invalidator))); + self + } + + fn notify_scenable_changed(&self) { + if let Some(inv) = self.scene_invalidator.lock(|cell| cell.get()) { + inv.scenable_attribute_changed(self.endpoint_id); + } + } + + /// Apply the `CurrentXAndCurrentY` mode: write `CurrentX` / + /// `CurrentY` / `EnhancedColorMode` and notify subscribers. + /// `scene_apply` gates `notify_scenable_changed` — see + /// [`crate::dm::clusters::app::level_control::LevelControlHandler::set_level`] + /// for the rationale. + fn apply_xy( + &self, + ctx: &N, + x: u16, + y: u16, + // _transition_time_ds: u16 — non-instant XY transitions are + // not modelled here; the skeleton applies instantly. The + // architecture supports threading `scene_apply` through a + // task-based transition the same way LevelControl does. + scene_apply: bool, + ) { + self.hooks.set_current_x(x); + self.hooks.set_current_y(y); + self.hooks + .set_enhanced_color_mode(EnhancedColorModeEnum::CurrentXAndCurrentY); + let cluster_id = Self::CLUSTER_ID; + ctx.notify_attr_changed(self.endpoint_id, cluster_id, AttributeId::CurrentX as _); + ctx.notify_attr_changed(self.endpoint_id, cluster_id, AttributeId::CurrentY as _); + ctx.notify_attr_changed( + self.endpoint_id, + cluster_id, + AttributeId::EnhancedColorMode as _, + ); + if !scene_apply { + self.notify_scenable_changed(); + } + } + + /// Apply the `ColorTemperatureMireds` mode. + fn apply_color_temperature( + &self, + ctx: &N, + mireds: u16, + scene_apply: bool, + ) { + self.hooks.set_color_temperature_mireds(mireds); + self.hooks + .set_enhanced_color_mode(EnhancedColorModeEnum::ColorTemperatureMireds); + let cluster_id = Self::CLUSTER_ID; + ctx.notify_attr_changed( + self.endpoint_id, + cluster_id, + AttributeId::ColorTemperatureMireds as _, + ); + ctx.notify_attr_changed( + self.endpoint_id, + cluster_id, + AttributeId::EnhancedColorMode as _, + ); + if !scene_apply { + self.notify_scenable_changed(); + } + } + + /// Apply the `CurrentHueAndCurrentSaturation` mode (non-enhanced + /// hue is `u8`; chip's reference truncates `EnhancedCurrentHue` + /// to the low byte when the captured mode says non-enhanced). + fn apply_hue_saturation( + &self, + ctx: &N, + hue_u8: u8, + saturation: u8, + scene_apply: bool, + ) { + // Truncate hue into the enhanced field — there's no separate + // non-enhanced setter on the hooks (would just be a u8 view + // of EnhancedCurrentHue per spec). + self.hooks.set_enhanced_current_hue(hue_u8 as u16); + self.hooks.set_current_saturation(saturation); + self.hooks + .set_enhanced_color_mode(EnhancedColorModeEnum::CurrentHueAndCurrentSaturation); + let cluster_id = Self::CLUSTER_ID; + ctx.notify_attr_changed( + self.endpoint_id, + cluster_id, + AttributeId::EnhancedCurrentHue as _, + ); + ctx.notify_attr_changed( + self.endpoint_id, + cluster_id, + AttributeId::CurrentSaturation as _, + ); + ctx.notify_attr_changed( + self.endpoint_id, + cluster_id, + AttributeId::EnhancedColorMode as _, + ); + if !scene_apply { + self.notify_scenable_changed(); + } + } + + /// Apply the `EnhancedCurrentHueAndCurrentSaturation` mode. + fn apply_enhanced_hue_saturation( + &self, + ctx: &N, + enhanced_hue: u16, + saturation: u8, + scene_apply: bool, + ) { + self.hooks.set_enhanced_current_hue(enhanced_hue); + self.hooks.set_current_saturation(saturation); + self.hooks + .set_enhanced_color_mode(EnhancedColorModeEnum::EnhancedCurrentHueAndCurrentSaturation); + let cluster_id = Self::CLUSTER_ID; + ctx.notify_attr_changed( + self.endpoint_id, + cluster_id, + AttributeId::EnhancedCurrentHue as _, + ); + ctx.notify_attr_changed( + self.endpoint_id, + cluster_id, + AttributeId::CurrentSaturation as _, + ); + ctx.notify_attr_changed( + self.endpoint_id, + cluster_id, + AttributeId::EnhancedColorMode as _, + ); + if !scene_apply { + self.notify_scenable_changed(); + } + } + + /// Apply a color-loop activation. Mirrors chip's + /// `ColorControl::ApplyScene` short-circuit: when a recalled + /// scene has `ColorLoopActive=1`, drop the `MoveTo*` dispatch + /// entirely and run the loop instead. + fn apply_color_loop( + &self, + ctx: &N, + direction: ColorLoopDirectionEnum, + time: u16, + scene_apply: bool, + ) { + self.hooks.set_color_loop_active(true); + self.hooks.set_color_loop_direction(direction); + self.hooks.set_color_loop_time(time); + let cluster_id = Self::CLUSTER_ID; + ctx.notify_attr_changed( + self.endpoint_id, + cluster_id, + AttributeId::ColorLoopActive as _, + ); + ctx.notify_attr_changed( + self.endpoint_id, + cluster_id, + AttributeId::ColorLoopDirection as _, + ); + ctx.notify_attr_changed( + self.endpoint_id, + cluster_id, + AttributeId::ColorLoopTime as _, + ); + if !scene_apply { + self.notify_scenable_changed(); + } + } +} + +impl SceneClusterHandler for ColorControlHandler<'_, H> { + const CLUSTER_ID: ClusterId = FULL_CLUSTER.id; + + fn endpoint_id(&self) -> EndptId { + self.endpoint_id + } + + fn is_scenable_attribute(attribute_id: AttrId) -> bool { + // Matter App Cluster spec §3.2.10. Feature-gated availability + // is enforced at capture/apply time (a captured attribute + // that maps to a disabled feature is silently dropped), not + // here — `is_scenable_attribute` only validates `AddScene` + // payload shape. + matches!( + attribute_id, + a if a == AttributeId::CurrentX as AttrId + || a == AttributeId::CurrentY as AttrId + || a == AttributeId::EnhancedCurrentHue as AttrId + || a == AttributeId::CurrentSaturation as AttrId + || a == AttributeId::ColorLoopActive as AttrId + || a == AttributeId::ColorLoopDirection as AttrId + || a == AttributeId::ColorLoopTime as AttrId + || a == AttributeId::ColorTemperatureMireds as AttrId + || a == AttributeId::EnhancedColorMode as AttrId + ) + } + + fn capture( + &self, + avp_array: AttributeValuePairStructArrayBuilder

, + ) -> Result, Error> { + // Capture order mirrors chip's + // `DefaultColorControlSceneHandler::SerializeSave`. + // `EnhancedColorMode` is captured unconditionally — apply + // dispatches on it. + let features = self.hooks.features(); + + let avp_array = if features.contains(Feature::XY) { + let x = self.hooks.current_x(); + let avp_array = avp_array.push_u16(AttributeId::CurrentX as _, x)?; + let y = self.hooks.current_y(); + avp_array.push_u16(AttributeId::CurrentY as _, y)? + } else { + avp_array + }; + + let avp_array = if features.contains(Feature::ENHANCED_HUE) { + let h = self.hooks.enhanced_current_hue(); + avp_array.push_u16(AttributeId::EnhancedCurrentHue as _, h)? + } else { + avp_array + }; + + let avp_array = if features.contains(Feature::HUE_AND_SATURATION) { + let s = self.hooks.current_saturation(); + avp_array.push_u8(AttributeId::CurrentSaturation as _, s)? + } else { + avp_array + }; + + let avp_array = if features.contains(Feature::COLOR_LOOP) { + let active = self.hooks.color_loop_active(); + let avp_array = avp_array.push_u8(AttributeId::ColorLoopActive as _, active as u8)?; + let direction = self.hooks.color_loop_direction(); + let avp_array = + avp_array.push_u8(AttributeId::ColorLoopDirection as _, direction as u8)?; + let time = self.hooks.color_loop_time(); + avp_array.push_u16(AttributeId::ColorLoopTime as _, time)? + } else { + avp_array + }; + + let avp_array = if features.contains(Feature::COLOR_TEMPERATURE) { + let mireds = self.hooks.color_temperature_mireds(); + avp_array.push_u16(AttributeId::ColorTemperatureMireds as _, mireds)? + } else { + avp_array + }; + + // `EnhancedColorMode` is `enum8`, serialised as + // `valueUnsigned8`. Always captured (drives apply dispatch). + let mode = self.hooks.enhanced_color_mode(); + avp_array.push_u8(AttributeId::EnhancedColorMode as _, mode as u8) + } + + async fn apply( + &self, + ctx: &C, + avp_list: &TLVArray<'_, AttributeValuePairStruct<'_>>, + transition_time_ms: u32, + ) -> Result<(), Error> { + // Delegate to the narrower-typed inner method so unit tests + // can pass `&()` (a no-op `AttrChangeNotifier`) without + // needing to mock a full `HandlerContext`. `HandlerContext` + // is a supertrait of `AttrChangeNotifier`, so any `&C` passed + // in here also satisfies the inner method's bound. + self.apply_inner(ctx, avp_list, transition_time_ms) + } +} + +impl ColorControlHandler<'_, H> { + /// Inner apply (sync) — same logic as the trait method but + /// scoped to `AttrChangeNotifier` instead of `HandlerContext`. + /// Sync because every per-mode applier is sync (no transition + /// task yet); when the command-handler path lands, the + /// transitioning variants will be queued through a `task_signal` + /// the same way LevelControl does it, and that signalling is + /// already sync. + fn apply_inner( + &self, + ctx: &N, + avp_list: &TLVArray<'_, AttributeValuePairStruct<'_>>, + _transition_time_ms: u32, + ) -> Result<(), Error> { + // Sweep the AVP list once and stash each known value. We + // need `EnhancedColorMode` AND the mode-specific values + // before we can decide which applier to call. + let mut mode: Option = None; + let mut current_x: Option = None; + let mut current_y: Option = None; + let mut current_saturation: Option = None; + let mut enhanced_current_hue: Option = None; + let mut color_temperature_mireds: Option = None; + let mut color_loop_active: Option = None; + let mut color_loop_direction: Option = None; + let mut color_loop_time: Option = None; + + for avp in avp_list.iter() { + let avp = avp?; + let attr_id = avp.attribute_id()?; + if attr_id == AttributeId::EnhancedColorMode as _ { + if let Some(v) = avp.value_unsigned_8()? { + mode = enhanced_color_mode_from_u8(v); + } + } else if attr_id == AttributeId::CurrentX as _ { + current_x = avp.value_unsigned_16()?; + } else if attr_id == AttributeId::CurrentY as _ { + current_y = avp.value_unsigned_16()?; + } else if attr_id == AttributeId::CurrentSaturation as _ { + current_saturation = avp.value_unsigned_8()?; + } else if attr_id == AttributeId::EnhancedCurrentHue as _ { + enhanced_current_hue = avp.value_unsigned_16()?; + } else if attr_id == AttributeId::ColorTemperatureMireds as _ { + color_temperature_mireds = avp.value_unsigned_16()?; + } else if attr_id == AttributeId::ColorLoopActive as _ { + color_loop_active = avp.value_unsigned_8()?; + } else if attr_id == AttributeId::ColorLoopDirection as _ { + color_loop_direction = avp.value_unsigned_8()?; + } else if attr_id == AttributeId::ColorLoopTime as _ { + color_loop_time = avp.value_unsigned_16()?; + } + } + + // If the scene captured an active color loop, hand off to + // the loop applier and ignore the Move-To dispatch — + // mirrors chip's `ColorControl::ApplyScene`. + if color_loop_active == Some(1) { + let direction = color_loop_direction + .and_then(color_loop_direction_from_u8) + .unwrap_or(ColorLoopDirectionEnum::Increment); + let time = color_loop_time.unwrap_or(0x0019); + self.apply_color_loop(ctx, direction, time, true); + return Ok(()); + } + + let Some(mode) = mode else { + // No mode captured (perhaps an older firmware's blob + // that didn't include it) — nothing to do. + return Ok(()); + }; + + match mode { + EnhancedColorModeEnum::CurrentXAndCurrentY => { + let (Some(x), Some(y)) = (current_x, current_y) else { + return Ok(()); + }; + self.apply_xy(ctx, x, y, true); + } + EnhancedColorModeEnum::ColorTemperatureMireds => { + let Some(mireds) = color_temperature_mireds else { + return Ok(()); + }; + self.apply_color_temperature(ctx, mireds, true); + } + EnhancedColorModeEnum::CurrentHueAndCurrentSaturation => { + // Non-enhanced hue is u8; chip's reference truncates + // EnhancedCurrentHue's low byte. (Behaviour mirrored + // from `ColorControl::ApplyScene`.) + let (Some(hue), Some(sat)) = ( + enhanced_current_hue.map(|h| (h & 0xFF) as u8), + current_saturation, + ) else { + return Ok(()); + }; + self.apply_hue_saturation(ctx, hue, sat, true); + } + EnhancedColorModeEnum::EnhancedCurrentHueAndCurrentSaturation => { + let (Some(hue), Some(sat)) = (enhanced_current_hue, current_saturation) else { + return Ok(()); + }; + self.apply_enhanced_hue_saturation(ctx, hue, sat, true); + } + } + + Ok(()) + } +} + +/// Convert a stored `valueUnsigned8` to an +/// [`EnhancedColorModeEnum`], returning `None` for unknown values +/// rather than failing the apply (matches chip's lenient parse). +fn enhanced_color_mode_from_u8(v: u8) -> Option { + match v { + 0 => Some(EnhancedColorModeEnum::CurrentHueAndCurrentSaturation), + 1 => Some(EnhancedColorModeEnum::CurrentXAndCurrentY), + 2 => Some(EnhancedColorModeEnum::ColorTemperatureMireds), + 3 => Some(EnhancedColorModeEnum::EnhancedCurrentHueAndCurrentSaturation), + _ => None, + } +} + +/// Convert a stored `valueUnsigned8` to a [`ColorLoopDirectionEnum`], +/// returning `None` for unknown values. +fn color_loop_direction_from_u8(v: u8) -> Option { + match v { + 0 => Some(ColorLoopDirectionEnum::Decrement), + 1 => Some(ColorLoopDirectionEnum::Increment), + _ => None, + } +} + +#[cfg(test)] +mod tests { + //! Unit tests for the ColorControl scenes integration. Validates + //! the [`SceneClusterHandler`] impl against the cluster's + //! feature-gated capture matrix, mode-routed apply dispatch, + //! `ColorLoopActive=1` short-circuit, and the `scene_apply` / + //! drift-detection contract. + //! + //! End-to-end YAML coverage isn't available — no chip-tool + //! `Test_TC_S_*` / `TestScenes*` suite exercises ColorControl + //! AVPs. These tests are the validation gate for the integration. + //! + //! Test infrastructure: + //! - [`MockHooks`]: `Cell`-backed implementation of + //! [`ColorControlHooks`]. Sets `features()` from a constructor + //! field; all getters return the cached values; all setters + //! write through. + //! - [`CountingInvalidator`]: tracks `scenable_attribute_changed` + //! call count so tests can assert on drift-notification gating. + //! - `build_avp_bytes`: round-trips AVPs through the codegen + //! `AttributeValuePairStructArrayBuilder` so `apply` receives + //! the same TLV shape it would on the wire. + //! - `dummy_ctx`: minimal `HandlerContext` whose + //! `notify_attr_changed` is a no-op (subscriber notification is + //! tested elsewhere; here we focus on scene-cluster behaviour). + + use super::*; + use crate::tlv::{TLVElement, TLVWriteParent}; + use crate::utils::storage::WriteBuf; + + /// All in-test calls into `apply_*` go through the + /// `&impl AttrChangeNotifier` surface (not the full + /// `HandlerContext`). The stdlib `()` is a no-op + /// `AttrChangeNotifier` (see `dm::types::handler::impl AttrChangeNotifier for ()`), + /// so we use `&()` everywhere we'd otherwise need to mock a + /// context. This sidesteps the heavy `HandlerContext`-mock + /// boilerplate that doesn't add real test coverage — the apply + /// helpers only USE the notifier surface. + const NULL_CTX: &() = &(); + + /// `Cell`-backed implementation of [`ColorControlHooks`] for + /// tests. The `features` bitmap is constructor-fixed; everything + /// else round-trips through `Cell::get`/`set`. + struct MockHooks { + features: Feature, + current_x: Cell, + current_y: Cell, + enhanced_current_hue: Cell, + current_saturation: Cell, + color_temperature_mireds: Cell, + color_loop_active: Cell, + color_loop_direction: Cell, + color_loop_time: Cell, + color_loop_start_enhanced_hue: Cell, + enhanced_color_mode: Cell, + } + + impl MockHooks { + fn new(features: Feature) -> Self { + Self { + features, + current_x: Cell::new(0), + current_y: Cell::new(0), + enhanced_current_hue: Cell::new(0), + current_saturation: Cell::new(0), + color_temperature_mireds: Cell::new(0), + color_loop_active: Cell::new(false), + color_loop_direction: Cell::new(ColorLoopDirectionEnum::Decrement), + color_loop_time: Cell::new(0), + color_loop_start_enhanced_hue: Cell::new(0), + enhanced_color_mode: Cell::new( + EnhancedColorModeEnum::CurrentHueAndCurrentSaturation, + ), + } + } + } + + impl ColorControlHooks for MockHooks { + fn features(&self) -> Feature { + self.features + } + fn current_x(&self) -> u16 { + self.current_x.get() + } + fn set_current_x(&self, value: u16) { + self.current_x.set(value); + } + fn current_y(&self) -> u16 { + self.current_y.get() + } + fn set_current_y(&self, value: u16) { + self.current_y.set(value); + } + fn enhanced_current_hue(&self) -> u16 { + self.enhanced_current_hue.get() + } + fn set_enhanced_current_hue(&self, value: u16) { + self.enhanced_current_hue.set(value); + } + fn current_saturation(&self) -> u8 { + self.current_saturation.get() + } + fn set_current_saturation(&self, value: u8) { + self.current_saturation.set(value); + } + fn color_temperature_mireds(&self) -> u16 { + self.color_temperature_mireds.get() + } + fn set_color_temperature_mireds(&self, value: u16) { + self.color_temperature_mireds.set(value); + } + fn color_loop_active(&self) -> bool { + self.color_loop_active.get() + } + fn set_color_loop_active(&self, value: bool) { + self.color_loop_active.set(value); + } + fn color_loop_direction(&self) -> ColorLoopDirectionEnum { + self.color_loop_direction.get() + } + fn set_color_loop_direction(&self, value: ColorLoopDirectionEnum) { + self.color_loop_direction.set(value); + } + fn color_loop_time(&self) -> u16 { + self.color_loop_time.get() + } + fn set_color_loop_time(&self, value: u16) { + self.color_loop_time.set(value); + } + fn color_loop_start_enhanced_hue(&self) -> u16 { + self.color_loop_start_enhanced_hue.get() + } + fn enhanced_color_mode(&self) -> EnhancedColorModeEnum { + self.enhanced_color_mode.get() + } + fn set_enhanced_color_mode(&self, value: EnhancedColorModeEnum) { + self.enhanced_color_mode.set(value); + } + } + + /// [`SceneInvalidator`] mock that counts calls — tests assert + /// on this to verify the `scene_apply` flag suppresses drift + /// notification. + struct CountingInvalidator { + count: Cell, + } + + impl CountingInvalidator { + const fn new() -> Self { + Self { + count: Cell::new(0), + } + } + fn count(&self) -> u32 { + self.count.get() + } + } + + impl SceneInvalidator for CountingInvalidator { + fn scenable_attribute_changed(&self, _endpoint_id: EndptId) { + self.count.set(self.count.get() + 1); + } + } + + /// AVP-array TLV blobs are built inline per-test rather than via + /// a helper: the `AttributeValuePairStructArrayBuilder` carries + /// the `WriteBuf` lifetime in nested type parameters, so a + /// generic helper has unpleasant HRTB-around-nested-lifetime + /// signatures. Inlining is shorter than the indirection. + /// + /// The pattern is: + /// ```ignore + /// let mut buf = [0u8; 128]; + /// let len = { + /// let mut wb = WriteBuf::new(&mut buf); + /// let parent = TLVWriteParent::new("test", &mut wb); + /// let array = AttributeValuePairStructArrayBuilder::new( + /// parent, &crate::tlv::TLVTag::Anonymous, + /// ).unwrap(); + /// let array = array.push_u16(…).unwrap()…; + /// array.end().unwrap(); + /// wb.get_tail() + /// }; // wb dropped here → buf's mutable borrow released + /// let bytes = &buf[..len]; + /// ``` + + /// Returns a fresh [`ColorControlHandler`] for tests. `EP = 1` + /// matches our other scene-aware handlers' test convention. + fn handler(features: Feature) -> ColorControlHandler<'static, MockHooks> { + // SAFETY-equivalent of leaking: tests don't drop, and the + // hooks live for the duration of the test. Cleaner than + // wrestling with `'static` bounds for `with_scene_invalidator`. + // (The invalidator is wired explicitly per test via + // `with_scene_invalidator` when needed.) + let hooks = MockHooks::new(features); + ColorControlHandler::new(Dataver::new(1), 1, hooks) + } + + // ---- is_scenable_attribute ---- + + #[test] + fn is_scenable_attribute_accepts_all_nine_scenable() { + for attr in [ + AttributeId::CurrentX, + AttributeId::CurrentY, + AttributeId::EnhancedCurrentHue, + AttributeId::CurrentSaturation, + AttributeId::ColorLoopActive, + AttributeId::ColorLoopDirection, + AttributeId::ColorLoopTime, + AttributeId::ColorTemperatureMireds, + AttributeId::EnhancedColorMode, + ] { + assert!( + as SceneClusterHandler>::is_scenable_attribute( + attr as AttrId, + ), + "{:?} should be scenable", + attr, + ); + } + } + + #[test] + fn is_scenable_attribute_rejects_unscenable_color_attrs() { + // Per spec these are NOT scenable, even though they're + // ColorControl attributes. + for attr in [ + AttributeId::ColorLoopStartEnhancedHue, + AttributeId::StartUpColorTemperatureMireds, + ] { + assert!( + ! as SceneClusterHandler>::is_scenable_attribute( + attr as AttrId, + ), + "{:?} should NOT be scenable", + attr, + ); + } + } + + // ---- capture: feature gating ---- + + #[test] + fn capture_xy_only_emits_just_xy_and_mode() { + let h = handler(Feature::XY); + h.hooks.set_current_x(0x1234); + h.hooks.set_current_y(0x5678); + h.hooks + .set_enhanced_color_mode(EnhancedColorModeEnum::CurrentXAndCurrentY); + + let mut buf = [0u8; 128]; + let len = { + let mut wb = WriteBuf::new(&mut buf); + let parent = TLVWriteParent::new("capture", &mut wb); + let array = + AttributeValuePairStructArrayBuilder::new(parent, &crate::tlv::TLVTag::Anonymous) + .unwrap(); + let array = h.capture(array).unwrap(); + array.end().unwrap(); + wb.get_tail() + }; + let bytes = &buf[..len]; + + // Walk the array and collect (attr_id, value) pairs. Bare + // numeric checks here — TLV-level encoding is tested + // elsewhere; we just want to know that the cluster picked + // the right attrs and values. + let elem = TLVElement::new(bytes); + let arr: TLVArray<'_, AttributeValuePairStruct<'_>> = TLVArray::new(elem).unwrap(); + let mut seen: heapless::Vec<(u32, Option, Option), 16> = heapless::Vec::new(); + for avp in arr.iter() { + let avp = avp.unwrap(); + seen.push(( + avp.attribute_id().unwrap(), + avp.value_unsigned_8().unwrap(), + avp.value_unsigned_16().unwrap(), + )) + .unwrap(); + } + // XY features → CurrentX, CurrentY, EnhancedColorMode. + // No hue/sat/temp/loop entries. + assert_eq!(seen.len(), 3); + assert_eq!(seen[0].0, AttributeId::CurrentX as u32); + assert_eq!(seen[0].2, Some(0x1234)); + assert_eq!(seen[1].0, AttributeId::CurrentY as u32); + assert_eq!(seen[1].2, Some(0x5678)); + assert_eq!(seen[2].0, AttributeId::EnhancedColorMode as u32); + assert_eq!( + seen[2].1, + Some(EnhancedColorModeEnum::CurrentXAndCurrentY as u8) + ); + } + + #[test] + fn capture_color_temperature_only_emits_just_mireds_and_mode() { + let h = handler(Feature::COLOR_TEMPERATURE); + h.hooks.set_color_temperature_mireds(370); + h.hooks + .set_enhanced_color_mode(EnhancedColorModeEnum::ColorTemperatureMireds); + + let mut buf = [0u8; 128]; + let len = { + let mut wb = WriteBuf::new(&mut buf); + let parent = TLVWriteParent::new("capture", &mut wb); + let array = + AttributeValuePairStructArrayBuilder::new(parent, &crate::tlv::TLVTag::Anonymous) + .unwrap(); + let array = h.capture(array).unwrap(); + array.end().unwrap(); + wb.get_tail() + }; + let bytes = &buf[..len]; + + let elem = TLVElement::new(bytes); + let arr: TLVArray<'_, AttributeValuePairStruct<'_>> = TLVArray::new(elem).unwrap(); + let mut ids: heapless::Vec = heapless::Vec::new(); + for avp in arr.iter() { + ids.push(avp.unwrap().attribute_id().unwrap()).unwrap(); + } + assert_eq!(ids.len(), 2); + assert_eq!(ids[0], AttributeId::ColorTemperatureMireds as u32); + assert_eq!(ids[1], AttributeId::EnhancedColorMode as u32); + } + + #[test] + fn capture_full_feature_set_emits_all_scenable_attrs() { + let h = handler( + Feature::XY + | Feature::ENHANCED_HUE + | Feature::HUE_AND_SATURATION + | Feature::COLOR_LOOP + | Feature::COLOR_TEMPERATURE, + ); + + let mut buf = [0u8; 128]; + let len = { + let mut wb = WriteBuf::new(&mut buf); + let parent = TLVWriteParent::new("capture", &mut wb); + let array = + AttributeValuePairStructArrayBuilder::new(parent, &crate::tlv::TLVTag::Anonymous) + .unwrap(); + let array = h.capture(array).unwrap(); + array.end().unwrap(); + wb.get_tail() + }; + let bytes = &buf[..len]; + + let elem = TLVElement::new(bytes); + let arr: TLVArray<'_, AttributeValuePairStruct<'_>> = TLVArray::new(elem).unwrap(); + let mut ids: heapless::Vec = heapless::Vec::new(); + for avp in arr.iter() { + ids.push(avp.unwrap().attribute_id().unwrap()).unwrap(); + } + // Spec order from `SceneClusterHandler::capture`: + // X, Y, EnhancedHue, Saturation, LoopActive, LoopDirection, + // LoopTime, ColorTemperatureMireds, EnhancedColorMode. + assert_eq!(ids.len(), 9); + let expected = [ + AttributeId::CurrentX as u32, + AttributeId::CurrentY as u32, + AttributeId::EnhancedCurrentHue as u32, + AttributeId::CurrentSaturation as u32, + AttributeId::ColorLoopActive as u32, + AttributeId::ColorLoopDirection as u32, + AttributeId::ColorLoopTime as u32, + AttributeId::ColorTemperatureMireds as u32, + AttributeId::EnhancedColorMode as u32, + ]; + for (got, want) in ids.iter().zip(expected.iter()) { + assert_eq!(got, want); + } + } + + // ---- apply: mode dispatch ---- + + /// Build an AVP list inline (see the `build_avp_bytes` doc-block + /// for the pattern). Used by every apply-side test. + fn build_xy_avps(buf: &mut [u8], x: u16, y: u16, mode: EnhancedColorModeEnum) -> usize { + let mut wb = WriteBuf::new(buf); + let parent = TLVWriteParent::new("test", &mut wb); + let array = + AttributeValuePairStructArrayBuilder::new(parent, &crate::tlv::TLVTag::Anonymous) + .unwrap(); + let array = array + .push_u16(AttributeId::CurrentX as _, x) + .unwrap() + .push_u16(AttributeId::CurrentY as _, y) + .unwrap() + .push_u8(AttributeId::EnhancedColorMode as _, mode as u8) + .unwrap(); + array.end().unwrap(); + wb.get_tail() + } + + #[test] + fn apply_xy_mode_writes_x_y_and_sets_mode() { + let h = handler(Feature::XY); + let mut buf = [0u8; 128]; + let len = build_xy_avps( + &mut buf, + 0xABCD, + 0x1234, + EnhancedColorModeEnum::CurrentXAndCurrentY, + ); + let bytes = &buf[..len]; + + let elem = TLVElement::new(bytes); + let avp_list: TLVArray<'_, AttributeValuePairStruct<'_>> = TLVArray::new(elem).unwrap(); + h.apply_inner(NULL_CTX, &avp_list, 0).unwrap(); + + assert_eq!(h.hooks.current_x(), 0xABCD); + assert_eq!(h.hooks.current_y(), 0x1234); + assert!(matches!( + h.hooks.enhanced_color_mode(), + EnhancedColorModeEnum::CurrentXAndCurrentY + )); + } + + #[test] + fn apply_color_temperature_mode_writes_mireds_and_sets_mode() { + let h = handler(Feature::COLOR_TEMPERATURE); + let mut buf = [0u8; 128]; + let len = { + let mut wb = WriteBuf::new(&mut buf); + let parent = TLVWriteParent::new("test", &mut wb); + let array = + AttributeValuePairStructArrayBuilder::new(parent, &crate::tlv::TLVTag::Anonymous) + .unwrap(); + let array = array + .push_u16(AttributeId::ColorTemperatureMireds as _, 250) + .unwrap() + .push_u8( + AttributeId::EnhancedColorMode as _, + EnhancedColorModeEnum::ColorTemperatureMireds as u8, + ) + .unwrap(); + array.end().unwrap(); + wb.get_tail() + }; + let bytes = &buf[..len]; + + let elem = TLVElement::new(bytes); + let avp_list: TLVArray<'_, AttributeValuePairStruct<'_>> = TLVArray::new(elem).unwrap(); + h.apply_inner(NULL_CTX, &avp_list, 0).unwrap(); + + assert_eq!(h.hooks.color_temperature_mireds(), 250); + assert!(matches!( + h.hooks.enhanced_color_mode(), + EnhancedColorModeEnum::ColorTemperatureMireds + )); + } + + #[test] + fn apply_hue_saturation_mode_truncates_enhanced_hue_to_u8() { + // Captured EnhancedCurrentHue is u16; non-enhanced apply + // path takes the low byte. Mirrors chip's `ApplyScene` + // behaviour and is documented on `apply_hue_saturation`. + let h = handler(Feature::HUE_AND_SATURATION); + let mut buf = [0u8; 128]; + let len = { + let mut wb = WriteBuf::new(&mut buf); + let parent = TLVWriteParent::new("test", &mut wb); + let array = + AttributeValuePairStructArrayBuilder::new(parent, &crate::tlv::TLVTag::Anonymous) + .unwrap(); + // Hue = 0x12FF → low byte 0xFF after truncation. + let array = array + .push_u16(AttributeId::EnhancedCurrentHue as _, 0x12FF) + .unwrap() + .push_u8(AttributeId::CurrentSaturation as _, 100) + .unwrap() + .push_u8( + AttributeId::EnhancedColorMode as _, + EnhancedColorModeEnum::CurrentHueAndCurrentSaturation as u8, + ) + .unwrap(); + array.end().unwrap(); + wb.get_tail() + }; + let bytes = &buf[..len]; + + let elem = TLVElement::new(bytes); + let avp_list: TLVArray<'_, AttributeValuePairStruct<'_>> = TLVArray::new(elem).unwrap(); + h.apply_inner(NULL_CTX, &avp_list, 0).unwrap(); + + // The mutator writes the low byte into the enhanced field + // (the cluster's only hue storage); the mode flips to + // non-enhanced. + assert_eq!(h.hooks.enhanced_current_hue(), 0xFF); + assert_eq!(h.hooks.current_saturation(), 100); + assert!(matches!( + h.hooks.enhanced_color_mode(), + EnhancedColorModeEnum::CurrentHueAndCurrentSaturation + )); + } + + #[test] + fn apply_enhanced_hue_saturation_keeps_full_u16_hue() { + let h = handler(Feature::ENHANCED_HUE | Feature::HUE_AND_SATURATION); + let mut buf = [0u8; 128]; + let len = { + let mut wb = WriteBuf::new(&mut buf); + let parent = TLVWriteParent::new("test", &mut wb); + let array = + AttributeValuePairStructArrayBuilder::new(parent, &crate::tlv::TLVTag::Anonymous) + .unwrap(); + let array = array + .push_u16(AttributeId::EnhancedCurrentHue as _, 0x4321) + .unwrap() + .push_u8(AttributeId::CurrentSaturation as _, 200) + .unwrap() + .push_u8( + AttributeId::EnhancedColorMode as _, + EnhancedColorModeEnum::EnhancedCurrentHueAndCurrentSaturation as u8, + ) + .unwrap(); + array.end().unwrap(); + wb.get_tail() + }; + let bytes = &buf[..len]; + + let elem = TLVElement::new(bytes); + let avp_list: TLVArray<'_, AttributeValuePairStruct<'_>> = TLVArray::new(elem).unwrap(); + h.apply_inner(NULL_CTX, &avp_list, 0).unwrap(); + + // Enhanced mode preserves the full 16-bit hue. + assert_eq!(h.hooks.enhanced_current_hue(), 0x4321); + assert_eq!(h.hooks.current_saturation(), 200); + } + + // ---- apply: ColorLoopActive=1 short-circuit ---- + + #[test] + fn apply_color_loop_active_short_circuits_move_to_dispatch() { + // Even when a mode + matching values are captured, if + // ColorLoopActive=1 is present apply must hand off to the + // loop applier and IGNORE the mode-dispatched value. Mirrors + // chip's `ColorControl::ApplyScene`. + let h = handler(Feature::COLOR_LOOP | Feature::XY); + let mut buf = [0u8; 128]; + let len = { + let mut wb = WriteBuf::new(&mut buf); + let parent = TLVWriteParent::new("test", &mut wb); + let array = + AttributeValuePairStructArrayBuilder::new(parent, &crate::tlv::TLVTag::Anonymous) + .unwrap(); + let array = array + .push_u8(AttributeId::ColorLoopActive as _, 1) + .unwrap() + .push_u8( + AttributeId::ColorLoopDirection as _, + ColorLoopDirectionEnum::Increment as u8, + ) + .unwrap() + .push_u16(AttributeId::ColorLoopTime as _, 60) + .unwrap() + // XY values + mode that would normally win — should + // be ignored because the loop short-circuits. + .push_u16(AttributeId::CurrentX as _, 0xDEAD) + .unwrap() + .push_u16(AttributeId::CurrentY as _, 0xBEEF) + .unwrap() + .push_u8( + AttributeId::EnhancedColorMode as _, + EnhancedColorModeEnum::CurrentXAndCurrentY as u8, + ) + .unwrap(); + array.end().unwrap(); + wb.get_tail() + }; + let bytes = &buf[..len]; + + let elem = TLVElement::new(bytes); + let avp_list: TLVArray<'_, AttributeValuePairStruct<'_>> = TLVArray::new(elem).unwrap(); + h.apply_inner(NULL_CTX, &avp_list, 0).unwrap(); + + // Loop state set: + assert!(h.hooks.color_loop_active()); + assert!(matches!( + h.hooks.color_loop_direction(), + ColorLoopDirectionEnum::Increment + )); + assert_eq!(h.hooks.color_loop_time(), 60); + // XY values NOT applied (short-circuit took effect): + assert_eq!(h.hooks.current_x(), 0); + assert_eq!(h.hooks.current_y(), 0); + } + + // ---- apply: missing-data tolerance ---- + + #[test] + fn apply_with_no_mode_is_noop() { + // EnhancedColorMode is missing — chip's reference treats this + // as a no-op rather than an error (forward-compat with + // older firmware that didn't capture the mode). + let h = handler(Feature::XY); + let mut buf = [0u8; 128]; + let len = { + let mut wb = WriteBuf::new(&mut buf); + let parent = TLVWriteParent::new("test", &mut wb); + let array = + AttributeValuePairStructArrayBuilder::new(parent, &crate::tlv::TLVTag::Anonymous) + .unwrap(); + let array = array + .push_u16(AttributeId::CurrentX as _, 0xAAAA) + .unwrap() + .push_u16(AttributeId::CurrentY as _, 0xBBBB) + .unwrap(); + array.end().unwrap(); + wb.get_tail() + }; + let bytes = &buf[..len]; + + let elem = TLVElement::new(bytes); + let avp_list: TLVArray<'_, AttributeValuePairStruct<'_>> = TLVArray::new(elem).unwrap(); + h.apply_inner(NULL_CTX, &avp_list, 0).unwrap(); + + // Hooks never mutated — apply skipped everything. + assert_eq!(h.hooks.current_x(), 0); + assert_eq!(h.hooks.current_y(), 0); + } + + // ---- scene_apply gates drift notification ---- + + #[test] + fn apply_via_scenes_does_not_fire_invalidator() { + // The whole reason `apply` takes the scenes path is that + // mutations during a scene recall MUST NOT fire + // `notify_scenable_changed` — otherwise `SceneInvalidator` + // would flip `SceneValid` to false after the recall just set + // it true. Verify the invalidator stays at zero. + let inv = CountingInvalidator::new(); + let hooks = MockHooks::new(Feature::XY); + let h = ColorControlHandler::new(Dataver::new(1), 1, hooks).with_scene_invalidator(&inv); + + let mut buf = [0u8; 128]; + let len = build_xy_avps( + &mut buf, + 0x1111, + 0x2222, + EnhancedColorModeEnum::CurrentXAndCurrentY, + ); + let bytes = &buf[..len]; + + let elem = TLVElement::new(bytes); + let avp_list: TLVArray<'_, AttributeValuePairStruct<'_>> = TLVArray::new(elem).unwrap(); + h.apply_inner(NULL_CTX, &avp_list, 0).unwrap(); + + assert_eq!(inv.count(), 0, "scene apply must not invalidate"); + // Sanity: state DID change. + assert_eq!(h.hooks.current_x(), 0x1111); + } + + #[test] + fn direct_mutator_with_scene_apply_false_fires_invalidator() { + // The inverse: call the same internal applier with + // `scene_apply=false` (the path command handlers will use + // when they ship) — drift notification MUST fire. + let inv = CountingInvalidator::new(); + let hooks = MockHooks::new(Feature::XY); + let h = ColorControlHandler::new(Dataver::new(1), 1, hooks).with_scene_invalidator(&inv); + + h.apply_xy(NULL_CTX, 0x1111, 0x2222, false); + + assert_eq!(inv.count(), 1, "command-driven mutation must invalidate"); + } + + // ---- capture → apply roundtrip ---- + + #[test] + fn capture_then_apply_roundtrips_xy_mode_state() { + // End-to-end: stamp a known state into `src`, capture into + // bytes, apply the bytes onto an empty `dst`, then assert + // dst's state matches src's. This is the closest thing we + // have to an end-to-end YAML test for ColorControl scenes. + let src = handler(Feature::XY); + src.hooks.set_current_x(0x7F00); + src.hooks.set_current_y(0x80FF); + src.hooks + .set_enhanced_color_mode(EnhancedColorModeEnum::CurrentXAndCurrentY); + + let mut buf = [0u8; 128]; + let len = { + let mut wb = WriteBuf::new(&mut buf); + let parent = TLVWriteParent::new("roundtrip", &mut wb); + let array = + AttributeValuePairStructArrayBuilder::new(parent, &crate::tlv::TLVTag::Anonymous) + .unwrap(); + let array = src.capture(array).unwrap(); + array.end().unwrap(); + wb.get_tail() + }; + let bytes = &buf[..len]; + + // Apply onto a fresh handler with zeroed state. + let dst = handler(Feature::XY); + assert_eq!(dst.hooks.current_x(), 0); + let elem = TLVElement::new(bytes); + let avp_list: TLVArray<'_, AttributeValuePairStruct<'_>> = TLVArray::new(elem).unwrap(); + dst.apply_inner(NULL_CTX, &avp_list, 0).unwrap(); + + assert_eq!(dst.hooks.current_x(), 0x7F00); + assert_eq!(dst.hooks.current_y(), 0x80FF); + assert!(matches!( + dst.hooks.enhanced_color_mode(), + EnhancedColorModeEnum::CurrentXAndCurrentY + )); + } +} From 71c344e2f9e13769a8267421dd58c2974e440974 Mon Sep 17 00:00:00 2001 From: ivmarkov Date: Mon, 1 Jun 2026 08:27:57 +0000 Subject: [PATCH 11/15] Reduce the verbosity of the comments --- examples/src/bin/scenes_tests.rs | 42 +- .../src/dm/clusters/app/color_control.rs | 249 +---- .../src/dm/clusters/app/level_control.rs | 99 +- rs-matter/src/dm/clusters/app/on_off.rs | 91 +- rs-matter/src/dm/clusters/scenes.rs | 978 ++++++------------ rs-matter/src/persist.rs | 11 +- xtask/src/itest.rs | 29 +- 7 files changed, 385 insertions(+), 1114 deletions(-) diff --git a/examples/src/bin/scenes_tests.rs b/examples/src/bin/scenes_tests.rs index 7c5261a81..829b6bcd1 100644 --- a/examples/src/bin/scenes_tests.rs +++ b/examples/src/bin/scenes_tests.rs @@ -15,16 +15,9 @@ * limitations under the License. */ -//! An example Matter device that exercises the Scenes Management -//! cluster (`0x0062`) alongside On/Off + LevelControl over Ethernet. -//! -//! Driven by the `xtask Scenes` suite (see `xtask::TestSuite::Scenes`), -//! which runs the chip-tool `Test_TC_S_*` YAML certification tests -//! against this binary. Structurally this is a clone of -//! `dimmable_light` (same OnOff + LevelControl hooks + business -//! logic), with the Scenes Management cluster added on EP1 and the -//! per-cluster `SceneClusterHandler` impls registered with -//! `ScenesHandler::new`. +//! Example Matter device exercising the Scenes Management cluster +//! alongside On/Off + LevelControl. Structurally a clone of +//! `dimmable_light` with Scenes added on EP1. #![recursion_limit = "256"] #![allow(clippy::uninlined_format_args)] @@ -98,16 +91,11 @@ static SUBSCRIPTIONS: StaticCell = StaticCell::new(); static EVENTS: StaticCell = StaticCell::new(); static KV_BUF: StaticCell<[u8; 4096]> = StaticCell::new(); -/// Scene-table capacity for this example. `Test_TC_S_2_x` / `_3_1` and -/// the spec-mandated minimum (16) both fit comfortably; bump if -/// `TestScenesMaxCapacity` ever joins the suite. const SCENES_CAPACITY: usize = 16; static SCENES_STATE: StaticCell> = StaticCell::new(); -// `UnitTesting` is wired on EP1 for the chip-tool `TestScenes*` -// composite YAML suites — they use the cluster's `TestAddArguments` -// command to do in-test arithmetic on attribute reads. Adds ~no -// runtime cost when the suites aren't running. +// UnitTesting on EP1 is needed by the `TestScenes*` YAML suites, +// which use `TestAddArguments` for in-test arithmetic. static UNIT_TESTING_DATA: StaticCell> = StaticCell::new(); fn main() -> Result<(), Error> { @@ -170,24 +158,13 @@ fn run() -> Result<(), Error> { let mut rand = crypto.rand()?; - // `ScenesState` must be live BEFORE the scenable cluster handlers - // are constructed: each handler's `with_scene_invalidator` builder - // wires a `&ScenesState` reference into the handler so any - // command-driven mutation of its scenable attributes (`OnOff`, - // `CurrentLevel`) flips `SceneValid` to false for the recalled - // scene on this endpoint (see `TestScenesFabricSceneInfo` - // step 25). + // `ScenesState` must outlive the scenable handlers — they hold a + // reference to it via `with_scene_invalidator`. let unit_testing_data = UNIT_TESTING_DATA .uninit() .init_with(RefCell::init(UnitTestingHandlerData::init())); let scenes_state = SCENES_STATE.uninit().init_with(ScenesState::init()); - // Restore the scene table + per-fabric `CurrentScene` bookkeeping - // from KV — re-applies any scenes a previous run of this binary - // stored under `SCENES_KEY`. Must run before the data model goes - // live so `RecallScene` on the very first commission step (after - // a reboot in the middle of `Test_TC_S_2_2`) sees the persisted - // entries. futures_lite::future::block_on(scenes_state.load_persist(&mut kv, kv_buf))?; // OnOff cluster setup @@ -213,11 +190,6 @@ fn run() -> Result<(), Error> { level_control_handler.init(Some(&on_off_handler)); // Scenes Management cluster setup. - // - // `OnOffHandler` and `LevelControlHandler` implement - // `SceneClusterHandler` directly — the same `&handler` value the - // data-model chain uses doubles as the scenes-registry entry via - // the blanket `impl SceneClusterHandler for &T`. let scenes_handler = ScenesHandler::new( Dataver::new_rand(&mut rand), scenes_state, diff --git a/rs-matter/src/dm/clusters/app/color_control.rs b/rs-matter/src/dm/clusters/app/color_control.rs index 6deea8554..2ac2aaaed 100644 --- a/rs-matter/src/dm/clusters/app/color_control.rs +++ b/rs-matter/src/dm/clusters/app/color_control.rs @@ -15,54 +15,9 @@ * limitations under the License. */ -//! ColorControl cluster (skeleton handler — Scenes integration only). -//! -//! This module ships a `ColorControlHandler` whose **only complete -//! surface today is the [`SceneClusterHandler`] impl**. It does not -//! yet expose the full data-model `ClusterHandler` trait (commands -//! and attribute reads/writes are stubbed out — the -//! command-driven path will be added in a follow-up). The skeleton -//! exists primarily to validate that the -//! [`crate::dm::clusters::scenes::SceneClusterHandler`] architecture -//! gracefully handles ColorControl's shape: -//! -//! - **Up to 9 scenable attributes** (`CurrentX`, `CurrentY`, -//! `EnhancedCurrentHue`, `CurrentSaturation`, `ColorLoopActive`, -//! `ColorLoopDirection`, `ColorLoopTime`, -//! `ColorTemperatureMireds`, `EnhancedColorMode`) — vs OnOff's 1 -//! and LevelControl's 1. -//! - **Feature-conditional capture**: which subset of the attrs is -//! captured depends on the cluster's `Feature` bitmap -//! (`XY` / `HUE_AND_SATURATION` / `ENHANCED_HUE` / `COLOR_LOOP` / -//! `COLOR_TEMPERATURE`). -//! - **Mode-dependent apply**: `EnhancedColorMode` selects which -//! internal applier runs — `MoveToColor`-equivalent for `XY`, -//! `MoveToColorTemperature` for `ColorTemperatureMireds`, -//! `MoveToHueAndSaturation` / `EnhancedMoveToHueAndSaturation` for -//! the hue/saturation modes. `ColorLoopActive=1` short-circuits to -//! the `ColorLoopSet` path regardless of `EnhancedColorMode`. -//! - **Per-instance feature configuration**: instances on different -//! endpoints may enable different subsets. The handler reads its -//! active feature bitmap from the [`ColorControlHooks`] trait. -//! -//! The handler holds a [`crate::dm::clusters::scenes::SceneInvalidator`] -//! reference (set via [`ColorControlHandler::with_scene_invalidator`]) -//! so command-driven mutations of scenable attributes (when the -//! command path lands) can flip `SceneValid → false` — exactly the -//! same pattern as [`crate::dm::clusters::app::on_off::OnOffHandler`] -//! and [`crate::dm::clusters::app::level_control::LevelControlHandler`]. -//! -//! ## Hooks model -//! -//! [`ColorControlHooks`] exposes per-attribute getters and setters. -//! The application implements it on whatever per-device state it -//! keeps — typically a struct cached in static memory with -//! [`core::cell::Cell`] for each field. Setters MUST be cheap and -//! synchronous; the SceneClusterHandler's `apply` calls them inline -//! and immediately follows up with `notify_attr_changed` for -//! subscribers (unless `scene_apply=true`, in which case the -//! drift-detection notification is skipped — see the OnOff/LC -//! `set_on`/`set_level` `scene_apply` parameter for the rationale). +//! ColorControl cluster — skeleton handler exposing only the +//! [`SceneClusterHandler`] impl. The full data-model `ClusterHandler` +//! (commands, attribute reads/writes) will be added in a follow-up. use core::cell::Cell; @@ -78,25 +33,13 @@ use crate::utils::sync::blocking::Mutex; pub use crate::dm::clusters::decl::color_control::*; -/// Device-supplied state + I/O for the ColorControl cluster. -/// -/// All getters MUST be infallible (return cached state from the -/// device's local store). All setters MUST be synchronous and cheap -/// — `SceneClusterHandler::apply` calls them inline. Both halves -/// are required even for feature subsets the device doesn't -/// implement; unsupported attributes can be backed by stub fields -/// whose setters are no-ops and whose getters return a fixed sentinel -/// (typically `0`). -/// -/// `features` reports the active `Feature` bitmap for THIS endpoint -/// — different ColorControl instances may enable different subsets, -/// so the handler reads it from the hooks rather than from a global -/// constant. +/// Device-supplied state + I/O for the ColorControl cluster. Getters +/// return cached device state; setters are synchronous and cheap +/// (called inline from scene apply). Attributes outside the active +/// feature subset may be backed by no-op stubs. pub trait ColorControlHooks { - /// The `Feature` bitmap active on this endpoint. The Scenes - /// integration uses it to feature-gate capture (`XY` → emit - /// `CurrentX`/`CurrentY`, `COLOR_TEMPERATURE` → emit - /// `ColorTemperatureMireds`, etc.). + /// Active `Feature` bitmap on this endpoint. Used to feature-gate + /// scene capture. fn features(&self) -> Feature; fn current_x(&self) -> u16; @@ -120,34 +63,24 @@ pub trait ColorControlHooks { fn color_loop_direction(&self) -> ColorLoopDirectionEnum; fn set_color_loop_direction(&self, value: ColorLoopDirectionEnum); - /// Duration of one full color-loop cycle, in seconds (per spec). + /// Duration of one full color-loop cycle, in seconds. fn color_loop_time(&self) -> u16; fn set_color_loop_time(&self, value: u16); - /// The recalled-scene's `ColorLoopSet` path uses this as the - /// starting hue when activating the loop. Read-only at this level - /// — there's no scenable setter (the spec's `ColorLoopSet` - /// command sets it, but scene recall doesn't carry a new value - /// for it). + /// Starting hue used when scene recall activates the color loop. fn color_loop_start_enhanced_hue(&self) -> u16; fn enhanced_color_mode(&self) -> EnhancedColorModeEnum; fn set_enhanced_color_mode(&self, value: EnhancedColorModeEnum); } -/// Skeleton ColorControl cluster handler. See module docs for the -/// scope: this currently only implements the scenes-integration -/// surface, not the full data-model `ClusterHandler` trait. +/// Skeleton ColorControl cluster handler — currently exposes only +/// the scenes-integration surface, not the full `ClusterHandler`. pub struct ColorControlHandler<'a, H: ColorControlHooks> { - #[allow(dead_code)] // wired in when the command-handler path lands + #[allow(dead_code)] dataver: Dataver, endpoint_id: EndptId, hooks: H, - /// Optional scene-drift notifier — see - /// [`crate::dm::clusters::scenes::SceneInvalidator`]. Set via - /// [`Self::with_scene_invalidator`]; defaults to `None`, in which - /// case all internal mutators are no-ops with respect to scene - /// invalidation. scene_invalidator: Mutex>>, } @@ -174,22 +107,8 @@ impl<'a, H: ColorControlHooks> ColorControlHandler<'a, H> { } } - /// Apply the `CurrentXAndCurrentY` mode: write `CurrentX` / - /// `CurrentY` / `EnhancedColorMode` and notify subscribers. - /// `scene_apply` gates `notify_scenable_changed` — see - /// [`crate::dm::clusters::app::level_control::LevelControlHandler::set_level`] - /// for the rationale. - fn apply_xy( - &self, - ctx: &N, - x: u16, - y: u16, - // _transition_time_ds: u16 — non-instant XY transitions are - // not modelled here; the skeleton applies instantly. The - // architecture supports threading `scene_apply` through a - // task-based transition the same way LevelControl does. - scene_apply: bool, - ) { + /// Apply the `CurrentXAndCurrentY` mode. + fn apply_xy(&self, ctx: &N, x: u16, y: u16, scene_apply: bool) { self.hooks.set_current_x(x); self.hooks.set_current_y(y); self.hooks @@ -233,9 +152,8 @@ impl<'a, H: ColorControlHooks> ColorControlHandler<'a, H> { } } - /// Apply the `CurrentHueAndCurrentSaturation` mode (non-enhanced - /// hue is `u8`; chip's reference truncates `EnhancedCurrentHue` - /// to the low byte when the captured mode says non-enhanced). + /// Apply the `CurrentHueAndCurrentSaturation` mode. Hue is + /// stored in `EnhancedCurrentHue` as the low byte. fn apply_hue_saturation( &self, ctx: &N, @@ -243,9 +161,6 @@ impl<'a, H: ColorControlHooks> ColorControlHandler<'a, H> { saturation: u8, scene_apply: bool, ) { - // Truncate hue into the enhanced field — there's no separate - // non-enhanced setter on the hooks (would just be a u8 view - // of EnhancedCurrentHue per spec). self.hooks.set_enhanced_current_hue(hue_u8 as u16); self.hooks.set_current_saturation(saturation); self.hooks @@ -304,10 +219,8 @@ impl<'a, H: ColorControlHooks> ColorControlHandler<'a, H> { } } - /// Apply a color-loop activation. Mirrors chip's - /// `ColorControl::ApplyScene` short-circuit: when a recalled - /// scene has `ColorLoopActive=1`, drop the `MoveTo*` dispatch - /// entirely and run the loop instead. + /// Apply a color-loop activation — short-circuits `MoveTo*` + /// dispatch when the recalled scene has `ColorLoopActive=1`. fn apply_color_loop( &self, ctx: &N, @@ -348,11 +261,8 @@ impl SceneClusterHandler for ColorControlHandler<'_, H> { } fn is_scenable_attribute(attribute_id: AttrId) -> bool { - // Matter App Cluster spec §3.2.10. Feature-gated availability - // is enforced at capture/apply time (a captured attribute - // that maps to a disabled feature is silently dropped), not - // here — `is_scenable_attribute` only validates `AddScene` - // payload shape. + // Feature-gated availability is enforced at capture/apply, + // not here — this only validates `AddScene` payload shape. matches!( attribute_id, a if a == AttributeId::CurrentX as AttrId @@ -371,8 +281,6 @@ impl SceneClusterHandler for ColorControlHandler<'_, H> { &self, avp_array: AttributeValuePairStructArrayBuilder

, ) -> Result, Error> { - // Capture order mirrors chip's - // `DefaultColorControlSceneHandler::SerializeSave`. // `EnhancedColorMode` is captured unconditionally — apply // dispatches on it. let features = self.hooks.features(); @@ -419,8 +327,6 @@ impl SceneClusterHandler for ColorControlHandler<'_, H> { avp_array }; - // `EnhancedColorMode` is `enum8`, serialised as - // `valueUnsigned8`. Always captured (drives apply dispatch). let mode = self.hooks.enhanced_color_mode(); avp_array.push_u8(AttributeId::EnhancedColorMode as _, mode as u8) } @@ -431,32 +337,22 @@ impl SceneClusterHandler for ColorControlHandler<'_, H> { avp_list: &TLVArray<'_, AttributeValuePairStruct<'_>>, transition_time_ms: u32, ) -> Result<(), Error> { - // Delegate to the narrower-typed inner method so unit tests - // can pass `&()` (a no-op `AttrChangeNotifier`) without - // needing to mock a full `HandlerContext`. `HandlerContext` - // is a supertrait of `AttrChangeNotifier`, so any `&C` passed - // in here also satisfies the inner method's bound. + // Inner method takes `AttrChangeNotifier` (a `HandlerContext` + // supertrait) so unit tests can pass `&()` without mocking. self.apply_inner(ctx, avp_list, transition_time_ms) } } impl ColorControlHandler<'_, H> { - /// Inner apply (sync) — same logic as the trait method but - /// scoped to `AttrChangeNotifier` instead of `HandlerContext`. - /// Sync because every per-mode applier is sync (no transition - /// task yet); when the command-handler path lands, the - /// transitioning variants will be queued through a `task_signal` - /// the same way LevelControl does it, and that signalling is - /// already sync. + /// Sync apply, scoped to `AttrChangeNotifier` for testability. fn apply_inner( &self, ctx: &N, avp_list: &TLVArray<'_, AttributeValuePairStruct<'_>>, _transition_time_ms: u32, ) -> Result<(), Error> { - // Sweep the AVP list once and stash each known value. We - // need `EnhancedColorMode` AND the mode-specific values - // before we can decide which applier to call. + // We need `EnhancedColorMode` plus the mode-specific values + // before dispatching, so collect them all in one pass. let mut mode: Option = None; let mut current_x: Option = None; let mut current_y: Option = None; @@ -493,9 +389,7 @@ impl ColorControlHandler<'_, H> { } } - // If the scene captured an active color loop, hand off to - // the loop applier and ignore the Move-To dispatch — - // mirrors chip's `ColorControl::ApplyScene`. + // An active color loop short-circuits the MoveTo dispatch. if color_loop_active == Some(1) { let direction = color_loop_direction .and_then(color_loop_direction_from_u8) @@ -506,8 +400,6 @@ impl ColorControlHandler<'_, H> { } let Some(mode) = mode else { - // No mode captured (perhaps an older firmware's blob - // that didn't include it) — nothing to do. return Ok(()); }; @@ -525,9 +417,7 @@ impl ColorControlHandler<'_, H> { self.apply_color_temperature(ctx, mireds, true); } EnhancedColorModeEnum::CurrentHueAndCurrentSaturation => { - // Non-enhanced hue is u8; chip's reference truncates - // EnhancedCurrentHue's low byte. (Behaviour mirrored - // from `ColorControl::ApplyScene`.) + // Non-enhanced hue is the low byte of EnhancedCurrentHue. let (Some(hue), Some(sat)) = ( enhanced_current_hue.map(|h| (h & 0xFF) as u8), current_saturation, @@ -549,8 +439,7 @@ impl ColorControlHandler<'_, H> { } /// Convert a stored `valueUnsigned8` to an -/// [`EnhancedColorModeEnum`], returning `None` for unknown values -/// rather than failing the apply (matches chip's lenient parse). +/// [`EnhancedColorModeEnum`], `None` for unknown values. fn enhanced_color_mode_from_u8(v: u8) -> Option { match v { 0 => Some(EnhancedColorModeEnum::CurrentHueAndCurrentSaturation), @@ -562,7 +451,7 @@ fn enhanced_color_mode_from_u8(v: u8) -> Option { } /// Convert a stored `valueUnsigned8` to a [`ColorLoopDirectionEnum`], -/// returning `None` for unknown values. +/// `None` for unknown values. fn color_loop_direction_from_u8(v: u8) -> Option { match v { 0 => Some(ColorLoopDirectionEnum::Decrement), @@ -573,47 +462,17 @@ fn color_loop_direction_from_u8(v: u8) -> Option { #[cfg(test)] mod tests { - //! Unit tests for the ColorControl scenes integration. Validates - //! the [`SceneClusterHandler`] impl against the cluster's - //! feature-gated capture matrix, mode-routed apply dispatch, - //! `ColorLoopActive=1` short-circuit, and the `scene_apply` / - //! drift-detection contract. - //! - //! End-to-end YAML coverage isn't available — no chip-tool - //! `Test_TC_S_*` / `TestScenes*` suite exercises ColorControl - //! AVPs. These tests are the validation gate for the integration. - //! - //! Test infrastructure: - //! - [`MockHooks`]: `Cell`-backed implementation of - //! [`ColorControlHooks`]. Sets `features()` from a constructor - //! field; all getters return the cached values; all setters - //! write through. - //! - [`CountingInvalidator`]: tracks `scenable_attribute_changed` - //! call count so tests can assert on drift-notification gating. - //! - `build_avp_bytes`: round-trips AVPs through the codegen - //! `AttributeValuePairStructArrayBuilder` so `apply` receives - //! the same TLV shape it would on the wire. - //! - `dummy_ctx`: minimal `HandlerContext` whose - //! `notify_attr_changed` is a no-op (subscriber notification is - //! tested elsewhere; here we focus on scene-cluster behaviour). + //! Unit tests for the ColorControl scenes integration — no + //! chip-tool YAML suite covers it. use super::*; use crate::tlv::{TLVElement, TLVWriteParent}; use crate::utils::storage::WriteBuf; - /// All in-test calls into `apply_*` go through the - /// `&impl AttrChangeNotifier` surface (not the full - /// `HandlerContext`). The stdlib `()` is a no-op - /// `AttrChangeNotifier` (see `dm::types::handler::impl AttrChangeNotifier for ()`), - /// so we use `&()` everywhere we'd otherwise need to mock a - /// context. This sidesteps the heavy `HandlerContext`-mock - /// boilerplate that doesn't add real test coverage — the apply - /// helpers only USE the notifier surface. + /// `()` is a no-op `AttrChangeNotifier`, which is all the apply + /// helpers use — avoids mocking a full `HandlerContext`. const NULL_CTX: &() = &(); - /// `Cell`-backed implementation of [`ColorControlHooks`] for - /// tests. The `features` bitmap is constructor-fixed; everything - /// else round-trips through `Cell::get`/`set`. struct MockHooks { features: Feature, current_x: Cell, @@ -711,9 +570,6 @@ mod tests { } } - /// [`SceneInvalidator`] mock that counts calls — tests assert - /// on this to verify the `scene_apply` flag suppresses drift - /// notification. struct CountingInvalidator { count: Cell, } @@ -735,36 +591,7 @@ mod tests { } } - /// AVP-array TLV blobs are built inline per-test rather than via - /// a helper: the `AttributeValuePairStructArrayBuilder` carries - /// the `WriteBuf` lifetime in nested type parameters, so a - /// generic helper has unpleasant HRTB-around-nested-lifetime - /// signatures. Inlining is shorter than the indirection. - /// - /// The pattern is: - /// ```ignore - /// let mut buf = [0u8; 128]; - /// let len = { - /// let mut wb = WriteBuf::new(&mut buf); - /// let parent = TLVWriteParent::new("test", &mut wb); - /// let array = AttributeValuePairStructArrayBuilder::new( - /// parent, &crate::tlv::TLVTag::Anonymous, - /// ).unwrap(); - /// let array = array.push_u16(…).unwrap()…; - /// array.end().unwrap(); - /// wb.get_tail() - /// }; // wb dropped here → buf's mutable borrow released - /// let bytes = &buf[..len]; - /// ``` - - /// Returns a fresh [`ColorControlHandler`] for tests. `EP = 1` - /// matches our other scene-aware handlers' test convention. fn handler(features: Feature) -> ColorControlHandler<'static, MockHooks> { - // SAFETY-equivalent of leaking: tests don't drop, and the - // hooks live for the duration of the test. Cleaner than - // wrestling with `'static` bounds for `with_scene_invalidator`. - // (The invalidator is wired explicitly per test via - // `with_scene_invalidator` when needed.) let hooks = MockHooks::new(features); ColorControlHandler::new(Dataver::new(1), 1, hooks) } @@ -947,8 +774,6 @@ mod tests { // ---- apply: mode dispatch ---- - /// Build an AVP list inline (see the `build_avp_bytes` doc-block - /// for the pattern). Used by every apply-side test. fn build_xy_avps(buf: &mut [u8], x: u16, y: u16, mode: EnhancedColorModeEnum) -> usize { let mut wb = WriteBuf::new(buf); let parent = TLVWriteParent::new("test", &mut wb); @@ -1164,9 +989,7 @@ mod tests { #[test] fn apply_with_no_mode_is_noop() { - // EnhancedColorMode is missing — chip's reference treats this - // as a no-op rather than an error (forward-compat with - // older firmware that didn't capture the mode). + // Missing EnhancedColorMode → no-op rather than error. let h = handler(Feature::XY); let mut buf = [0u8; 128]; let len = { diff --git a/rs-matter/src/dm/clusters/app/level_control.rs b/rs-matter/src/dm/clusters/app/level_control.rs index 868e7de3a..ee4e0d5e0 100644 --- a/rs-matter/src/dm/clusters/app/level_control.rs +++ b/rs-matter/src/dm/clusters/app/level_control.rs @@ -100,15 +100,9 @@ enum Task { with_on_off: bool, target: u8, transition_time: u16, - /// True when this task was queued by - /// [`SceneClusterHandler::apply`] — every - /// `set_level` call this transition triggers will skip - /// [`notify_scenable_changed`] so the Scenes cluster's - /// `SceneValid` bookkeeping stays true throughout the - /// recall (the state we're transitioning *toward* IS the - /// recalled-scene state). False for regular - /// command-initiated moves, where each step is genuine - /// drift and must invalidate `SceneValid`. + /// When `true`, the transition was queued by a scene recall; + /// `set_level` skips `notify_scenable_changed` so `SceneValid` + /// is preserved. scene_apply: bool, }, Move { @@ -205,9 +199,8 @@ pub struct LevelControlHandler<'a, H: LevelControlHooks, OH: OnOffHooks> { endpoint_id: EndptId, hooks: H, on_off_handler: Mutex>>>, - /// See `OnOffHandler::scene_invalidator` — same role, fired - /// whenever `CurrentLevel` (the cluster's only scenable attribute) - /// mutates. + /// See [`OnOffHandler::with_scene_invalidator`] — same role, fired + /// when `CurrentLevel` mutates. scene_invalidator: Mutex>>, state: Mutex>, task_signal: Signal>, @@ -301,23 +294,17 @@ impl<'a, H: LevelControlHooks, OH: OnOffHooks> LevelControlHandler<'a, H, OH> { } } - /// Attach a [`SceneInvalidator`] (typically the - /// [`crate::dm::clusters::scenes::ScenesState`] that backs the - /// Scenes Management cluster on the same endpoint). When set, - /// every successful mutation of `CurrentLevel` calls the - /// invalidator so the Scenes cluster can flip `SceneValid` to - /// false for any currently-recalled scene on this endpoint. No-op - /// when unset, so non-scenes deployments incur zero overhead. + /// Attach a [`SceneInvalidator`] — typically the + /// [`crate::dm::clusters::scenes::ScenesState`] backing Scenes + /// Management on the same endpoint — so command-driven + /// `CurrentLevel` mutations flip `SceneValid → false` for any + /// recalled scene. No-op when unset. pub fn with_scene_invalidator(self, invalidator: &'a dyn SceneInvalidator) -> Self { self.scene_invalidator .lock(|cell| cell.set(Some(invalidator))); self } - /// Internal: notify the wired [`SceneInvalidator`], if any, that - /// `CurrentLevel` just changed on this endpoint. Called from - /// `set_level` so every Move / Step / MoveTo / Stop command path - /// converges through a single notification site. fn notify_scenable_changed(&self) { if let Some(inv) = self.scene_invalidator.lock(|cell| cell.get()) { inv.scenable_attribute_changed(self.endpoint_id); @@ -504,18 +491,9 @@ impl<'a, H: LevelControlHooks, OH: OnOffHooks> LevelControlHandler<'a, H, OH> { false => Some(level), }; self.hooks.set_current_level(current_level); - // Every Move / Step / MoveTo / Stop command path lands here, - // so `set_level` is the single notification point we need to - // hook for the Scenes cluster's `SceneValid` drift logic — - // EXCEPT when this mutation was queued by - // [`SceneClusterHandler::apply`]. In the scene-apply case the - // device is transitioning *toward* the recalled-scene state, - // so SceneValid must stay true throughout (including - // intermediate steps of a non-instant transition). The - // caller passes `scene_apply=true` to suppress drift - // notification on every step; the final step's - // `SceneValid=true` is then set by the Scenes handler's - // `remember_current` after `apply` returns. + // Scene-recall transitions move *toward* the recalled state, + // so they must not invalidate `SceneValid` on intermediate + // steps. Scenes restores the bit after `apply` returns. if !scene_apply { self.notify_scenable_changed(); } @@ -888,9 +866,7 @@ impl<'a, H: LevelControlHooks, OH: OnOffHooks> LevelControlHandler<'a, H, OH> { let t_time = transition_time.unwrap_or(0); - // `move_to_level_blocking` is only used by command-driven - // paths (MoveToLevel-with-blocking) — never by scene apply - // — so `scene_apply=false`. + // Command-driven only — never reached from scene apply. self.move_to_level_transition(ctx, with_on_off, level, t_time, false) .await?; @@ -1891,18 +1867,9 @@ impl OnOffHooks for NoOnOff { } } -/// Scenes Management integration for the LevelControl cluster. -/// -/// Per Matter Application Cluster Spec §1.5, LevelControl exposes a -/// single scene-able attribute (`CurrentLevel`, nullable u8) that is -/// **read-only** at the attribute level. Scene apply therefore goes -/// through the `MoveToLevel` path with the captured level + the -/// scene's transition time (ms → deciseconds, saturating). -/// -/// Implemented directly on [`LevelControlHandler`] — the same handler -/// value the application keeps for the normal data-model chain -/// doubles as the scenes-registry entry, via the blanket -/// `impl SceneClusterHandler for &T`. +/// Scenes Management integration for the LevelControl cluster. The +/// only scenable attribute is `CurrentLevel`; apply routes through +/// `MoveToLevel` with the scene's transition time. impl SceneClusterHandler for LevelControlHandler<'_, H, OH> where H: LevelControlHooks, @@ -1915,8 +1882,6 @@ where } fn is_scenable_attribute(attribute_id: AttrId) -> bool { - // Per Matter App Cluster spec, only `CurrentLevel` is the - // scenable attribute on LevelControl. attribute_id == AttributeId::CurrentLevel as AttrId } @@ -1924,9 +1889,7 @@ where &self, avp_array: AttributeValuePairStructArrayBuilder

, ) -> Result, Error> { - // `CurrentLevel` is `nullable int8u`. Null → skip the AVP - // entry; downstream apply has nothing to act on. Read - // directly from device-supplied state — no IM round-trip. + // `CurrentLevel` is nullable; null → skip the AVP entry. if let Some(level) = self.hooks.current_level() { avp_array.push_u8(AttributeId::CurrentLevel as _, level) } else { @@ -1948,26 +1911,12 @@ where let Some(level) = avp.value_unsigned_8()? else { continue; }; - // Delegate to the same task-based pipeline that - // command-driven `MoveToLevel` uses, but with - // `scene_apply=true` threaded through. That flag - // suppresses `notify_scenable_changed` on every - // intermediate (and final) `set_level` call this - // transition triggers, so the Scenes cluster's - // `SceneValid` bookkeeping stays true throughout the - // recall — instant *or* smooth-fade — instead of being - // re-clobbered when the (asynchronous) transition task - // lands the final level after `recall_scene` had already - // called `remember_current`. - // - // `with_on_off = false` so this LC apply doesn't - // trigger OnOff coupling: the scene blob's own OnOff - // AVP (if any) lands OnOff independently via - // `OnOffHandler::apply`. - // - // `RecallScene.transitionTime` is `int32u` milliseconds; - // `MoveToLevel.transitionTime` is `int16u` deciseconds. - // Convert with saturation. + // Reuse the command-driven `MoveToLevel` pipeline with + // `scene_apply=true` (suppresses `SceneValid` drift on + // every step) and `with_on_off=false` (OnOff lands its own + // AVP via `OnOffHandler::apply`). RecallScene transition + // time is ms; MoveToLevel is deciseconds — convert with + // saturation. let transition_ds = (transition_time_ms / 100).min(u16::MAX as u32) as u16; return self.move_to_level( false, diff --git a/rs-matter/src/dm/clusters/app/on_off.rs b/rs-matter/src/dm/clusters/app/on_off.rs index f48e27bde..634d59350 100644 --- a/rs-matter/src/dm/clusters/app/on_off.rs +++ b/rs-matter/src/dm/clusters/app/on_off.rs @@ -143,15 +143,8 @@ pub struct OnOffHandler<'a, H: OnOffHooks, LH: LevelControlHooks> { endpoint_id: EndptId, hooks: H, level_control_handler: Mutex>>>, - /// Optional notifier for the Scenes Management cluster's - /// `SceneValid` bookkeeping (see - /// [`crate::dm::clusters::scenes::SceneInvalidator`]). Set via - /// [`OnOffHandler::with_scene_invalidator`] when this device hosts - /// Scenes Management on the same endpoint. Every successful - /// command-driven mutation of `OnOff` (the cluster's only scenable - /// attribute) calls back into the invalidator so any - /// currently-recalled scene on this endpoint flips to - /// `SceneValid=false`. + /// Set via [`OnOffHandler::with_scene_invalidator`] when this + /// device hosts Scenes Management on the same endpoint. scene_invalidator: Mutex>>, state: Mutex>, state_change_signal: Signal>, @@ -302,22 +295,17 @@ impl<'a, H: OnOffHooks, LH: LevelControlHooks> OnOffHandler<'a, H, LH> { HandlerAsyncAdaptor(self) } - /// Attach a [`SceneInvalidator`] (typically the - /// [`crate::dm::clusters::scenes::ScenesState`] that backs the - /// Scenes Management cluster on the same endpoint). When set, - /// every successful mutation of the `OnOff` attribute calls the - /// invalidator so the Scenes cluster can flip `SceneValid` to - /// false for any currently-recalled scene on this endpoint. No-op - /// when unset, so non-scenes deployments incur zero overhead. + /// Attach a [`SceneInvalidator`] — typically the + /// [`crate::dm::clusters::scenes::ScenesState`] backing Scenes + /// Management on the same endpoint — so command-driven `OnOff` + /// mutations flip `SceneValid → false` for any recalled scene. + /// No-op when unset. pub fn with_scene_invalidator(self, invalidator: &'a dyn SceneInvalidator) -> Self { self.scene_invalidator .lock(|cell| cell.set(Some(invalidator))); self } - /// Internal: notify the wired [`SceneInvalidator`], if any, that - /// `OnOff` just changed on this endpoint. Called from every - /// command-driven `OnOff` mutation site. fn notify_scenable_changed(&self) { if let Some(inv) = self.scene_invalidator.lock(|cell| cell.get()) { inv.scenable_attribute_changed(self.endpoint_id); @@ -363,10 +351,8 @@ impl<'a, H: OnOffHooks, LH: LevelControlHooks> OnOffHandler<'a, H, LH> { let lighting_attrs_updated = Self::update_attr_on(state); ctx.notify_attr_changed(self.endpoint_id, Self::CLUSTER.id, AttributeId::OnOff as _); - // Scene-recall-driven mutations transition the device - // *into* a known scene state, so they MUST NOT fire - // drift-detection — Scenes handles `SceneValid` via - // `remember_current` once the recall completes. + // Scene-recall mutations transition *into* the recalled state, + // so they must not trigger `SceneValid` drift-detection. if !scene_apply { self.notify_scenable_changed(); } @@ -1151,17 +1137,9 @@ impl LevelControlHooks for NoLevelControl { } } -/// Scenes Management integration for the OnOff cluster. -/// -/// Per Matter Application Cluster Spec §1.4 / §1.5, OnOff exposes a -/// single scene-able attribute (`OnOff`, bool) that is **read-only** -/// at the attribute level. Scene apply therefore goes through the -/// `On` / `Off` commands rather than an attribute write. -/// -/// Implemented directly on [`OnOffHandler`] — the same handler value -/// the application keeps for the normal data-model chain doubles as -/// the scenes-registry entry, via the blanket -/// `impl SceneClusterHandler for &T`. +/// Scenes Management integration for the OnOff cluster. The only +/// scenable attribute is `OnOff`; apply routes through `set_on` / +/// `set_off` rather than an attribute write. impl SceneClusterHandler for OnOffHandler<'_, H, LH> where H: OnOffHooks, @@ -1174,8 +1152,6 @@ where } fn is_scenable_attribute(attribute_id: AttrId) -> bool { - // Per Matter App Cluster spec, only `OnOff` (the cluster's - // namesake) is the scenable attribute. attribute_id == AttributeId::OnOff as AttrId } @@ -1183,7 +1159,6 @@ where &self, avp_array: AttributeValuePairStructArrayBuilder

, ) -> Result, Error> { - // Read directly from device-supplied state — no IM round-trip. let v = self.hooks.on_off(); avp_array.push_u8(AttributeId::OnOff as _, v as u8) } @@ -1202,40 +1177,14 @@ where let Some(value) = avp.value_unsigned_8()? else { continue; }; - // Per spec, OnOff doesn't honour a per-scene transition - // time (it's a discrete on/off transition). Mutate - // state *inline* via the same `set_on` / `set_off` - // helpers the command state-machine uses — not via the - // `state_change_signal` deferred path. The reason is - // sequencing with `SceneInvalidator`: `set_on` / - // `set_off` fire `notify_scenable_changed` synchronously, - // which flips `SceneValid → false` for the recalled - // scene. The Scenes handler then calls `remember_current` - // after `apply` returns and resets `SceneValid → true`. - // If we deferred via the signal, the invalidation would - // race AFTER `remember_current` and incorrectly clobber - // it (caught by `Test_TC_S_2_2`). - // `level_control_initiated=true` suppresses the - // OnOff → LC coupling (`coupled_on_off_cluster_on_off_state_change`). - // We don't want that coupling during scene recall because: - // (a) the scene blob carries its own `CurrentLevel` AVP - // which LevelControl's `apply` lands directly, so - // any indirect LC mutation OnOff would queue is at - // best redundant and at worst conflicts; - // (b) the coupling is signal-based — the LC task - // consumes `Task::OnOffStateChange` ASYNCHRONOUSLY - // and ends up calling `set_level` long after - // `recall_scene` returns. That `set_level` fires - // `notify_scenable_changed`, flipping - // `SceneValid → false` after `remember_current` - // had restored it to `true` — observable as the - // `Test_TC_S_2_2` post-RecallScene FabricSceneInfo - // read returning the wrong `SceneValid`. - // - // The bool's name is "level_control_initiated" because - // its original intent is "called by LC, don't loop back - // to LC". Reusing it for the structurally-identical - // "called by Scenes" case is the cleanest available hook. + // OnOff scene apply is a discrete transition (no per-scene + // fade), so mutate inline via `set_on` / `set_off` rather + // than the deferred `state_change_signal` path — Scenes + // then calls `remember_current` to restore `SceneValid` in + // the same await. `level_control_initiated=true` suppresses + // OnOff↔LC coupling, since the scene blob carries its own + // `CurrentLevel` AVP that LevelControl applies directly; + // `scene_apply=true` suppresses drift-invalidation. self.with_state(|state| { if value != 0 { self.set_on(state, true, true, ctx); diff --git a/rs-matter/src/dm/clusters/scenes.rs b/rs-matter/src/dm/clusters/scenes.rs index 570685095..60b858b7a 100644 --- a/rs-matter/src/dm/clusters/scenes.rs +++ b/rs-matter/src/dm/clusters/scenes.rs @@ -15,61 +15,22 @@ * limitations under the License. */ -//! Scenes Management cluster (Matter Application Cluster Specification). +//! Scenes Management cluster handler. //! -//! # Overview +//! A scene is a named snapshot of a chosen subset of cluster +//! attributes on one endpoint, recallable on demand. Scene capture +//! and apply talk to scene-aware clusters via the +//! [`SceneClusterHandler`] trait, which the cluster's normal handler +//! type implements directly — `&on_off_handler` doubles as both a +//! data-model chain entry and a scenes-registry entry. //! -//! A scene is a named snapshot of a chosen subset of cluster attributes -//! on one endpoint, stored on the device and recallable on demand. The -//! Scenes Management cluster owns the scene table and exposes commands -//! to add, view, remove, snapshot (`StoreScene`) and apply -//! (`RecallScene`) scenes. +//! [`ScenesState`] holds the per-device scene table and per-fabric +//! `CurrentScene` bookkeeping; the table is persisted as a single +//! TLV blob under [`SCENES_KEY`] on every successful mutation, and +//! re-hydrated on startup via [`ScenesState::load_persist`]. //! -//! # Implementation status -//! -//! - All 8 commands have entry points. -//! - The 6 **data-only** commands are fully implemented: -//! `AddScene`, `ViewScene`, `RemoveScene`, `RemoveAllScenes`, -//! `GetSceneMembership`, `CopyScene`. -//! - **`StoreScene`** is fully implemented: it reads the scene-able -//! attributes of the registered clusters (OnOff + LevelControl — -//! see [`SCENEABLE_CLUSTERS`]) on the host endpoint via -//! `ctx.handler().read()` and stores the result as a wire-form -//! `ExtensionFieldSetStructs` blob keyed by `(group, scene)`. -//! - **`RecallScene`** is fully implemented: it parses the stored -//! `ExtensionFieldSetStructs` blob and re-applies each cluster's -//! captured state by invoking the spec'd cluster command (OnOff: -//! `On` / `Off`; LevelControl: `MoveToLevel`) via -//! `ctx.handler().invoke()`. Apply is intentionally per-cluster (not -//! a generic attribute write) because both spec'd scene-able -//! attributes are read-only. -//! - **In-RAM storage only**: scenes are not persisted across reboots. -//! - The `SceneNames` feature is **disabled** by default; scene names -//! sent by the controller are accepted on the wire but discarded. -//! -//! # Storage model -//! -//! Scenes are fabric-scoped (each fabric has its own scene table) and -//! per-endpoint (scenes on EP1 don't affect EP2). The state is a -//! single flat [`Vec`] of [`SceneEntry`] entries keyed by -//! `(fab_idx, endpoint_id, group_id, scene_id)`. -//! -//! The caller owns the [`ScenesState`] and shares it via reference -//! with one [`ScenesHandler`] per endpoint where the cluster is -//! exposed. -//! -//! # Async-trait shape note -//! -//! [`ClusterAsyncHandler`] methods that don't actually need to -//! `.await` anything (every one except `handle_store_scene`) are -//! written as -//! `fn foo(...) -> impl Future<...> { ready(self.foo(...)) }` -//! delegating to a plain `fn foo(...) -> Result<...>` helper. This -//! compiles to a much smaller image than `async fn` — no state-machine -//! generator, no closure. Matters on flash-constrained MCUs. -//! `handle_store_scene` and `handle_recall_scene` *do* await -//! (cross-cluster reads + invokes), so their wrappers are real -//! `async fn`s that call [`Self::store_scene`] / [`Self::recall_scene`]. +//! The `SceneNames` feature is not supported — scene names sent on +//! the wire are accepted and discarded. use core::future::{ready, Future}; use core::num::NonZeroU8; @@ -92,114 +53,75 @@ use crate::utils::sync::blocking::Mutex; pub use crate::dm::clusters::decl::scenes_management::*; pub use crate::persist::SCENES_KEY; -/// IM status codes specific to the Scenes Management cluster (see -/// "Generic Usage Notes" in the Matter Application Cluster spec). +// IM status codes used by Scenes Management response structs and +// command-level `Err(...)` returns. const SC_NOT_FOUND: u8 = 0x8B; const SC_INSUFFICIENT_SPACE: u8 = 0x89; -/// IM-level `INVALID_COMMAND` (0x85). Returned by every group-aware -/// Scenes command when `GroupID != 0` is not present in the Groups -/// cluster's Group Table for `(fab_idx, endpoint_id)` — per Matter -/// Application Cluster spec §1.4.9 "Common per-command behavior". const SC_INVALID_COMMAND: u8 = 0x85; -/// IM-level `CONSTRAINT_ERROR` (0x87). Returned by every Scenes -/// command that takes a `SceneID` when the value is `0xFF`, which the -/// spec reserves as invalid (valid range is `0x00 – 0xFE`). const SC_CONSTRAINT_ERROR: u8 = 0x87; -/// Reserved (invalid) `SceneID` value per Matter App Cluster spec. + +/// Reserved (invalid) `SceneID` value per Matter Core Spec. const RESERVED_SCENE_ID: SceneId = 0xFF; -/// Maximum legal `TransitionTime` value on `AddScene`, per Matter App -/// Cluster spec §1.4.7.1 "AddScene Command": -/// The maximum value SHALL be 60 000 000 (1000 minutes). -/// Anything larger MUST be rejected with `CONSTRAINT_ERROR`. + +/// Maximum legal `AddScene.TransitionTime` in milliseconds +/// (60 000 seconds / 1000 minutes per Matter Core Spec). const MAX_TRANSITION_TIME_MS: u32 = 60_000_000; -/// Max length of the serialized `ExtensionFieldSetStructs` payload -/// carried on a single scene record. Per chip's notes a Color Control -/// scene is the largest realistic case at ~99 B; OnOff + LevelControl -/// scenes are ~16 B. `128` covers the realistic worst case for the -/// clusters Phase B.2 / C will register, with the cost paid per scene -/// (so `N * MAX_EXT_FIELDS_LEN` RAM total). +/// Default max length of the serialized `ExtensionFieldSetStructs` +/// payload on a single scene record. ColorControl scenes are the +/// largest realistic case at ~100 B; OnOff + LevelControl scenes are +/// ~16 B. Bumpable via the `M` const generic on [`ScenesState`] / +/// [`ScenesHandler`]; total RAM cost is `N * M`. pub const MAX_EXT_FIELDS_LEN: usize = 128; -/// Per-cluster scene capture + apply trait. -/// -/// Implemented **directly on the cluster's normal handler type** — -/// e.g. `impl SceneClusterHandler for OnOffHandler<'_, H, LH>`. The -/// same `&handler` value the application registers in the data-model -/// chain doubles as a scenes registry entry, so cross-cluster reads -/// and writes during `StoreScene` / `RecallScene` are direct typed -/// method calls on the handler — no IM-layer round-trip, no TLV -/// serde, no recursion-limit games. -/// -/// The application composes a tuple-recursive registry -/// (`(&on_off, (&level_control, ()))`, etc.) and passes it to -/// [`ScenesHandler::new`]; the blanket impl -/// `impl SceneClusterHandler for &T` -/// makes the references flow through transparently. +/// Per-cluster scene capture + apply trait. Implemented directly on +/// the cluster's handler type (e.g. `OnOffHandler`) so the same +/// `&handler` the application registers in the data-model chain can +/// also be registered in the scenes registry — no separate wrapper, +/// no IM round-trip, no TLV serde. /// -/// Back-direction notifications (a scenable attribute mutated, so -/// `SceneValid` may need to flip) flow through -/// [`SceneInvalidator`], implemented by [`ScenesState`]. +/// Back-direction (a scenable attribute mutated, so `SceneValid` may +/// need to flip) goes through [`SceneInvalidator`], implemented by +/// [`ScenesState`]. pub trait SceneClusterHandler { - /// The Matter cluster ID this impl handles. Used by [`SceneClusters`] - /// to route apply dispatch. + /// The Matter cluster ID this impl handles. const CLUSTER_ID: ClusterId; - /// Endpoint this handler instance is installed on. The Scenes - /// handler uses this to skip clusters that don't live on the - /// `StoreScene` / `RecallScene` target endpoint. + /// Endpoint this handler instance is installed on. Used to skip + /// clusters not on the `StoreScene` / `RecallScene` target endpoint. fn endpoint_id(&self) -> EndptId; - /// Return `true` if `attribute_id` is a scenable attribute of this - /// cluster per the Matter Application Cluster spec. Walked by - /// [`SceneClusters::check_scenable`] during `AddScene` to reject - /// `ExtensionFieldSetStructs` referencing non-scenable attributes - /// (the spec requires `INVALID_COMMAND` in that case; - /// `Test_TC_S_2_2` step 8g exercises it). - /// - /// Default impl rejects every attribute — concrete cluster - /// handlers MUST override. + /// True if `attribute_id` is a scenable attribute of this cluster + /// per the Matter Core Spec. `AddScene` rejects EFS payloads that + /// reference non-scenable attributes. fn is_scenable_attribute(_attribute_id: AttrId) -> bool { false } - /// Emit zero-or-more `AttributeValuePairStruct` elements for this - /// cluster's scenable state into `avp_array`, reading directly - /// from the handler's internal state (no IM round-trip). Returns - /// the (advanced) builder so the caller can close the array. - /// - /// Synchronous — internal state reads don't block. Use - /// [`AttributeValuePairStructArrayBuilder::push_u8`] / - /// [`AttributeValuePairStructArrayBuilder::push_u16`] / etc. for - /// a one-line per-attribute API. + /// Emit AVP entries for this cluster's scenable state into + /// `avp_array`. Use [`AttributeValuePairStructArrayBuilder::push_u8`] + /// / [`AttributeValuePairStructArrayBuilder::push_u16`] for a + /// one-line per-attribute API. fn capture( &self, avp_array: AttributeValuePairStructArrayBuilder

, ) -> Result, Error>; - /// Apply captured `avp_list` entries to the handler's internal - /// state directly (e.g. by calling the same private helpers that - /// the cluster's own command bodies use). `transition_time_ms` is - /// the effective transition for this recall (either the - /// `RecallScene` request override or the stored value). - /// - /// `ctx` is a [`HandlerContext`] — the same shape the cluster's - /// own long-running `run()` task receives. It gives the impl - /// access to `notify_attr_changed` (for subscribers) and - /// `kv()` (for persisting state mutated by the recall), without - /// exposing the IM-routed `ctx.handler()` recursion path that - /// led to the trait's earlier `T: AsyncHandler` design problem - /// — calling `ctx.handler()` from inside `apply` re-creates that - /// recursion-limit pathology, so impls MUST NOT do that. Clusters - /// whose state mutation runs on a long-running task - /// (signal-driven OnOff / LevelControl) can ignore `ctx` entirely: - /// the task carries its own context and fires its own - /// `notify_attr_changed` when the mutation lands. Clusters that - /// mutate state synchronously inside `apply` use `ctx` to notify - /// subscribers and persist as needed. - /// - /// Async because some clusters (LevelControl) kick off transition + /// Apply captured AVPs to the cluster's internal state. Async + /// because some clusters (LevelControl) kick off transition /// tasks; sync-only impls can return [`core::future::ready`]. + /// + /// # Arguments + /// - `ctx` — [`HandlerContext`] for subscriber notification + /// ([`crate::dm::AttrChangeNotifier::notify_attr_changed`]) and + /// persistence ([`HandlerContext::kv`]). Impls MUST NOT call + /// `ctx.handler()` from inside `apply` — recursion-limit + /// pathology, by design. + /// - `avp_list` — the captured scenable AVPs from `AddScene` / + /// `StoreScene`. + /// - `transition_time_ms` — effective transition time + /// (`RecallScene` request override, falling back to the stored + /// per-scene value). fn apply( &self, ctx: &C, @@ -208,10 +130,9 @@ pub trait SceneClusterHandler { ) -> impl Future>; } -/// Lets the application pass `&on_off_handler` (which it also keeps -/// for the normal data-model handler chain) into the scenes registry -/// without moving it. The trait's associated const + static -/// `is_scenable_attribute` delegate cleanly through the reference. +/// Lets the application pass `&handler` into the scenes registry +/// without moving it (the same `&handler` is also kept in the +/// data-model chain). Delegates every method through the reference. impl SceneClusterHandler for &T { const CLUSTER_ID: ClusterId = T::CLUSTER_ID; @@ -240,49 +161,27 @@ impl SceneClusterHandler for &T { } } -/// A tuple-recursive composition of [`SceneClusterHandler`]s, mirroring -/// the convention used by [`crate::dm::ChainedHandler`]. -/// -/// Terminated by `()`; one cluster registers as `(impl, ())`; multiple -/// register as `(a, (b, (c, ())))`. The macro-free spelling is -/// intentionally verbose for now — a `scene_clusters!` macro can be -/// layered on later. +/// Tuple-recursive composition of [`SceneClusterHandler`]s. Mirrors +/// [`crate::dm::ChainedHandler`]: terminated by `()`, one cluster +/// registers as `(impl, ())`, multiple as `(a, (b, (c, ())))`. pub trait SceneClusters { - /// Walk the registry, emitting one `ExtensionFieldSetStruct` per - /// cluster whose handler reports `endpoint_id() == endpoint_id`. - /// - /// `parent` is a raw [`TLVBuilderParent`] (e.g. wrapping a - /// [`crate::utils::storage::WriteBuf`] over the destination - /// buffer) — *not* an `ExtensionFieldSetStructArrayBuilder`. Each - /// cluster's EFS struct is emitted directly into the parent - /// (`start_struct(Anonymous) … end_container`), with no outer - /// `start_array` byte written. The caller is responsible for - /// writing the trailing `0x18` array terminator after this - /// returns. This keeps the captured wire form aligned with - /// [`SceneEntry::extension_fields`]'s "contents + 0x18" storage - /// shape without needing an extra `+ 1` byte to absorb a leading - /// control byte. + /// Emit one EFS struct per registered cluster whose + /// `endpoint_id()` matches `endpoint_id`. EFS structs are written + /// directly into the parent without an outer array wrapper; the + /// caller is responsible for the trailing array terminator. fn capture(&self, endpoint_id: EndptId, parent: P) -> Result; - /// Walk the registry looking for `cluster_id`. Returns: - /// - /// - `Some(true)` — `cluster_id` is registered and - /// `attribute_id` is scenable on that cluster (per - /// [`SceneClusterHandler::is_scenable_attribute`]). - /// - `Some(false)` — `cluster_id` is registered but - /// `attribute_id` is **not** scenable (`AddScene` MUST - /// reject with `INVALID_COMMAND`). - /// - `None` — `cluster_id` is not registered with the - /// Scenes handler. `AddScene` treats this as lenient (store - /// the bytes; `RecallScene` will silently skip them on - /// replay), matching chip's behaviour on a firmware - /// downgrade that drops a previously-scenable cluster. + /// `Some(true)` if `cluster_id` is registered and `attribute_id` + /// is scenable on it; `Some(false)` if registered but + /// non-scenable (`AddScene` returns `INVALID_COMMAND`); `None` + /// if `cluster_id` is not registered (lenient — store the bytes, + /// silently skip on recall; matches chip's firmware-downgrade + /// behaviour). fn check_scenable(&self, cluster_id: ClusterId, attribute_id: AttrId) -> Option; /// Find the registered cluster matching `(cluster_id, endpoint_id)` - /// and let it apply `avp_list`. Returns `Ok(true)` if a cluster - /// handled it, `Ok(false)` if no registered cluster matches (the - /// entry is silently skipped, matching chip's behavior). + /// and let it apply `avp_list`. Returns `Ok(true)` if handled, + /// `Ok(false)` if no registered cluster matches. fn apply( &self, ctx: &C, @@ -329,10 +228,6 @@ where fn capture(&self, endpoint_id: EndptId, parent: P) -> Result { let parent = if self.0.endpoint_id() == endpoint_id { - // Open this cluster's ExtensionFieldSetStruct directly on - // the parent (no outer array wrapper), hand the inner - // AVP-array builder to the cluster impl, then close both - // containers and continue down the chain. let efs = ExtensionFieldSetStructBuilder::new(parent, &TLVTag::Anonymous)?; let efs = efs.cluster_id(H::CLUSTER_ID)?; let avp_array = efs.attribute_value_list()?; @@ -364,26 +259,14 @@ where } } -// `SceneContext` and `CaptureReply` (the IM-routed cross-cluster -// read/invoke shim) were removed in favour of direct method calls on -// the typed cluster handler — see the module-level doc comment. - -// --------------------------------------------------------------------- -// Builder ergonomics — push_u8 / push_u16 etc. on the codegen'd AVP -// array builder so capture impls read as -// `avp_array.push_u8(attr_id, v)?` instead of carrying an external -// helper. Inherent impls are legal cross-module because the type is in -// the same crate (rs-matter), generated from the Scenes IDL. -// --------------------------------------------------------------------- - +/// Ergonomics shims on the codegen'd AVP array builder so `capture` +/// impls can write `avp_array.push_u8(attr_id, v)?` instead of +/// spelling out the codegen builder's 9-state push chain. impl

AttributeValuePairStructArrayBuilder

where P: TLVBuilderParent, { - /// Push one `AttributeValuePairStruct { attributeID, - /// valueUnsigned8 }` element. Wraps the codegen builder's 9-state - /// push chain so callers don't have to spell out 8 - /// `value_*(None)?` hops manually. + /// Push one AVP element with a `valueUnsigned8` value. pub fn push_u8(self, attr_id: AttrId, value: u8) -> Result { self.push()? .attribute_id(attr_id)? @@ -398,8 +281,7 @@ where .end() } - /// Push one `AttributeValuePairStruct { attributeID, - /// valueUnsigned16 }` element. + /// Push one AVP element with a `valueUnsigned16` value. pub fn push_u16(self, attr_id: AttrId, value: u16) -> Result { self.push()? .attribute_id(attr_id)? @@ -415,35 +297,24 @@ where } } -/// One scene record. Stores both the metadata (group/scene/transition) -/// and the wire-form `ExtensionFieldSetStructs` blob captured on -/// `AddScene` / `StoreScene` and replayed on `ViewScene` / -/// `RecallScene` / `CopyScene`. -/// -/// `M` is the per-scene blob capacity (see [`MAX_EXT_FIELDS_LEN`] for -/// the default rationale). +/// One scene record. Holds the metadata (fabric / endpoint / group +/// / scene / transition) plus the wire-form `ExtensionFieldSetStructs` +/// blob captured on `AddScene` / `StoreScene` and replayed on +/// `ViewScene` / `RecallScene` / `CopyScene`. `M` is the per-scene +/// blob capacity — see [`MAX_EXT_FIELDS_LEN`]. #[derive(Debug)] #[cfg_attr(feature = "defmt", derive(defmt::Format))] pub struct SceneEntry { - /// Fabric index that owns this scene (spec reserves `0` for - /// "no fabric" / PASE; an installed fabric is always non-zero). fab_idx: NonZeroU8, - /// Endpoint this scene lives on. endpoint_id: EndptId, - /// Group ID (0 ⇒ "no group" / per-endpoint). group_id: u16, - /// Scene ID within the group. scene_id: SceneId, - /// Transition time encoded per spec (1/10 s units). + /// Transition time in milliseconds (1..=`MAX_TRANSITION_TIME_MS`). transition_time: u32, - /// Serialized `ExtensionFieldSetStructs` array payload — what the - /// controller passed on `AddScene` (or what `StoreScene` - /// captured). Stored as the array container's *value* bytes (the - /// TLV element payload between the start-array control byte and - /// the end-of-container terminator; see - /// [`crate::tlv::TLVElement::raw_value`]). On `ViewScene` we - /// splice it back out at the response tag. Empty ⇒ no captured - /// fields (echoed as absent). + /// EFS array contents (between the array-control byte and the + /// terminator — what [`crate::tlv::TLVElement::raw_value`] + /// returns). Spliced back at the response tag by `ViewScene` / + /// `CopyScene`. Empty ⇒ no captured fields. extension_fields: Vec, } @@ -461,16 +332,10 @@ impl SceneEntry { && self.scene_id == scene_id } - /// In-place initializer used by [`super::ScenesHandler::upsert_scene`] to - /// stamp a fresh row directly into the slot inside the scene - /// table — avoiding the `M`-byte stack copy that - /// `extension_fields: Vec` would otherwise incur if - /// `SceneEntry` were constructed by value first. - /// - /// The `extension_fields` Vec is initialized empty; the caller of - /// [`super::ScenesHandler::upsert_scene`] supplies a closure that - /// fills it in place (typically by `extend_from_slice` from a - /// caller-owned slice). + /// In-place initializer that avoids the `M`-byte stack copy a + /// by-value `SceneEntry` would otherwise incur. The `extension_fields` + /// `Vec` is initialized empty; the caller fills it in place via + /// [`super::ScenesHandler::upsert_scene`]'s closure. fn init( fab_idx: NonZeroU8, endpoint_id: EndptId, @@ -489,25 +354,14 @@ impl SceneEntry { } } -/// Per-fabric "last recalled scene" pointer feeding +/// Per-fabric "last recalled scene" pointer backing /// `FabricSceneInfo.CurrentScene` / `CurrentGroup` / `SceneValid`. /// -/// The entry persists once a fabric has interacted with scenes (so -/// `FabricSceneInfo` keeps emitting a row for it even after its only -/// scene is removed) — `valid` carries `SceneValid` directly. -/// `TestScenesMultiFabric` step 36 asserts this lifecycle: TH2 removes -/// its only scene and then reads `FabricSceneInfo`, expecting -/// `SceneCount=0` with `CurrentScene`/`CurrentGroup` preserved and -/// `SceneValid=false`. -/// -/// `endpoint_id` records the endpoint the scene was recalled on so the -/// [`SceneInvalidator`] callback (fired by scenable cluster handlers -/// when their state changes) can flip `valid → false` per-endpoint -/// without touching other endpoints' recalled scenes. -/// -/// `FromTLV` / `ToTLV` are derived (the type has no const generics, -/// unlike [`SceneEntry`]) — the persisted shape is a struct with -/// context-tagged fields auto-numbered 0..4 in source order. +/// The slot persists once a fabric has interacted with scenes — so +/// `FabricSceneInfo` keeps emitting a row for it even after the only +/// scene is removed — and `valid` carries `SceneValid` directly. +/// `endpoint_id` lets [`SceneInvalidator`] flip `valid → false` +/// per-endpoint without touching other endpoints' recalled scenes. #[derive(Debug, Clone, Copy, FromTLV, ToTLV)] #[cfg_attr(feature = "defmt", derive(defmt::Format))] struct CurrentScene { @@ -518,17 +372,14 @@ struct CurrentScene { valid: bool, } -/// All mutable Scenes state, held behind a single mutex via -/// [`ScenesState`]. Grouped so the cluster handler takes exactly one -/// lock per operation — mirrors the `OnOffState` / `Mutex>` -/// shape used elsewhere in `rs-matter`. +/// All mutable Scenes state, held behind a single mutex inside +/// [`ScenesState`]. struct ScenesStateInner { - /// The scene table, keyed by `(fab_idx, endpoint_id, group_id, scene_id)`. + /// Scene table keyed by `(fab_idx, endpoint_id, group_id, scene_id)`. table: Vec, N>, - /// Bounded by `N` for storage symmetry; in practice one slot per - /// fabric. Absent for a given `fab_idx` ⇒ `SceneValid = false`. + /// One slot per fabric that has touched scenes. current_per_fabric: Vec, - /// Bookkeeping bump for the `FabricSceneInfo` reader. + /// Bumped on every state mutation that affects `FabricSceneInfo`. info_dataver: u32, } @@ -541,9 +392,6 @@ impl ScenesStateInner { } } - /// In-place initializer — preferred when stamping into uninit - /// memory (e.g. `StaticCell::uninit().init_with(...)`). Mirrors - /// the same pattern used by `Fabrics`, `MatterState`, etc. fn init() -> impl Init { init!(Self { table <- Vec::init(), @@ -557,19 +405,17 @@ impl ScenesStateInner { } } -/// Caller-owned per-device Scenes state. +/// Caller-owned per-device Scenes state — the scene table plus +/// per-fabric `CurrentScene` bookkeeping. Shared across all endpoints +/// exposing the cluster. /// /// Const generics: -/// - `N` — scene-table capacity (rows across all fabrics + endpoints). -/// - `M` — per-scene `ExtensionFieldSetStructs` blob capacity in -/// bytes. Defaults to [`MAX_EXT_FIELDS_LEN`] (128). Bump it when -/// you wire ColorControl into a multi-feature deployment whose -/// captured EFS exceeds the default budget. Total static RAM for -/// the scene table is `N * (M + small overhead)`. -/// -/// Shared across all endpoints that expose the cluster. Internally -/// a single [`Mutex`] over a [`RefCell`] — every handler operation -/// takes one lock and mutates the inner table directly. +/// - `N` — total scene-table capacity (rows across all fabrics + +/// endpoints). +/// - `M` — per-scene EFS blob capacity in bytes. Bump it when +/// wiring ColorControl into a multi-feature deployment whose +/// captured EFS exceeds [`MAX_EXT_FIELDS_LEN`]. Total static RAM +/// is roughly `N * (M + overhead)`. pub struct ScenesState { inner: Mutex>>, } @@ -606,25 +452,24 @@ impl Default for ScenesState { } } -/// Notified by scenable cluster handlers (OnOff, LevelControl, -/// ColorControl, …) when a scenable attribute on an endpoint changes -/// out from under a previously-recalled scene. Per Matter App Cluster -/// spec §1.4.6.5, that mutation invalidates `SceneValid` for every -/// fabric whose recalled scene lives on that endpoint. +/// Notified by scene-aware cluster handlers when a scenable +/// attribute on an endpoint changes outside a scene recall. Per +/// Matter Core Spec, such a mutation invalidates `SceneValid` for +/// every fabric whose recalled scene lives on that endpoint. /// -/// `ScenesState` implements this trait directly. Wire the -/// implementation into a scene-able cluster handler at construction -/// (e.g. `OnOffHandler::with_scene_invalidator(&scenes_state)`); the -/// handler then calls -/// [`Self::scenable_attribute_changed`] at every internal mutation -/// site for the cluster's scenable attribute set. +/// [`ScenesState`] implements this trait. Wire the impl into a +/// scene-aware cluster handler via its `with_scene_invalidator` +/// builder; the handler then calls +/// [`Self::scenable_attribute_changed`] from every command-driven +/// mutation site (scene-driven mutations skip the call so SceneValid +/// stays true through the recall). /// /// Implementations MUST be cheap and re-entrant — they run inline on /// the command-handler path. pub trait SceneInvalidator { - /// Flip `SceneValid → false` for every recalled scene that lives - /// on `endpoint_id`, across all fabrics. No-op when no fabric has - /// a scene recalled on that endpoint. + /// Flip `SceneValid → false` for every recalled scene on + /// `endpoint_id`, across all fabrics. No-op when no fabric has a + /// scene recalled there. fn scenable_attribute_changed(&self, endpoint_id: EndptId); } @@ -651,21 +496,15 @@ impl SceneInvalidator for ScenesState { } } -// --------------------------------------------------------------------- -// TLV round-trip used by the persistence layer. -// -// The whole [`ScenesStateInner`] is persisted as a single TLV struct -// under [`SCENES_KEY`] — the cross-fabric scene table plus the -// per-fabric `CurrentScene` bookkeeping. `info_dataver` is *not* -// persisted: the public `Dataver` on the handler is re-randomized at -// boot anyway, so any client cache will already see a new dataver and -// re-fetch. +// TLV round-trip used by the persistence layer. The whole +// `ScenesStateInner` is persisted as a single TLV struct under +// `SCENES_KEY`. `info_dataver` is not persisted (the public `Dataver` +// is re-randomized at boot anyway). // -// Hand-rolled rather than `#[derive(FromTLV, ToTLV)]` because the inner -// types are const-generic and the macro doesn't yet support that -// (same reason `EndpointLabels` in `user_label.rs` is hand-rolled). -// The persisted wire shape is private to this module; it only needs to -// round-trip between successive runs of the same firmware. +// Hand-rolled because the inner types are const-generic and the +// derive macro doesn't yet support that. The on-disk shape is +// private to this module and only needs to round-trip across +// successive runs of the same firmware. impl ToTLV for SceneEntry { fn to_tlv(&self, tag: &TLVTag, mut tw: W) -> Result<(), Error> { @@ -675,17 +514,16 @@ impl ToTLV for SceneEntry { self.group_id.to_tlv(&TLVTag::Context(2), &mut tw)?; self.scene_id.to_tlv(&TLVTag::Context(3), &mut tw)?; self.transition_time.to_tlv(&TLVTag::Context(4), &mut tw)?; - // The captured EFS bytes go on the wire as a single octet - // string, rather than as an array-of-u8 (which is what the - // blanket `Vec: ToTLV` would emit). + // EFS bytes go on the wire as one octet string — not an + // array-of-u8 (which is what the blanket `Vec: ToTLV` + // would emit). tw.str(&TLVTag::Context(5), &self.extension_fields)?; tw.end_container() } fn tlv_iter(&self, _tag: TLVTag) -> impl Iterator, Error>> { - // Only `Persist::store_tlv` exercises persistence and it goes - // through `to_tlv` above. Leave `tlv_iter` empty to satisfy the - // trait bound without dragging extra machinery in. + // Persistence goes through `to_tlv`; this is just here to + // satisfy the trait bound. core::iter::empty() } } @@ -728,7 +566,6 @@ impl<'a, const N: usize, const M: usize> FromTLV<'a> for ScenesStateInner Ok(Self { table: Vec::, N>::from_tlv(&s.ctx(0)?)?, current_per_fabric: Vec::::from_tlv(&s.ctx(1)?)?, - // Always boot at 0 — see the persistence note above. info_dataver: 0, }) } @@ -738,20 +575,16 @@ impl ScenesState { /// Re-hydrate the scene table and per-fabric `CurrentScene` /// bookkeeping from `store` under [`SCENES_KEY`]. Call once at /// application startup, before exposing the data model to - /// commissioners, so subsequent `RecallScene` / `GetSceneMembership` - /// commands see scenes that were stored before the last reboot. - /// - /// Missing key (first boot, or persistence cleared) is not an - /// error — the registry stays empty. + /// commissioners. A missing key (first boot or cleared + /// persistence) leaves the registry empty. pub async fn load_persist( &self, mut store: S, buf: &mut [u8], ) -> Result<(), Error> { let Some(data) = store.load(SCENES_KEY, buf)? else { - // No prior persistence — reset to empty so re-calling - // `load_persist` after a `remove` of the key behaves - // deterministically. + // Reset to empty so a `load_persist` after a key + // `remove` is deterministic. self.with(|inner| { inner.table.clear(); inner.current_per_fabric.clear(); @@ -773,9 +606,9 @@ impl ScenesState { Ok(()) } - /// Serialise the current state to `ctx.kv()` under [`SCENES_KEY`]. - /// Called from every mutating handler path after the in-memory - /// change is committed. + /// Persist the current state under [`SCENES_KEY`]. Called from + /// every mutating handler path after the in-memory change is + /// committed. fn store_persist(&self, ctx: &C) -> Result<(), Error> { let mut persist = Persist::new(ctx.kv()); @@ -790,27 +623,21 @@ impl ScenesState { /// Scenes Management cluster handler. /// -/// Generic over a tuple-recursive registry `R: SceneClusters` that -/// names which application-level clusters participate in scene -/// capture / recall on this device. Construct as: +/// Generic over a tuple-recursive registry `R: SceneClusters` of the +/// scene-aware cluster handlers that participate in scene capture / +/// recall on this device: /// /// ```ignore -/// use rs_matter::dm::clusters::app::on_off::OnOffSceneClusterHandler; -/// use rs_matter::dm::clusters::app::level_control::LevelControlSceneClusterHandler; -/// /// let scenes = ScenesHandler::new( /// dataver, /// &scenes_state, -/// (OnOffSceneClusterHandler, (LevelControlSceneClusterHandler, ())), +/// (&on_off_handler, (&level_control_handler, ())), /// ); /// ``` /// -/// The default `R = ()` constructs a Scenes handler with **no** -/// scene-able clusters — useful for tests / certification of the -/// table-management commands (Add/View/Remove/RemoveAll/GetSceneMembership/ -/// CopyScene) in isolation. `M` mirrors the same parameter on -/// [`ScenesState`] (per-scene blob capacity, defaults to -/// [`MAX_EXT_FIELDS_LEN`]). +/// The default `R = ()` builds a Scenes handler with no scene-aware +/// clusters — useful for testing the table-management commands in +/// isolation. `M` matches the same const generic on [`ScenesState`]. pub struct ScenesHandler<'a, const N: usize, R = (), const M: usize = MAX_EXT_FIELDS_LEN> where R: SceneClusters, @@ -840,22 +667,10 @@ where ctx.exchange().accessor()?.fab_idx() } - /// Per-fabric remaining-capacity estimate used by both - /// `GetSceneMembership::Capacity` and - /// `FabricSceneInfo::RemainingCapacity`. Per chip's reference - /// implementation (and the `Test_TC_S_*` certification suites), - /// the formula is **`(N - 1) / 2 − scenes_in_this_fabric`**: - /// `N - 1` slack reserves one row for inter-fabric arbitration, - /// `/ 2` splits the remaining budget evenly across the - /// (typically two) fabrics the spec expects to share the table. - /// - /// Result is then clamped by the *total free slots* across all - /// fabrics — once fab A and B have consumed their shares, fab C's - /// remaining must drop below its `(N-1)/2` allotment as the global - /// budget shrinks. `TestScenesMaxCapacity` step that asserts - /// `RemainingCapacity == 1` after fabs 1+2 fill 14 of 16 slots - /// catches the unclamped version. Final value is clamped to - /// `0xFF` to fit the u8 wire field. + /// Per-fabric `RemainingCapacity` for `GetSceneMembership` and + /// `FabricSceneInfo`. Formula matches chip's reference: + /// `(N - 1) / 2 - scenes_in_fab`, clamped by the total free + /// slots across all fabrics, then clamped to `u8`. fn remaining_capacity_for_fab(inner: &ScenesStateInner, fab_idx: NonZeroU8) -> u8 { let per_fab_budget = N.saturating_sub(1) / 2; let used = inner.table.iter().filter(|e| e.fab_idx == fab_idx).count(); @@ -864,12 +679,10 @@ where per_fab_remaining.min(global_remaining).min(0xFF) as u8 } - /// Check whether `group_id` is present in the Groups cluster's - /// Group Table for `(fab_idx, endpoint_id)`. Every group-aware - /// Scenes command must reject with `SC_INVALID_COMMAND` when this - /// returns `false`. `group_id == 0` is treated as "always valid" - /// matching the spec's special handling of the reserved no-group - /// ID. + /// `true` if `group_id` is present in the Groups cluster's Group + /// Table for `(fab_idx, endpoint_id)`. `group_id == 0` ("no + /// group") is always valid. Group-aware Scenes commands return + /// `INVALID_COMMAND` on `false`. fn group_in_table( ctx: &C, fab_idx: NonZeroU8, @@ -889,12 +702,9 @@ where }) } - /// Stamp `(endpoint, group, scene)` as the current recalled scene - /// for this fabric with `SceneValid = true`. Bumps - /// `FabricSceneInfo` dataver. Operates on already-locked inner - /// state. The `endpoint_id` lets the [`SceneInvalidator`] flip - /// `valid → false` per-endpoint when scenable attributes change - /// on that endpoint (see `TestScenesFabricSceneInfo` step 25). + /// Stamp `(endpoint, group, scene)` as the recalled scene for + /// `fab_idx` with `SceneValid = true`. Bumps `FabricSceneInfo` + /// dataver. Operates on already-locked inner state. fn remember_current( inner: &mut ScenesStateInner, fab_idx: NonZeroU8, @@ -926,18 +736,12 @@ where inner.bump_info_dataver(); } - /// Drop the recalled-scene tracker for `fab_idx` **only** when its - /// stored `(group_id, scene_id)` matches the one passed in — i.e. - /// when the operation that just happened (`AddScene` / - /// `StoreScene` / `RemoveScene` / `CopyScene` single-target case) - /// actually targeted the currently-recalled scene. Other scenes - /// changing leaves `SceneValid` alone, per Matter App Cluster - /// spec §1.4.6.5: - /// > Successful `CopyScene` or `AddScene` operations SHALL - /// > preserve the `SceneValid` attribute when the affected scene - /// > is not the currently recalled scene. - /// - /// Operates on already-locked inner state. + /// Flip `SceneValid → false` for `fab_idx` only when the + /// recalled scene's `(group, scene)` matches the operation's + /// target — i.e. an `AddScene` / `StoreScene` / `RemoveScene` / + /// single-target `CopyScene` that actually touches the recalled + /// scene. Other-scene operations leave `SceneValid` alone, per + /// Matter Core Spec. Operates on already-locked inner state. fn invalidate_current_if_match_scene( inner: &mut ScenesStateInner, fab_idx: NonZeroU8, @@ -956,11 +760,10 @@ where } } - /// Flip `SceneValid → false` for `fab_idx` when its remembered - /// `group_id` matches — used by `RemoveAllScenes(group_id)` and - /// the `COPY_ALL` mode of `CopyScene` (both of which can affect - /// any scene in the group). The slot keeps `CurrentScene` / - /// `CurrentGroup` populated for the next read so the fabric stays + /// Flip `SceneValid → false` for `fab_idx` when the recalled + /// scene's group matches the operation's group — used by + /// `RemoveAllScenes` and `COPY_ALL` `CopyScene`. The slot keeps + /// `CurrentScene` / `CurrentGroup` populated so the fabric stays /// "known" in `FabricSceneInfo`. fn invalidate_current_if_match_group( inner: &mut ScenesStateInner, @@ -979,17 +782,12 @@ where } } - /// Internal copy helper — runs against an already-locked - /// [`ScenesStateInner`]. Returns the IM status code (0 on success). - /// - /// In-place index-walk: no scratch buffer. `Vec::push` - /// always appends at the end, so an upsert that has to push a new - /// destination row lands at an index strictly greater than the - /// current source index — earlier-index iteration stays valid. - /// Pushed rows live in `group_to`, never match the `group_from` - /// filter, so the loop converges. Worst case is O(N²) on the - /// inner `position` lookup, which is fine for the small `N` this - /// cluster carries. + /// Body of `CopyScene` against an already-locked + /// [`ScenesStateInner`]. Returns the IM status code (0 on + /// success). In-place index walk: pushes destination rows go to + /// `group_to`, never match the `group_from` filter, so the loop + /// converges. Worst case is O(N²) on the inner `position` lookup, + /// which is fine for the small `N` this cluster carries. #[allow(clippy::too_many_arguments)] fn copy_scenes_inner( inner: &mut ScenesStateInner, @@ -1001,14 +799,10 @@ where scene_to: SceneId, copy_all: bool, ) -> u8 { - // Per-fab capacity gate up front: when the originating fabric - // is already at its `(N-1)/2` allotment (or the global table - // is full), the copy MUST be rejected with `INSUFFICIENT_SPACE` - // even when the destination scene already exists and would - // otherwise be a no-growth overwrite. Mirrors chip's reference - // handler (`TestScenesMaxCapacity` step 56 asserts this: TH2 - // is at-cap and copies onto an already-existing destination, - // but the test expects `0x89` regardless). + // Per-fab capacity gate up front: at-cap rejects the copy + // even when the destination already exists and would + // otherwise be a no-growth overwrite. Matches chip's + // reference. if Self::remaining_capacity_for_fab(inner, fab_idx) == 0 { return SC_INSUFFICIENT_SPACE; } @@ -1023,10 +817,8 @@ where && (copy_all || src.scene_id == scene_from); if src_matches { found_source = true; - // Copy the scalars + clone the extension-fields blob - // out so we can re-borrow the table mutably for the - // upsert. The clone is a `MAX_EXT_FIELDS_LEN`-sized - // stack value (~128 B), released after each iteration. + // Clone the source row's scalars + EFS blob so the + // table can be re-borrowed mutably for the upsert. let src_scene_id = src.scene_id; let src_transition_time = src.transition_time; let src_extension_fields = src.extension_fields.clone(); @@ -1041,9 +833,10 @@ where inner.table[pos].transition_time = src_transition_time; inner.table[pos].extension_fields = src_extension_fields; } else { + // Re-check per-fab capacity for each new push — + // earlier pushes in this loop may have exhausted + // the fabric's budget. if Self::remaining_capacity_for_fab(inner, fab_idx) == 0 { - // Reject when the originating fabric - // has reached its per-fab budget ((N-1)/2 entries. return SC_INSUFFICIENT_SPACE; } @@ -1063,8 +856,7 @@ where } } - // Single-scene mode copies exactly one entry — bail - // out before we walk the rest of the table. + // Single-scene mode copies exactly one entry. if !copy_all { break; } @@ -1072,16 +864,12 @@ where idx += 1; } - // Source must exist for the operation to succeed (per the - // `CopyScene` command's effect-on-receipt). if !found_source { return SC_NOT_FOUND; } - // Only invalidate `CurrentScene` if this copy actually touched - // the currently-recalled scene. Single-target mode targets - // exactly `(group_to, scene_to)`; `COPY_ALL` mode targets the - // whole destination group. + // Invalidate `CurrentScene` only if the copy actually + // touched the recalled scene. if copy_all { Self::invalidate_current_if_match_group(inner, fab_idx, group_to); } else { @@ -1090,22 +878,11 @@ where 0 } - // ----------------------------------------------------------------- - // Handler bodies. - // - // The trait-required methods in the `ClusterAsyncHandler` impl - // below are tiny `fn -> impl Future` wrappers that delegate to - // these via `ready(self.foo(...))`. Keeping the real logic - // synchronous (a) lets us use `?` freely, (b) skips the - // `async fn` state-machine codegen, and (c) avoids closure - // captures in the wrappers. Three small wins that add up on - // flash-constrained targets. - // - // `store_scene` is the one exception — it actually `.await`s - // because it issues cross-cluster attribute reads through - // `ctx.handler().read()`. The wrapper for that one just `.await`s - // this method directly. - // ----------------------------------------------------------------- + // Handler bodies. The `ClusterAsyncHandler` impl below wraps + // these in `fn -> impl Future { ready(self.foo(...)) }` to keep + // the real logic synchronous — saves the `async fn` state-machine + // codegen, matters on flash-constrained targets. `store_scene` + // is the exception (cross-cluster attribute reads need `.await`). fn read_fabric_scene_info( &self, @@ -1115,8 +892,11 @@ where let endpoint_id = ctx.attr().endpoint_id; let accessor_fab_idx = ctx.exchange().accessor()?.fab_idx()?; - // Snapshot the relevant scalars under a single lock, then build - // the response outside the lock. + // Snapshot the relevant scalars under a single lock, then + // build the response outside the lock. A fabric gets a row + // once it has at least one scene OR has ever recalled one + // (the `current_per_fabric` slot persists past invalidation + // so the row stays present after the last scene is removed). let (has_state, scene_count, cur_group, cur_scene, valid, remaining) = self.state.with(|inner| { let count = inner @@ -1129,18 +909,10 @@ where .iter() .find(|c| c.fab_idx == accessor_fab_idx) .copied(); - // A fabric is "known" to the cluster — i.e. gets a - // `FabricSceneInfo` row — once it owns at least one - // scene OR has ever recalled one. `current_per_fabric` - // entries persist past invalidation (carrying - // `valid=false`) so the row stays present after the - // last scene is removed (`TestScenesMultiFabric` - // step 36). let has_state = count > 0 || current.is_some(); - // When a row IS emitted, `CurrentScene` / - // `CurrentGroup` are always populated — set to 0 when - // the fabric has never recalled a scene (i.e. no - // `current` slot at all). + // `CurrentScene` / `CurrentGroup` are always + // populated when a row is emitted — 0 when the + // fabric has never recalled a scene. let (g, s, v) = match current { Some(c) => (Some(c.group_id), Some(c.scene_id), c.valid), None => (Some(0u16), Some(0u8), false), @@ -1188,11 +960,8 @@ where let scene_id = request.scene_id()?; let transition_time = request.transition_time()?; - // Spec: `CONSTRAINT_ERROR` for the reserved `SceneID = 0xFF`, - // and also for `TransitionTime` exceeding the spec maximum - // (`Test_TC_S_2_2` steps 8d/8e). Both are checked before the - // group-table existence check so a bad request shape is - // rejected even if the target group is absent. + // Bad request shape (reserved scene id or oversized + // transition) takes precedence over the group-table check. if scene_id == RESERVED_SCENE_ID || transition_time > MAX_TRANSITION_TIME_MS { return response .status(SC_CONSTRAINT_ERROR)? @@ -1201,8 +970,6 @@ where .end(); } - // Spec: `INVALID_COMMAND` when `group_id != 0` is absent from - // the Groups cluster's Group Table for this endpoint. if !Self::group_in_table(ctx, fab_idx, endpoint_id, group_id)? { return response .status(SC_INVALID_COMMAND)? @@ -1211,36 +978,22 @@ where .end(); } - // Capture the `ExtensionFieldSetStructs` array payload from the - // request — store the *value* bytes (contents-plus-terminator - // of the array container), which is what `ViewScene` and - // `CopyScene` will splice back out at the relevant response - // tag. The codegen parser at context-tag 4 errors if the field - // is missing; tolerate that by treating it as an empty blob. - // Scene names are accepted on the wire (codegen parses them) - // but not stored — see the SceneNames feature note in the - // module docs. - // - // `upsert_scene`'s fill closure copies the request's raw EFS - // bytes directly into the table slot's `extension_fields` - // Vec, skipping an intermediate stack-allocated Vec. + // EFS array payload — stored as the array's value bytes + // (between control byte and terminator) so `ViewScene` / + // `CopyScene` can splice it back at the response tag. A + // missing field is treated as empty. Scene names are + // accepted on the wire but not stored. let efs_array_opt = request.extension_field_set_structs().ok(); let raw = match efs_array_opt { Some(ref array) => array.element().raw_value()?, None => &[], }; - // Spec-conformance check: every AVP in the EFS payload whose - // `cluster_id` is registered with this Scenes handler must - // reference a scenable attribute on that cluster. Mixing in an - // unscenable attribute MUST be rejected with `INVALID_COMMAND` - // (Matter App Cluster spec §1.4.7.1; exercised by - // `Test_TC_S_2_2` step 8g). - // - // For unregistered clusters we stay lenient (silently store the - // bytes) — matches chip's behaviour on firmware downgrades - // where a previously-scenable cluster is dropped from the - // registry. + // Every AVP referencing a registered cluster must be + // scenable on that cluster — otherwise `INVALID_COMMAND`. + // Unregistered clusters are lenient: store the bytes, + // silently skip on recall (matches chip on firmware + // downgrade). if let Some(ref efs_array) = efs_array_opt { for efs in efs_array.iter() { let efs = efs?; @@ -1259,10 +1012,6 @@ where } } - // Insert / replace + invalidate SceneValid for this fabric — all - // under a single lock. Per the `SceneValid` field rules, - // adding/storing a scene that doesn't match the current - // attribute state invalidates SceneValid. let status_code = self.state.with(|inner| { Self::upsert_scene( inner, @@ -1294,28 +1043,16 @@ where .end() } - /// Insert (or replace) one scene record and invalidate the fabric's - /// `CurrentScene` slot. Returns `Ok(0)` on success, or - /// `Ok(SC_INSUFFICIENT_SPACE)` when adding a *new* record would - /// overflow `N`. Errors from `fill` propagate to the caller. - /// - /// `fill` is handed a `&mut` reference to the **in-place** - /// `extension_fields` `Vec` inside the (newly-created or - /// to-be-replaced) `SceneEntry`. This lets callers populate the - /// blob directly into the table slot, skipping the 128 B stack - /// `Vec` an intermediate by-value parameter would have required. - /// - /// Used by both `AddScene` (closure copies the controller-provided - /// EFS payload) and `StoreScene` (closure copies the - /// just-captured-from-attributes EFS payload). + /// Insert (or replace) one scene record. `fill` populates the + /// slot's `extension_fields` `Vec` directly (avoiding an + /// intermediate stack copy of up to `M` bytes). Returns `Ok(0)` + /// on success, `Ok(SC_INSUFFICIENT_SPACE)` when a *new* record + /// would overflow `N`. Errors from `fill` propagate. /// - /// **Atomicity caveat**: on the replace-existing path, the slot's - /// previous `extension_fields` are cleared *before* `fill` runs. - /// If `fill` then errors, the slot is left with an empty blob. - /// Spec doesn't mandate atomicity here, and in-tree callers' fill - /// closures are `extend_from_slice` calls — all-or-nothing per - /// [`Vec::extend_from_slice`] — so partial state never - /// occurs in practice. + /// On the replace-existing path the previous `extension_fields` + /// are cleared before `fill` runs — a `fill` failure leaves the + /// slot with an empty blob (acceptable; in-tree callers use + /// `extend_from_slice` which is all-or-nothing). fn upsert_scene( inner: &mut ScenesStateInner, fab_idx: NonZeroU8, @@ -1342,11 +1079,8 @@ where } else if inner.table.len() >= N { Ok(SC_INSUFFICIENT_SPACE) } else { - // Push an empty entry in place, then let the closure fill - // its `extension_fields` directly. `push_init_unchecked` - // is safe (it only panics when full, and we just checked - // `len < N`); the `Result<(), Infallible>` always - // unwraps. + // `push_init_unchecked` only panics when full, and the + // `else if` above just checked `len < N`. inner .table .push_init_unchecked(SceneEntry::init( @@ -1359,8 +1093,6 @@ where .unwrap(); let pos = inner.table.len() - 1; if let Err(e) = fill(&mut inner.table[pos].extension_fields) { - // Roll back the just-pushed entry so the table is - // pre-call state. let _ = inner.table.pop(); return Err(e); } @@ -1380,7 +1112,7 @@ where let group_id = request.group_id()?; let scene_id = request.scene_id()?; - // Spec: `CONSTRAINT_ERROR` for the reserved `SceneID = 0xFF`. + // `SceneID = 0xFF` is reserved. if scene_id == RESERVED_SCENE_ID { return response .status(SC_CONSTRAINT_ERROR)? @@ -1393,8 +1125,6 @@ where .end(); } - // Spec: `INVALID_COMMAND` when `group_id != 0` is absent from - // the Groups cluster's Group Table for this endpoint. if !Self::group_in_table(ctx, fab_idx, endpoint_id, group_id)? { return response .status(SC_INVALID_COMMAND)? @@ -1407,18 +1137,9 @@ where .end(); } - // Build the response *inside* the lock so we can splice the - // stored `extension_fields` blob (a `&[u8]` borrow into the - // table) without cloning it onto the stack. The TLV builder - // chain is purely synchronous (no `.await`), so holding the - // mutex across the write is fine. - // - // Wire shape is (status, group_id, scene_id, optional - // transition_time, optional scene_name, optional extension - // fields). On NotFound all three optionals are absent; on - // Success transition_time is populated, scene name is empty - // (SceneNames feature disabled), and extension_field_set_structs - // gets the stored blob (or absent if none was supplied). + // Build the response inside the lock so the stored + // `extension_fields` slice can be spliced without cloning. + // The builder chain is sync; holding the mutex is fine. self.state.with(|inner| -> Result { let entry = inner .table @@ -1449,33 +1170,20 @@ where }) } - /// Splice the stored extension-fields blob into the response at - /// the optional field's tag (context 5 for both `ViewScene` and - /// `AddScene` request — same tag number, different wire role). - /// - /// The blob is the array container's *value* bytes - /// (contents-plus-terminator). We emit a fresh `start_array` at - /// the destination tag and then write the stored bytes via - /// `TLVWrite::write_raw_data`. Empty blob ⇒ skip the field - /// entirely via `OptionalBuilder::none`. + /// Splice the stored EFS blob into the response at context tag 5 + /// (the `ExtensionFieldSetStructs` field). The blob is the array + /// container's value bytes (contents + terminator). Empty blob + /// ⇒ skip the field via `OptionalBuilder::none`. fn write_blob_or_none(mut opt: OptionalBuilder, blob: &[u8]) -> Result where P: TLVBuilderParent, Q: TLVBuilder

, { if !blob.is_empty() { - // Tag is hard-coded as the spec field number for both - // `ViewSceneResponse.ExtensionFieldSetStructs` and other - // current call sites; if other tag positions reuse this - // helper, take the tag as an explicit argument. let writer = opt.writer(); writer.start_array(&TLVTag::Context(5))?; writer.write_raw_data(blob.iter().copied())?; } - // `none()` returns the parent without further writes. When the - // blob was non-empty we already emitted the field via the - // writer; when empty we skip the field entirely. Either way - // the surrounding response is well-formed. Ok(opt.none()) } @@ -1490,7 +1198,7 @@ where let group_id = request.group_id()?; let scene_id = request.scene_id()?; - // Spec: `CONSTRAINT_ERROR` for the reserved `SceneID = 0xFF`. + // `SceneID = 0xFF` is reserved. if scene_id == RESERVED_SCENE_ID { return response .status(SC_CONSTRAINT_ERROR)? @@ -1499,8 +1207,6 @@ where .end(); } - // Spec: `INVALID_COMMAND` when `group_id != 0` is absent from - // the Groups cluster's Group Table for this endpoint. if !Self::group_in_table(ctx, fab_idx, endpoint_id, group_id)? { return response .status(SC_INVALID_COMMAND)? @@ -1545,8 +1251,6 @@ where let endpoint_id = ctx.cmd().endpoint_id; let group_id = request.group_id()?; - // Spec: `INVALID_COMMAND` if `group_id != 0` is absent from - // the Groups cluster's Group Table for this endpoint. if !Self::group_in_table(ctx, fab_idx, endpoint_id, group_id)? { return response .status(SC_INVALID_COMMAND)? @@ -1574,16 +1278,9 @@ where response.status(0)?.group_id(group_id)?.end() } - /// `StoreScene` capture + commit. - /// - /// One of two [`ClusterAsyncHandler`] entry points that actually - /// `.await` — it issues cross-cluster attribute reads through - /// `ctx.handler().read()` for every registered - /// [`SceneClusterHandler`] that is present on the host endpoint. - /// The captured `ExtensionFieldSetStructs` blob is built up on a - /// stack buffer (no per-attribute heap allocation, and no IO while - /// the scene-table mutex is held). Only the final upsert briefly - /// acquires the mutex. + /// `StoreScene` capture + commit. Walks the + /// [`SceneClusters`] registry, builds an EFS blob on a stack + /// buffer, and upserts the result into the table. async fn store_scene( &self, ctx: &impl InvokeContext, @@ -1595,7 +1292,7 @@ where let group_id = request.group_id()?; let scene_id = request.scene_id()?; - // Spec: `CONSTRAINT_ERROR` for the reserved `SceneID = 0xFF`. + // `SceneID = 0xFF` is reserved. if scene_id == RESERVED_SCENE_ID { return response .status(SC_CONSTRAINT_ERROR)? @@ -1604,8 +1301,6 @@ where .end(); } - // Spec: `INVALID_COMMAND` when `group_id != 0` is absent from - // the Groups cluster's Group Table for this endpoint. if !Self::group_in_table(ctx, fab_idx, endpoint_id, group_id)? { return response .status(SC_INVALID_COMMAND)? @@ -1614,19 +1309,11 @@ where .end(); } - // Capture the EFS blob on the stack via the cluster registry. - // Doing this *before* the mutex acquire keeps async IO - // (`ctx.handler().read()`) out of the critical section. - // - // [`SceneClusters::capture`] writes EFS struct entries - // directly into the parent (no outer `start_array` byte); we - // append the trailing `0x18` ourselves. The result is exactly - // the "contents + 0x18 terminator" shape that - // [`SceneEntry::extension_fields`] stores — no leading byte - // to strip, no `MAX_EXT_FIELDS_LEN + 1` slack needed. - // Capture is now synchronous: each scene-aware cluster reads - // its own internal state via its `SceneClusterHandler::capture` - // impl. No IM-layer round-trip. + // Capture each scene-aware cluster's EFS struct into the + // scratch buffer. `SceneClusters::capture` writes each struct + // directly (no outer `start_array` byte); we append the + // trailing array terminator ourselves so the result matches + // `SceneEntry::extension_fields`'s "contents + 0x18" shape. let mut scratch = [0u8; M]; let total_len = { let mut wb = WriteBuf::new(&mut scratch); @@ -1637,12 +1324,8 @@ where }; let stored_bytes = &scratch[..total_len]; - // StoreScene reuses AddScene's transition time when overwriting - // an existing record (spec: "If a Scene Table entry with the - // same Scene ID exists, all the fields of the entry shall be - // updated…"). For a fresh record the transition time defaults - // to 0 — the spec leaves the field implementation-defined for - // StoreScene, and chip's reference handler does the same. + // Reuse the prior record's transition_time when overwriting; + // 0 for a fresh record (spec leaves it implementation-defined). let prior_tt = self.state.with(|inner| { inner .table @@ -1669,17 +1352,11 @@ where Ok(()) }, )?; - // StoreScene captures the device's *current* attribute - // state into the table, so the stored scene by definition - // matches the current state. Per Matter App Cluster spec - // §1.4.6.5 / chip's reference, that promotes - // `(group, scene)` to the recalled scene with - // `SceneValid=true` — `TestScenesMultiFabric` / - // `TestScenesMaxCapacity` / `TestScenesFabricSceneInfo` - // all assert this behaviour. `upsert_scene` may have just - // flipped the slot invalid (when overwriting the previously - // recalled entry); the `remember_current` below stamps it - // back to valid with the freshly-stored ID. + // The stored scene by definition matches current state, + // so promote `(group, scene)` to the recalled scene with + // `SceneValid=true` — overriding any invalidation + // `upsert_scene` may have just performed on a re-store + // of the previously-recalled entry. if status == 0 { Self::remember_current(inner, fab_idx, endpoint_id, group_id, scene_id); } @@ -1698,16 +1375,10 @@ where .end() } - /// `RecallScene` parse + apply. - /// - /// Flow: - /// 1. Look up the stored `(transition_time, ext_fields)` snapshot - /// under the mutex; release the mutex. - /// 2. Walk the EFS blob and let the cluster registry apply each - /// `ExtensionFieldSetStruct` entry (see - /// [`SceneClusters::apply`]). - /// 3. Only after apply succeeds, commit `CurrentScene` for this - /// fabric (acquiring the mutex again briefly). + /// `RecallScene` parse + apply: snapshot the stored EFS under + /// the mutex, drop the mutex, walk the EFS blob and let the + /// cluster registry apply each entry, then commit `CurrentScene` + /// for this fabric. async fn recall_scene( &self, ctx: &impl InvokeContext, @@ -1718,45 +1389,32 @@ where let group_id = request.group_id()?; let scene_id = request.scene_id()?; - // `RecallScene` has no response struct (returns `()`), so the - // status must be surfaced as an IM-level `CommandStatusIB.status` - // — i.e. returned via `Err(ErrorCode::*)`. The - // [`ErrorCode`] → [`IMStatusCode`] mapping in `im.rs` turns - // these into the spec-mandated wire codes - // (`ConstraintError = 0x87`, `InvalidCommand = 0x85`, - // `NotFound = 0x8b`). Surfacing them via `set_cluster_status` - // would produce `FAILURE` with a cluster-status side-channel, - // which chip-tool's certification suites correctly reject - // (see `Test_TC_S_2_2` step 4e). - - // Spec: `CONSTRAINT_ERROR` for the reserved `SceneID = 0xFF`. + // `RecallScene` has no response struct (returns `()`), so + // the spec status comes out as an IM-level + // `CommandStatusIB.status` via `Err(ErrorCode::*)`. The + // `ErrorCode → IMStatusCode` map in `im.rs` produces the + // right wire codes; `set_cluster_status` would wrap as + // `FAILURE` and chip-tool's certification suites reject that + // shape. + + // `SceneID = 0xFF` is reserved. if scene_id == RESERVED_SCENE_ID { return Err(ErrorCode::ConstraintError.into()); } - // Spec: `INVALID_COMMAND` when `group_id != 0` is absent from - // the Groups cluster's Group Table for this endpoint. if !Self::group_in_table(ctx, fab_idx, endpoint_id, group_id)? { return Err(ErrorCode::InvalidCommand.into()); } - // RecallScene's request carries an optional+nullable - // transition-time override (ms). Present-and-non-null wins - // over the stored record's transition time; otherwise fall - // back to the stored value. + // The request's optional+nullable `transition_time` override + // wins when present; otherwise fall back to the stored value. let override_tt_ms: Option = request.transition_time()?.and_then(|n| n.into_option()); - // Copy the stored EFS blob into a stack buffer under the lock, - // then drop the lock so the cross-cluster invokes below don't - // run while it's held. - // - // The stored form is "ExtensionFieldSetStruct elements + 0x18 - // terminator" (i.e. what `TLVElement::array().raw_value()` - // returns — see [`SceneEntry`] and `view_scene`). We iterate - // it via [`TLVSequence`] rather than re-attaching the missing - // `start_array(Anonymous)` byte: `TLVSequence` walks raw TLV - // bytes directly and terminates cleanly on the trailing - // `0x18`, so no framing buffer is needed. + // Copy the stored EFS blob into a stack buffer under the + // lock, then drop the lock so cross-cluster work below + // doesn't run with it held. `TLVSequence` walks the stored + // "EFS structs + 0x18 terminator" shape directly — no need + // to re-attach the missing `start_array(Anonymous)` byte. let mut blob = [0u8; M]; let (blob_len, stored_tt_ms) = self.state.with(|inner| -> Result<_, Error> { let Some(e) = inner @@ -1771,9 +1429,6 @@ where Ok((Some(len), Some(e.transition_time))) })?; let (Some(blob_len), Some(stored_tt_ms)) = (blob_len, stored_tt_ms) else { - // Spec: `NOT_FOUND` when no matching scene exists. Surfaced - // at IM level (see the comment above on `ConstraintError` - // / `InvalidCommand`). return Err(ErrorCode::NotFound.into()); }; @@ -1783,10 +1438,8 @@ where let efs = ExtensionFieldSetStruct::new(efs_element?); let cluster_id = efs.cluster_id()?; let avp_list = efs.attribute_value_list()?; - // `apply` returns `false` for unknown cluster IDs — match - // chip's behaviour and silently skip them (the blob may - // have been written by a previous firmware version with a - // different scene-able cluster set). + // Unknown cluster IDs (firmware downgrade that dropped a + // scenable cluster) are silently skipped by `apply`. let _ = self .clusters .apply(ctx, endpoint_id, cluster_id, &avp_list, effective_tt_ms) @@ -1811,11 +1464,8 @@ where let endpoint_id = ctx.cmd().endpoint_id; let group_id = request.group_id()?; - // Spec: `INVALID_COMMAND` when `group_id != 0` isn't in the - // Groups cluster's Group Table on this endpoint. Capacity is - // reported as `null` in that case (per the - // `Test_TC_S_2_2` spec table — `anyOf [fabricCapacity, 0xfe, - // null]`, we pick `null`). + // Reject unknown group with `INVALID_COMMAND`; spec allows + // `null` for `Capacity` on this failure path. if !Self::group_in_table(ctx, fab_idx, endpoint_id, group_id)? { return response .status(SC_INVALID_COMMAND)? @@ -1826,11 +1476,10 @@ where .end(); } - // Build the response directly inside the lock — the TLV - // builder is purely synchronous (no `.await`), so holding the - // lock for the write is cheap. This avoids snapshotting scene - // IDs into a stack `Vec` (could be ~N bytes; - // matters on small-stack MCUs). + // Build the response inside the lock so scene IDs can be + // streamed directly without snapshotting into a stack `Vec`. + // `SceneList` is always emitted on the success path (empty + // when the group has no scenes on this endpoint). self.state.with(|inner| -> Result { let remaining = Self::remaining_capacity_for_fab(inner, fab_idx); @@ -1839,11 +1488,6 @@ where .capacity(Nullable::some(remaining))? .group_id(group_id)?; - // The `SceneList` optional field is *always present* on - // the success path — empty when the group has no scenes - // on this device, populated otherwise. The chip-tool - // certification suites (`Test_TC_S_2_3` step 1f) assert - // the field exists rather than being omitted. let list = resp.scene_list()?.some()?; let list = inner .table @@ -1870,14 +1514,12 @@ where let group_to = request.group_identifier_to()?; let scene_to = request.scene_identifier_to()?; - // Per the `CopyModeBitmap` spec: bit 0 of Mode = COPY_ALL_SCENES - // (copy all scenes from the source group; the From/To SceneIDs - // are ignored when set). + // `CopyModeBitmap` bit 0 = COPY_ALL_SCENES (From/To + // SceneIDs are ignored in this mode). let copy_all = (mode.bits() & 0x01) != 0; - // Spec: `CONSTRAINT_ERROR` for the reserved `SceneID = 0xFF` - // on either `scene_from` or `scene_to` — but only when the - // single-scene mode actually uses them. + // The reserved `SceneID = 0xFF` is only invalid in + // single-scene mode (COPY_ALL ignores those fields). if !copy_all && (scene_from == RESERVED_SCENE_ID || scene_to == RESERVED_SCENE_ID) { return response .status(SC_CONSTRAINT_ERROR)? @@ -1886,9 +1528,6 @@ where .end(); } - // Spec: `INVALID_COMMAND` when EITHER `group_from` or - // `group_to` (when non-zero) is absent from the Groups - // cluster's Group Table for this endpoint. if !Self::group_in_table(ctx, fab_idx, endpoint_id, group_from)? || !Self::group_in_table(ctx, fab_idx, endpoint_id, group_to)? { @@ -1899,8 +1538,6 @@ where .end(); } - // The whole "look up source + copy entries" operation runs - // under one lock so the table can't change mid-copy. let status = self.state.with(|inner| { Self::copy_scenes_inner( inner, @@ -1931,8 +1568,6 @@ impl ClusterAsyncHandler for ScenesHandler<'_ where R: SceneClusters, { - /// FULL_CLUSTER minus the SceneNames feature (we accept the field - /// on the wire but don't persist it — see module docs). const CLUSTER: Cluster<'static> = FULL_CLUSTER; fn dataver(&self) -> u32 { @@ -1991,10 +1626,6 @@ where ready(self.remove_all_scenes(&ctx, &request, response)) } - // `handle_store_scene` actually `.await`s (unlike the other - // ClusterAsyncHandler methods on this handler) because StoreScene - // captures the current values of scene-able attributes on other - // clusters via `ctx.handler().read()` — see [`Self::store_scene`]. async fn handle_store_scene( &self, ctx: impl InvokeContext, @@ -2004,9 +1635,6 @@ where self.store_scene(&ctx, &request, response).await } - // Like `handle_store_scene`, `handle_recall_scene` `.await`s — - // apply is cluster-specific business logic that goes through - // `ctx.handler().invoke()` (see [`Self::recall_scene`]). async fn handle_recall_scene( &self, ctx: impl InvokeContext, @@ -2036,16 +1664,11 @@ where #[cfg(test)] mod tests { - //! Unit tests for the more intricate Scenes Management logic. - //! - //! Focus is on [`ScenesHandler::copy_scenes_inner`] (in-place - //! upsert loop on a shared `inner.table`) and the `CurrentScene` - //! invalidation rules — the pieces with the easiest-to-introduce - //! bugs. - //! - //! Tests run against [`ScenesStateInner`] directly (the - //! handler-visible storage), so we don't have to spin up a full - //! `Matter`/`Exchange`/`InvokeContext` to exercise the algorithm. + //! Unit tests for the Scenes Management internals — primarily + //! [`ScenesHandler::copy_scenes_inner`] (in-place upsert loop + //! over a shared table) and the `CurrentScene` invalidation + //! rules. Tests operate on [`ScenesStateInner`] directly, no + //! `Matter` / `InvokeContext` setup needed. use super::*; @@ -2070,8 +1693,7 @@ mod tests { } } - /// Variant of [`entry`] that stamps an arbitrary extension-fields - /// blob — used by the Phase B.1 copy-preserves-blob test. + /// Variant of [`entry`] that stamps an arbitrary EFS blob. fn entry_with_blob( fab_idx: NonZeroU8, endpoint_id: EndptId, @@ -2140,7 +1762,7 @@ mod tests { .map(|e| e.extension_fields.as_slice()) } - // ---- Phase B.1: extension-fields blob preservation ---- + // ---- extension-fields blob preservation ---- #[test] fn copy_single_scene_preserves_extension_fields_blob() { @@ -2560,17 +2182,9 @@ mod tests { assert!(f2.valid); } - // ---- Phase D: AddScene / StoreScene shared `upsert_scene` path ---- - // - // `AddScene` and `StoreScene` differ only in *where* the EFS blob - // comes from (request payload vs cross-cluster capture) — both - // commit through `upsert_scene` with a fill closure that - // `extend_from_slice`s into the slot's `Vec`. These tests - // exercise the upsert state-machine directly (no async handler - // harness needed). + // ---- AddScene / StoreScene shared `upsert_scene` path ---- /// Fill closure that copies a fixed slice into the slot Vec. - /// Used by `upsert_scene` tests where the contents don't matter. fn fill_with<'a>(blob: &'a [u8]) -> impl FnOnce(&mut Vec) -> Result<(), Error> + 'a { move |ext| { ext.extend_from_slice(blob) @@ -2700,9 +2314,7 @@ mod tests { #[test] fn upsert_preserves_current_scene_when_upsert_targets_a_different_scene() { - // Non-matching upsert MUST leave `SceneValid` intact (the - // spec-conformance regression that `TestScenesFabricSceneInfo` - // step 21 catches when violated). + // Non-matching upsert MUST leave `SceneValid` intact. let mut inner = ScenesStateInner::<8>::new(); ScenesHandler::<8>::remember_current(&mut inner, fab(1), 1, 1, 1); diff --git a/rs-matter/src/persist.rs b/rs-matter/src/persist.rs index 0176ed848..6d36ce0a2 100644 --- a/rs-matter/src/persist.rs +++ b/rs-matter/src/persist.rs @@ -65,15 +65,8 @@ pub const LKG_UTC_KEY: u16 = BINDINGS_KEY + 1; pub const TRUSTED_TIME_SOURCE_KEY: u16 = LKG_UTC_KEY + 1; /// The key used for storing the entire Scenes Management cluster -/// state — the cross-fabric scene table plus the per-fabric -/// "last recalled scene" bookkeeping — as a single TLV blob. -/// Re-persisted on every successful mutation (AddScene / StoreScene / -/// RemoveScene / RemoveAllScenes / CopyScene / RecallScene). -/// -/// The blob's size is bounded by `N * (M + ~14)` bytes plus per-fabric -/// overhead — for the in-tree defaults (`N=16, M=128`) that is well -/// under the 4 KiB KvBlobStore working-buffer assumed by most MCU -/// targets. +/// state (scene table + per-fabric `CurrentScene`) as a single TLV +/// blob. Re-persisted on every successful mutation. pub const SCENES_KEY: u16 = TRUSTED_TIME_SOURCE_KEY + 1; /// A trait representing a key-value BLOB storage. diff --git a/xtask/src/itest.rs b/xtask/src/itest.rs index c8c401ce6..cbe848087 100644 --- a/xtask/src/itest.rs +++ b/xtask/src/itest.rs @@ -481,33 +481,14 @@ pub(crate) const LIGHT_TESTS: &[&str] = &[ ]; /// Scenes Management YAML tests — run against the `scenes_tests` example. -/// -/// Targets the Scenes Management cluster (`0x0062`) on EP1 of a -/// dimmable-light-style endpoint with OnOff + LevelControl + Scenes wired -/// together. The non-`Test_TC_S_*` entries are the chip-tool composite -/// YAML suites (multi-fabric, fabric removal, max capacity, fabric scene -/// info) which exercise less obvious end-to-end behaviour. -/// -/// Listed tests are expected to pass; commented entries with a `TODO` -/// are known-failing or not-yet-verified and tracked separately. pub(crate) const SCENES_TESTS: &[&str] = &[ - // Attribute-read coverage (SceneTableSize, FabricSceneInfo). "Test_TC_S_2_1", - // `Test_TC_S_2_2` reboots the DUT in step 4 and expects scenes - // stored before the reboot to be re-applied by a `RecallScene` - // afterwards — covered by [`ScenesState::load_persist`]. "Test_TC_S_2_2", "Test_TC_S_2_3", "Test_TC_S_2_4", "Test_TC_S_2_5", "Test_TC_S_2_6", - // Effect-on-receipt cross-cluster checks (RecallScene actually - // applies OnOff / LevelControl state via cross-cluster commands). "Test_TC_S_3_1", - // `TestScenesFabricSceneInfo` — drives the - // `SceneInvalidator` drift-detection path: scenable attribute - // mutations on OnOff / LevelControl flip `SceneValid → false` for - // any recalled scene on the affected endpoint. "TestScenesFabricSceneInfo", "TestScenesMultiFabric", "TestScenesFabricRemoval", @@ -536,12 +517,7 @@ pub(crate) enum TestSuite { Camera, /// OnOff + LevelControl, exercising the dimmable_light example. Light, - /// Scenes Management (`0x0062`) — exercises Add/View/Remove/Store/ - /// Recall/GetSceneMembership/CopyScene plus cross-cluster apply - /// (RecallScene actually invokes OnOff/LevelControl commands). Runs - /// against the `scenes_tests` example which wires Scenes onto EP1 - /// of an OnOff+LevelControl device, with the per-cluster - /// `SceneClusterHandler` impls registered. + /// Scenes Management cluster — runs against the `scenes_tests` example. Scenes, /// **Inverted** suite — rs-matter as the **commissioner** driving /// upstream `chip-all-clusters-app` (the device under test). Builds @@ -594,9 +570,6 @@ impl TestSuite { /// Cargo features the example binary must be built with for this suite. pub(crate) fn default_features(&self) -> &'static [&'static str] { match self { - // `scenes_tests` is a pure test binary (like - // `chip_tool_tests` / `camera_tests`) — no `chip-test` - // feature gate. Self::System | Self::SystemPython | Self::SystemYaml | Self::Camera | Self::Scenes => { &[] } From de3218631369e1a170b50581c1d0018c896934cd Mon Sep 17 00:00:00 2001 From: ivmarkov Date: Mon, 1 Jun 2026 08:57:40 +0000 Subject: [PATCH 12/15] Remove no longer used pub use --- rs-matter/src/dm/types.rs | 1 - 1 file changed, 1 deletion(-) diff --git a/rs-matter/src/dm/types.rs b/rs-matter/src/dm/types.rs index cf8a4dbab..90f9dbacd 100644 --- a/rs-matter/src/dm/types.rs +++ b/rs-matter/src/dm/types.rs @@ -24,7 +24,6 @@ pub use dataver::*; pub use endpoint::*; pub use event::*; pub use handler::*; -pub(crate) use handler::{InvokeContextInstance, ReadContextInstance}; pub use metadata::*; pub use node::*; pub use privilege::*; From 12b4b02b9b2bb2af899ae5ea7125a6002a0a9f4e Mon Sep 17 00:00:00 2001 From: ivmarkov Date: Mon, 1 Jun 2026 08:58:35 +0000 Subject: [PATCH 13/15] Fix copyright --- examples/src/bin/scenes_tests.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/examples/src/bin/scenes_tests.rs b/examples/src/bin/scenes_tests.rs index 829b6bcd1..974493a07 100644 --- a/examples/src/bin/scenes_tests.rs +++ b/examples/src/bin/scenes_tests.rs @@ -1,6 +1,6 @@ /* * - * Copyright (c) 2025-2026 Project CHIP Authors + * Copyright (c) 2026 Project CHIP Authors * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. From ddb39c69bd88b6e60621f289012a4cfefcbdc07b Mon Sep 17 00:00:00 2001 From: ivmarkov Date: Mon, 1 Jun 2026 09:46:00 +0000 Subject: [PATCH 14/15] Apply fixes as per the code review feedback --- .../src/dm/clusters/app/color_control.rs | 22 ++++----- rs-matter/src/dm/clusters/scenes.rs | 46 ++++++++++++++----- 2 files changed, 43 insertions(+), 25 deletions(-) diff --git a/rs-matter/src/dm/clusters/app/color_control.rs b/rs-matter/src/dm/clusters/app/color_control.rs index 2ac2aaaed..0f3e7b323 100644 --- a/rs-matter/src/dm/clusters/app/color_control.rs +++ b/rs-matter/src/dm/clusters/app/color_control.rs @@ -152,8 +152,8 @@ impl<'a, H: ColorControlHooks> ColorControlHandler<'a, H> { } } - /// Apply the `CurrentHueAndCurrentSaturation` mode. Hue is - /// stored in `EnhancedCurrentHue` as the low byte. + /// Apply the `CurrentHueAndCurrentSaturation` mode. `CurrentHue` + /// is the high byte of `EnhancedCurrentHue`. fn apply_hue_saturation( &self, ctx: &N, @@ -161,7 +161,7 @@ impl<'a, H: ColorControlHooks> ColorControlHandler<'a, H> { saturation: u8, scene_apply: bool, ) { - self.hooks.set_enhanced_current_hue(hue_u8 as u16); + self.hooks.set_enhanced_current_hue((hue_u8 as u16) << 8); self.hooks.set_current_saturation(saturation); self.hooks .set_enhanced_color_mode(EnhancedColorModeEnum::CurrentHueAndCurrentSaturation); @@ -417,9 +417,9 @@ impl ColorControlHandler<'_, H> { self.apply_color_temperature(ctx, mireds, true); } EnhancedColorModeEnum::CurrentHueAndCurrentSaturation => { - // Non-enhanced hue is the low byte of EnhancedCurrentHue. + // `CurrentHue` is the high byte of `EnhancedCurrentHue`. let (Some(hue), Some(sat)) = ( - enhanced_current_hue.map(|h| (h & 0xFF) as u8), + enhanced_current_hue.map(|h| (h >> 8) as u8), current_saturation, ) else { return Ok(()); @@ -851,9 +851,9 @@ mod tests { #[test] fn apply_hue_saturation_mode_truncates_enhanced_hue_to_u8() { - // Captured EnhancedCurrentHue is u16; non-enhanced apply - // path takes the low byte. Mirrors chip's `ApplyScene` - // behaviour and is documented on `apply_hue_saturation`. + // `CurrentHue` (u8) is the high byte of `EnhancedCurrentHue` + // (u16). Captured EnhancedHue=0x12FF → CurrentHue=0x12 → + // round-trip stored as 0x1200. let h = handler(Feature::HUE_AND_SATURATION); let mut buf = [0u8; 128]; let len = { @@ -862,7 +862,6 @@ mod tests { let array = AttributeValuePairStructArrayBuilder::new(parent, &crate::tlv::TLVTag::Anonymous) .unwrap(); - // Hue = 0x12FF → low byte 0xFF after truncation. let array = array .push_u16(AttributeId::EnhancedCurrentHue as _, 0x12FF) .unwrap() @@ -882,10 +881,7 @@ mod tests { let avp_list: TLVArray<'_, AttributeValuePairStruct<'_>> = TLVArray::new(elem).unwrap(); h.apply_inner(NULL_CTX, &avp_list, 0).unwrap(); - // The mutator writes the low byte into the enhanced field - // (the cluster's only hue storage); the mode flips to - // non-enhanced. - assert_eq!(h.hooks.enhanced_current_hue(), 0xFF); + assert_eq!(h.hooks.enhanced_current_hue(), 0x1200); assert_eq!(h.hooks.current_saturation(), 100); assert!(matches!( h.hooks.enhanced_color_mode(), diff --git a/rs-matter/src/dm/clusters/scenes.rs b/rs-matter/src/dm/clusters/scenes.rs index 60b858b7a..1ba90a740 100644 --- a/rs-matter/src/dm/clusters/scenes.rs +++ b/rs-matter/src/dm/clusters/scenes.rs @@ -63,6 +63,10 @@ const SC_CONSTRAINT_ERROR: u8 = 0x87; /// Reserved (invalid) `SceneID` value per Matter Core Spec. const RESERVED_SCENE_ID: SceneId = 0xFF; +/// `SceneID` 0 is reserved for the Global Scene; never valid in +/// add/view/remove/store/recall/copy. +const GLOBAL_SCENE_ID: SceneId = 0; + /// Maximum legal `AddScene.TransitionTime` in milliseconds /// (60 000 seconds / 1000 minutes per Matter Core Spec). const MAX_TRANSITION_TIME_MS: u32 = 60_000_000; @@ -962,7 +966,10 @@ where // Bad request shape (reserved scene id or oversized // transition) takes precedence over the group-table check. - if scene_id == RESERVED_SCENE_ID || transition_time > MAX_TRANSITION_TIME_MS { + if scene_id == GLOBAL_SCENE_ID + || scene_id == RESERVED_SCENE_ID + || transition_time > MAX_TRANSITION_TIME_MS + { return response .status(SC_CONSTRAINT_ERROR)? .group_id(group_id)? @@ -1012,6 +1019,16 @@ where } } + // An oversized EFS payload is a per-scene capacity failure, + // surfaced via `SC_INSUFFICIENT_SPACE` (not a transaction error). + if raw.len() > M { + return response + .status(SC_INSUFFICIENT_SPACE)? + .group_id(group_id)? + .scene_id(scene_id)? + .end(); + } + let status_code = self.state.with(|inner| { Self::upsert_scene( inner, @@ -1112,8 +1129,8 @@ where let group_id = request.group_id()?; let scene_id = request.scene_id()?; - // `SceneID = 0xFF` is reserved. - if scene_id == RESERVED_SCENE_ID { + // `SceneID = 0x00` (Global Scene) and `0xFF` are reserved. + if scene_id == GLOBAL_SCENE_ID || scene_id == RESERVED_SCENE_ID { return response .status(SC_CONSTRAINT_ERROR)? .group_id(group_id)? @@ -1198,8 +1215,8 @@ where let group_id = request.group_id()?; let scene_id = request.scene_id()?; - // `SceneID = 0xFF` is reserved. - if scene_id == RESERVED_SCENE_ID { + // `SceneID = 0x00` (Global Scene) and `0xFF` are reserved. + if scene_id == GLOBAL_SCENE_ID || scene_id == RESERVED_SCENE_ID { return response .status(SC_CONSTRAINT_ERROR)? .group_id(group_id)? @@ -1292,8 +1309,8 @@ where let group_id = request.group_id()?; let scene_id = request.scene_id()?; - // `SceneID = 0xFF` is reserved. - if scene_id == RESERVED_SCENE_ID { + // `SceneID = 0x00` (Global Scene) and `0xFF` are reserved. + if scene_id == GLOBAL_SCENE_ID || scene_id == RESERVED_SCENE_ID { return response .status(SC_CONSTRAINT_ERROR)? .group_id(group_id)? @@ -1397,8 +1414,8 @@ where // `FAILURE` and chip-tool's certification suites reject that // shape. - // `SceneID = 0xFF` is reserved. - if scene_id == RESERVED_SCENE_ID { + // `SceneID = 0x00` (Global Scene) and `0xFF` are reserved. + if scene_id == GLOBAL_SCENE_ID || scene_id == RESERVED_SCENE_ID { return Err(ErrorCode::ConstraintError.into()); } @@ -1518,9 +1535,14 @@ where // SceneIDs are ignored in this mode). let copy_all = (mode.bits() & 0x01) != 0; - // The reserved `SceneID = 0xFF` is only invalid in - // single-scene mode (COPY_ALL ignores those fields). - if !copy_all && (scene_from == RESERVED_SCENE_ID || scene_to == RESERVED_SCENE_ID) { + // Reserved `SceneID`s (Global Scene `0x00`, `0xFF`) are only + // invalid in single-scene mode (COPY_ALL ignores those fields). + if !copy_all + && (scene_from == GLOBAL_SCENE_ID + || scene_from == RESERVED_SCENE_ID + || scene_to == GLOBAL_SCENE_ID + || scene_to == RESERVED_SCENE_ID) + { return response .status(SC_CONSTRAINT_ERROR)? .group_identifier_from(group_from)? From 7fbfbd52d41fed27b1474de5f074a5f86ccf4d76 Mon Sep 17 00:00:00 2001 From: ivmarkov Date: Mon, 1 Jun 2026 10:16:52 +0000 Subject: [PATCH 15/15] Add the scenes test suite to CI --- .github/workflows/chiptool-tests.yml | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/.github/workflows/chiptool-tests.yml b/.github/workflows/chiptool-tests.yml index 15fc0dbd7..a71a09787 100644 --- a/.github/workflows/chiptool-tests.yml +++ b/.github/workflows/chiptool-tests.yml @@ -116,12 +116,18 @@ jobs: cargo xtask itest --skip-setup --features openssl TestBasicInformation \ 2>&1 | tee /tmp/itest-system-openssl.log - - name: Run LevelControl and OnOff integration tests for dimmable_light + - name: Run LevelControl and OnOff integration tests run: | set -o pipefail cargo xtask itest --skip-setup --suite light \ 2>&1 | tee /tmp/itest-light.log + - name: Run Scenes integration tests + run: | + set -o pipefail + cargo xtask itest --skip-setup --suite scenes \ + 2>&1 | tee /tmp/itest-scenes.log + - name: Run Camera integration tests run: | set -o pipefail