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 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..54a396fed --- /dev/null +++ b/examples/src/bin/scenes_tests.pics @@ -0,0 +1,152 @@ +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 +# 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.A0007=1 # FabricSceneInfo — read in TestScenesFabricSceneInfo +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.S.F03=1 # SceneTable feature — gated by TestScenesFabricSceneInfo +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..974493a07 --- /dev/null +++ b/examples/src/bin/scenes_tests.rs @@ -0,0 +1,582 @@ +/* + * + * 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. + */ + +//! 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)] + +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::{self, LevelControlHooks}; +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::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::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::{ + 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::cell::RefCell; +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(); + +const SCENES_CAPACITY: usize = 16; +static SCENES_STATE: StaticCell> = StaticCell::new(); + +// 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> { + 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()?; + + // `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()); + 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()) + .with_scene_invalidator(scenes_state); + + // 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() + }, + ) + .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)); + + // Scenes Management cluster setup. + let scenes_handler = ScenesHandler::new( + Dataver::new_rand(&mut rand), + scenes_state, + (&on_off_handler, (&level_control_handler, ())), + ); + + // 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, + unit_testing_data, + ), + 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, + UnitTestingHandler::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, R>, + unit_testing_data: &'a RefCell, +) -> 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(), + ) + .chain( + EpClMatcher::new(Some(1), Some(UnitTestingHandler::CLUSTER.id)), + Async( + UnitTestingHandler::new(Dataver::new_rand(&mut rand), unit_testing_data) + .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.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/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/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..0f3e7b323 --- /dev/null +++ b/rs-matter/src/dm/clusters/app/color_control.rs @@ -0,0 +1,1102 @@ +/* + * + * 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 — 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; + +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. 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 { + /// Active `Feature` bitmap on this endpoint. Used to feature-gate + /// scene capture. + 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. + fn color_loop_time(&self) -> u16; + fn set_color_loop_time(&self, value: u16); + + /// 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 — currently exposes only +/// the scenes-integration surface, not the full `ClusterHandler`. +pub struct ColorControlHandler<'a, H: ColorControlHooks> { + #[allow(dead_code)] + dataver: Dataver, + endpoint_id: EndptId, + hooks: H, + 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. + 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 + .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. `CurrentHue` + /// is the high byte of `EnhancedCurrentHue`. + fn apply_hue_saturation( + &self, + ctx: &N, + hue_u8: u8, + saturation: u8, + scene_apply: bool, + ) { + 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); + 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 — short-circuits `MoveTo*` + /// dispatch when the recalled scene has `ColorLoopActive=1`. + 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 { + // 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 + || 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> { + // `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 + }; + + 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> { + // 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> { + /// Sync apply, scoped to `AttrChangeNotifier` for testability. + fn apply_inner( + &self, + ctx: &N, + avp_list: &TLVArray<'_, AttributeValuePairStruct<'_>>, + _transition_time_ms: u32, + ) -> Result<(), Error> { + // 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; + 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()?; + } + } + + // 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) + .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 { + 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 => { + // `CurrentHue` is the high byte of `EnhancedCurrentHue`. + let (Some(hue), Some(sat)) = ( + enhanced_current_hue.map(|h| (h >> 8) 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`], `None` for unknown values. +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`], +/// `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 — no + //! chip-tool YAML suite covers it. + + use super::*; + use crate::tlv::{TLVElement, TLVWriteParent}; + use crate::utils::storage::WriteBuf; + + /// `()` is a no-op `AttrChangeNotifier`, which is all the apply + /// helpers use — avoids mocking a full `HandlerContext`. + const NULL_CTX: &() = &(); + + 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); + } + } + + 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); + } + } + + fn handler(features: Feature) -> ColorControlHandler<'static, MockHooks> { + 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 ---- + + 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() { + // `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 = { + 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 _, 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(); + + assert_eq!(h.hooks.enhanced_current_hue(), 0x1200); + 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() { + // Missing EnhancedColorMode → no-op rather than error. + 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 + )); + } +} diff --git a/rs-matter/src/dm/clusters/app/level_control.rs b/rs-matter/src/dm/clusters/app/level_control.rs index 9625569fb..ee4e0d5e0 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,10 @@ enum Task { with_on_off: bool, target: u8, transition_time: u16, + /// When `true`, the transition was queued by a scene recall; + /// `set_level` skips `notify_scenable_changed` so `SceneValid` + /// is preserved. + scene_apply: bool, }, Move { with_on_off: bool, @@ -190,6 +199,9 @@ pub struct LevelControlHandler<'a, H: LevelControlHooks, OH: OnOffHooks> { endpoint_id: EndptId, hooks: H, on_off_handler: Mutex>>>, + /// See [`OnOffHandler::with_scene_invalidator`] — same role, fired + /// when `CurrentLevel` mutates. + scene_invalidator: Mutex>>, state: Mutex>, task_signal: Signal>, } @@ -276,11 +288,29 @@ 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`] 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 + } + + 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 +479,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 +491,12 @@ impl<'a, H: LevelControlHooks, OH: OnOffHooks> LevelControlHandler<'a, H, OH> { false => Some(level), }; self.hooks.set_current_level(current_level); + // 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(); + } let last_notification = Instant::now() - state.last_current_level_notification; // CurrentLevel Quiet report conditions: @@ -532,9 +569,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 +640,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 +794,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 +821,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 +866,8 @@ 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) + // Command-driven only — never reached from scene apply. + self.move_to_level_transition(ctx, with_on_off, level, t_time, false) .await?; Ok(()) @@ -832,6 +881,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 +923,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 +1076,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 +1165,7 @@ impl<'a, H: LevelControlHooks, OH: OnOffHooks> LevelControlHandler<'a, H, OH> { Some(transition_time), options_mask, options_override, + false, ) } @@ -1146,7 +1200,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 +1232,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 +1561,7 @@ impl ClusterAsyncHandler for LevelControlH transition_time, options_mask, options_override, + false, ) }) } @@ -1602,7 +1660,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, + ) }) } @@ -1802,6 +1867,70 @@ impl OnOffHooks for NoOnOff { } } +/// 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, + OH: OnOffHooks, +{ + const CLUSTER_ID: ClusterId = FULL_CLUSTER.id; + + fn endpoint_id(&self) -> EndptId { + self.endpoint_id + } + + fn is_scenable_attribute(attribute_id: AttrId) -> bool { + attribute_id == AttributeId::CurrentLevel as AttrId + } + + fn capture( + &self, + avp_array: AttributeValuePairStructArrayBuilder

, + ) -> Result, Error> { + // `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 { + Ok(avp_array) + } + } + + 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; + } + let Some(level) = avp.value_unsigned_8()? else { + continue; + }; + // 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, + level, + Some(transition_ds), + OptionsBitmap::empty(), + OptionsBitmap::empty(), + true, + ); + } + 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..634d59350 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,9 @@ pub struct OnOffHandler<'a, H: OnOffHooks, LH: LevelControlHooks> { endpoint_id: EndptId, hooks: H, level_control_handler: Mutex>>>, + /// 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>, } @@ -175,6 +185,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 +295,23 @@ impl<'a, H: OnOffHooks, LH: LevelControlHooks> OnOffHandler<'a, H, LH> { HandlerAsyncAdaptor(self) } + /// 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 + } + + 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 +337,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 +351,11 @@ 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 mutations transition *into* the recalled state, + // so they must not trigger `SceneValid` drift-detection. + 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 +413,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 +453,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 +553,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 +601,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 +622,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 +631,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 +668,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 +691,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 +736,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 +1034,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 @@ -1098,6 +1137,67 @@ impl LevelControlHooks for NoLevelControl { } } +/// 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, + LH: LevelControlHooks, +{ + const CLUSTER_ID: ClusterId = FULL_CLUSTER.id; + + fn endpoint_id(&self) -> EndptId { + self.endpoint_id + } + + fn is_scenable_attribute(attribute_id: AttrId) -> bool { + attribute_id == AttributeId::OnOff as AttrId + } + + fn capture( + &self, + avp_array: AttributeValuePairStructArrayBuilder

, + ) -> Result, Error> { + 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; + } + let Some(value) = avp.value_unsigned_8()? else { + continue; + }; + // 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); + } else { + self.set_off(state, true, true, ctx); + } + }); + return Ok(()); + } + 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 new file mode 100644 index 000000000..1ba90a740 --- /dev/null +++ b/rs-matter/src/dm/clusters/scenes.rs @@ -0,0 +1,2433 @@ +/* + * + * 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 handler. +//! +//! 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. +//! +//! [`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`]. +//! +//! 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; + +use crate::dm::{ + 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, ToTLV, TLV, +}; +use crate::utils::cell::RefCell; +use crate::utils::init::{init, Init}; +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 used by Scenes Management response structs and +// command-level `Err(...)` returns. +const SC_NOT_FOUND: u8 = 0x8B; +const SC_INSUFFICIENT_SPACE: u8 = 0x89; +const SC_INVALID_COMMAND: u8 = 0x85; +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; + +/// 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 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 (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. + const CLUSTER_ID: ClusterId; + + /// Endpoint this handler instance is installed on. Used to skip + /// clusters not on the `StoreScene` / `RecallScene` target endpoint. + fn endpoint_id(&self) -> EndptId; + + /// 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 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 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, + avp_list: &TLVArray<'_, AttributeValuePairStruct<'_>>, + transition_time_ms: u32, + ) -> impl Future>; +} + +/// 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; + + 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<'_>>, + transition_time_ms: u32, + ) -> Result<(), Error> { + T::apply(*self, ctx, avp_list, transition_time_ms).await + } +} + +/// 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 { + /// 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; + + /// `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 handled, + /// `Ok(false)` if no registered cluster matches. + fn apply( + &self, + ctx: &C, + endpoint_id: EndptId, + cluster_id: ClusterId, + avp_list: &TLVArray<'_, AttributeValuePairStruct<'_>>, + transition_time_ms: u32, + ) -> impl Future>; +} + +impl SceneClusters for () { + 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( + &self, + _ctx: &C, + _endpoint_id: EndptId, + _cluster_id: ClusterId, + _avp_list: &TLVArray<'_, AttributeValuePairStruct<'_>>, + _transition_time_ms: u32, + ) -> impl Future> { + ready(Ok(false)) + } +} + +impl SceneClusters for (H, T) +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) + } + } + + fn capture(&self, endpoint_id: EndptId, parent: P) -> Result { + let parent = if self.0.endpoint_id() == endpoint_id { + 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(avp_array)?; + let efs = avp_array.end()?; + efs.end()? + } else { + parent + }; + self.1.capture(endpoint_id, parent) + } + + async fn apply( + &self, + ctx: &C, + endpoint_id: EndptId, + cluster_id: ClusterId, + avp_list: &TLVArray<'_, AttributeValuePairStruct<'_>>, + 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(ctx, endpoint_id, cluster_id, avp_list, transition_time_ms) + .await + } + } +} + +/// 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 AVP element with a `valueUnsigned8` value. + 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 AVP element with a `valueUnsigned16` value. + 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. 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 { + fab_idx: NonZeroU8, + endpoint_id: EndptId, + group_id: u16, + scene_id: SceneId, + /// Transition time in milliseconds (1..=`MAX_TRANSITION_TIME_MS`). + transition_time: u32, + /// 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, +} + +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 + } + + /// 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, + 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 backing +/// `FabricSceneInfo.CurrentScene` / `CurrentGroup` / `SceneValid`. +/// +/// 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 { + fab_idx: NonZeroU8, + endpoint_id: EndptId, + group_id: u16, + scene_id: SceneId, + valid: bool, +} + +/// All mutable Scenes state, held behind a single mutex inside +/// [`ScenesState`]. +struct ScenesStateInner { + /// Scene table keyed by `(fab_idx, endpoint_id, group_id, scene_id)`. + table: Vec, N>, + /// One slot per fabric that has touched scenes. + current_per_fabric: Vec, + /// Bumped on every state mutation that affects `FabricSceneInfo`. + info_dataver: u32, +} + +impl ScenesStateInner { + const fn new() -> Self { + Self { + table: Vec::new(), + current_per_fabric: Vec::new(), + info_dataver: 0, + } + } + + 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 — the scene table plus +/// per-fabric `CurrentScene` bookkeeping. Shared across all endpoints +/// exposing the cluster. +/// +/// Const generics: +/// - `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>>, +} + +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() + } +} + +/// 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. 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 on + /// `endpoint_id`, across all fabrics. No-op when no fabric has a + /// scene recalled there. + 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. 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 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> { + 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)?; + // 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>> { + // Persistence goes through `to_tlv`; this is just here to + // satisfy the trait bound. + 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)?)?, + 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. 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 { + // Reset to empty so a `load_persist` after a key + // `remove` is deterministic. + 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(()) + } + + /// 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()); + + 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` of the +/// scene-aware cluster handlers that participate in scene capture / +/// recall on this device: +/// +/// ```ignore +/// let scenes = ScenesHandler::new( +/// dataver, +/// &scenes_state, +/// (&on_off_handler, (&level_control_handler, ())), +/// ); +/// ``` +/// +/// 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, +{ + dataver: Dataver, + state: &'a ScenesState, + clusters: R, +} + +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 { + HandlerAsyncAdaptor(self) + } + + fn fab_idx(ctx: &C) -> Result { + ctx.exchange().accessor()?.fab_idx() + } + + /// 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(); + 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 + } + + /// `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, + 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 `(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, + endpoint_id: EndptId, + group_id: u16, + scene_id: SceneId, + ) { + if let Some(slot) = inner + .current_per_fabric + .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; + } 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, + endpoint_id, + group_id, + scene_id, + valid: true, + }); + } + inner.bump_info_dataver(); + } + + /// 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, + group_id: u16, + scene_id: SceneId, + ) { + 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(); + } + } + + /// 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, + fab_idx: NonZeroU8, + group_id: u16, + ) { + 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(); + } + } + + /// 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, + fab_idx: NonZeroU8, + endpoint_id: EndptId, + group_from: u16, + scene_from: SceneId, + group_to: u16, + scene_to: SceneId, + copy_all: bool, + ) -> u8 { + // 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; + } + + 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; + // 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(); + 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; + 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 { + 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. + if !copy_all { + break; + } + } + idx += 1; + } + + if !found_source { + return SC_NOT_FOUND; + } + + // 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 { + Self::invalidate_current_if_match_scene(inner, fab_idx, group_to, scene_to); + } + 0 + } + + // 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, + 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. 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 + .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 has_state = count > 0 || current.is_some(); + // `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), + }; + 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)? + .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 add_scene( + &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()?; + + // Bad request shape (reserved scene id or oversized + // transition) takes precedence over the group-table check. + 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)? + .scene_id(scene_id)? + .end(); + } + + 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(); + } + + // 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 => &[], + }; + + // 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?; + 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(); + } + } + } + } + + // 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, + 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.state.store_persist(ctx)?; + ctx.notify_own_attr_changed(AttributeId::FabricSceneInfo as _); + } + + response + .status(status_code)? + .group_id(group_id)? + .scene_id(scene_id)? + .end() + } + + /// 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. + /// + /// 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, + 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_if_match_scene(inner, fab_idx, group_id, scene_id); + Ok(0) + } else if inner.table.len() >= N { + Ok(SC_INSUFFICIENT_SPACE) + } else { + // `push_init_unchecked` only panics when full, and the + // `else if` above just checked `len < N`. + 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) { + let _ = inner.table.pop(); + return Err(e); + } + Self::invalidate_current_if_match_scene(inner, fab_idx, group_id, scene_id); + Ok(0) + } + } + + fn view_scene( + &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()?; + + // `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)? + .scene_id(scene_id)? + .transition_time(None)? + .scene_name(None)? + .extension_field_set_structs()? + .none() + .end(); + } + + 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 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 + .iter() + .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(); + }; + + let opt = response + .status(0)? + .group_id(group_id)? + .scene_id(scene_id)? + .transition_time(Some(e.transition_time))? + .scene_name(Some(""))? + .extension_field_set_structs()?; + + Self::write_blob_or_none(opt, &e.extension_fields)?.end() + }) + } + + /// 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() { + let writer = opt.writer(); + writer.start_array(&TLVTag::Context(5))?; + writer.write_raw_data(blob.iter().copied())?; + } + Ok(opt.none()) + } + + fn remove_scene( + &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()?; + + // `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)? + .scene_id(scene_id)? + .end(); + } + + 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 + .iter() + .position(|e| e.matches(fab_idx, endpoint_id, group_id, scene_id)) + { + inner.table.swap_remove(pos); + Self::invalidate_current_if_match_scene(inner, fab_idx, group_id, scene_id); + 0 + } else { + SC_NOT_FOUND + } + }); + + if status == 0 { + self.state.store_persist(ctx)?; + ctx.notify_own_attr_changed(AttributeId::FabricSceneInfo as _); + } + + response + .status(status)? + .group_id(group_id)? + .scene_id(scene_id)? + .end() + } + + fn remove_all_scenes( + &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()?; + + 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| { + !(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_if_match_group(inner, fab_idx, group_id); + } + changed + }); + + if removed { + self.state.store_persist(ctx)?; + ctx.notify_own_attr_changed(AttributeId::FabricSceneInfo as _); + } + + response.status(0)?.group_id(group_id)?.end() + } + + /// `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, + request: &StoreSceneRequest<'_>, + response: StoreSceneResponseBuilder

, + ) -> 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()?; + + // `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)? + .scene_id(scene_id)? + .end(); + } + + 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 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); + let parent = TLVWriteParent::new("StoreScene EFS", &mut wb); + let _ = self.clusters.capture(endpoint_id, parent)?; + wb.end_container()?; + wb.get_tail() + }; + let stored_bytes = &scratch[..total_len]; + + // 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 + .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| { + let status = 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(()) + }, + )?; + // 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); + } + Ok::<_, Error>(status) + })?; + + if status_code == 0 { + self.state.store_persist(ctx)?; + ctx.notify_own_attr_changed(AttributeId::FabricSceneInfo as _); + } + + response + .status(status_code)? + .group_id(group_id)? + .scene_id(scene_id)? + .end() + } + + /// `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, + request: &RecallSceneRequest<'_>, + ) -> Result<(), Error> { + 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()?; + + // `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 = 0x00` (Global Scene) and `0xFF` are reserved. + if scene_id == GLOBAL_SCENE_ID || scene_id == RESERVED_SCENE_ID { + return Err(ErrorCode::ConstraintError.into()); + } + + if !Self::group_in_table(ctx, fab_idx, endpoint_id, group_id)? { + return Err(ErrorCode::InvalidCommand.into()); + } + + // 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 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 + .table + .iter() + .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 { + return Err(ErrorCode::NotFound.into()); + }; + + let effective_tt_ms = override_tt_ms.unwrap_or(stored_tt_ms); + + 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()?; + // 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) + .await?; + } + + self.state + .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 _); + Ok(()) + } + + fn get_scene_membership( + &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()?; + + // 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)? + .capacity(Nullable::none())? + .group_id(group_id)? + .scene_list()? + .none() + .end(); + } + + // 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); + + let resp = response + .status(0)? + .capacity(Nullable::some(remaining))? + .group_id(group_id)?; + + 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 copy_scene( + &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()?; + + // `CopyModeBitmap` bit 0 = COPY_ALL_SCENES (From/To + // SceneIDs are ignored in this mode). + let copy_all = (mode.bits() & 0x01) != 0; + + // 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)? + .scene_identifier_from(scene_from)? + .end(); + } + + 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(); + } + + 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.state.store_persist(ctx)?; + ctx.notify_own_attr_changed(AttributeId::FabricSceneInfo as _); + } + + response + .status(status)? + .group_identifier_from(group_from)? + .scene_identifier_from(scene_from)? + .end() + } +} + +impl ClusterAsyncHandler for ScenesHandler<'_, N, R, M> +where + R: SceneClusters, +{ + 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.read_fabric_scene_info(&ctx, builder)) + } + + fn handle_add_scene( + &self, + ctx: impl InvokeContext, + request: AddSceneRequest<'_>, + response: AddSceneResponseBuilder

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

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

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

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

, + ) -> Result { + self.store_scene(&ctx, &request, response).await + } + + async fn handle_recall_scene( + &self, + ctx: impl InvokeContext, + request: RecallSceneRequest<'_>, + ) -> Result<(), Error> { + self.recall_scene(&ctx, &request).await + } + + fn handle_get_scene_membership( + &self, + ctx: impl InvokeContext, + request: GetSceneMembershipRequest<'_>, + response: GetSceneMembershipResponseBuilder

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

, + ) -> impl Future> { + ready(self.copy_scene(&ctx, &request, response)) + } +} + +#[cfg(test)] +mod tests { + //! 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::*; + + 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, + extension_fields: Vec::new(), + } + } + + /// Variant of [`entry`] that stamps an arbitrary EFS blob. + 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, + } + } + + 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, + 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, + 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) + } + + /// Helper: look up the extension-fields blob for one entry. + fn find_blob( + inner: &ScenesStateInner, + 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()) + } + + // ---- 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() { + // `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::<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::<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[..])); + 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] + 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() { + // `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::<16>::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_on_match() { + let mut inner = ScenesStateInner::<8>::new(); + push(&mut inner, entry(fab(1), 1, 10, 5, 100)); + // 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), 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); + // 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] + 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), 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); + assert!(inner.current_per_fabric[0].valid); + } + + #[test] + fn failed_copy_does_not_invalidate_current_scene() { + let mut inner = ScenesStateInner::<8>::new(); + ScenesHandler::<8>::remember_current(&mut inner, fab(1), 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), 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); + 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), 1, 10, 1); + ScenesHandler::<8>::remember_current(&mut inner, fab(2), 1, 20, 2); + + assert_eq!(inner.current_per_fabric.len(), 2); + } + + #[test] + fn invalidate_match_scene_only_clears_exact_match() { + let mut inner = ScenesStateInner::<8>::new(); + 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` / + // `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); + assert!(inner.current_per_fabric.iter().all(|c| c.valid)); + + // 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(), 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] + fn invalidate_match_group_clears_any_scene_in_group() { + let mut inner = ScenesStateInner::<8>::new(); + 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); + assert!(inner.current_per_fabric.iter().all(|c| c.valid)); + + // 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); + 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); + } + + // ---- AddScene / StoreScene shared `upsert_scene` path ---- + + /// Fill closure that copies a fixed slice into the slot Vec. + 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_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), 1, 10, 5); + + let _ = + ScenesHandler::<8>::upsert_scene(&mut inner, fab(1), 1, 10, 5, 100, fill_with(&[0x18])) + .unwrap(); + + // 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] + fn upsert_preserves_current_scene_when_upsert_targets_a_different_scene() { + // Non-matching upsert MUST leave `SceneValid` intact. + let mut inner = ScenesStateInner::<8>::new(); + ScenesHandler::<8>::remember_current(&mut inner, fab(1), 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); + assert!(inner.current_per_fabric[0].valid); + } + + #[test] + fn upsert_keeps_other_fabrics_current_scene_intact() { + 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), 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])) + .unwrap(); + + // 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] + 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), 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()); + } +} 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/persist.rs b/rs-matter/src/persist.rs index 4a930c923..6d36ce0a2 100644 --- a/rs-matter/src/persist.rs +++ b/rs-matter/src/persist.rs @@ -64,6 +64,11 @@ 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 (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. /// /// NOTE: For now, the trait is deliberately modeled as non-async, so that it can be used from 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)); diff --git a/xtask/src/itest.rs b/xtask/src/itest.rs index ddc70dda9..cbe848087 100644 --- a/xtask/src/itest.rs +++ b/xtask/src/itest.rs @@ -480,6 +480,21 @@ 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. +pub(crate) const SCENES_TESTS: &[&str] = &[ + "Test_TC_S_2_1", + "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", + "Test_TC_S_3_1", + "TestScenesFabricSceneInfo", + "TestScenesMultiFabric", + "TestScenesFabricRemoval", + "TestScenesMaxCapacity", +]; + /// 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 +517,8 @@ pub(crate) enum TestSuite { Camera, /// OnOff + LevelControl, exercising the dimmable_light example. Light, + /// 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 /// both binaries, spawns the CHIP app on `[::1]:` with known @@ -531,6 +548,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 +562,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 +570,9 @@ 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 => &[], + Self::System | Self::SystemPython | Self::SystemYaml | Self::Camera | Self::Scenes => { + &[] + } Self::Light => &["chip-test"], Self::Commissioner => &[], } @@ -563,6 +584,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, } }