Skip to main content

rs_matter/dm/clusters/
binding.rs

1/*
2 *
3 *    Copyright (c) 2026 Project CHIP Authors
4 *
5 *    Licensed under the Apache License, Version 2.0 (the "License");
6 *    you may not use this file except in compliance with the License.
7 *    You may obtain a copy of the License at
8 *
9 *        http://www.apache.org/licenses/LICENSE-2.0
10 *
11 *    Unless required by applicable law or agreed to in writing, software
12 *    distributed under the License is distributed on an "AS IS" BASIS,
13 *    WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
14 *    See the License for the specific language governing permissions and
15 *    limitations under the License.
16 */
17
18//! Binding cluster handler (Matter Core spec).
19//!
20//! Per-endpoint, fabric-scoped, **persistent** list of `TargetStruct`
21//! entries each describing a unicast `(node, endpoint, cluster?)` or
22//! a groupcast `(group, cluster?)` destination. The Binding cluster
23//! is the *device-side address book* a client cluster reads when it
24//! wants to send a command somewhere: a wall switch with `OnOff` in
25//! its client list, for instance, reads its Binding list to find the
26//! bulb(s) it's been paired with.
27//!
28//! See the spec's [`9.6` summary][spec], or the in-tree write-up in
29//! `super::user_label` for the analogous (per-endpoint, persistent,
30//! shared-registry) shape.
31//!
32//! # Persistence
33//!
34//! Spec marks the `Binding` attribute with the `N` quality
35//! bit (Non-Volatile, Matter Core) — values **SHALL** survive
36//! reboots. We re-serialise the whole registry under [`BINDINGS_KEY`]
37//! after every successful write, and re-hydrate on startup via
38//! [`Bindings::load_persist`], driven by the [`LifecycleOp::Startup`]
39//! lifecycle operation the handler receives (deliver it by calling
40//! `InteractionModel::startup` once at startup).
41//!
42//! # Fabric scoping
43//!
44//! `TargetStruct` carries an implicit `FabricIndex` field (id 254)
45//! that the IM dispatch auto-injects from the writing accessor. On
46//! reads, `attr.fab_filter` + `attr.fab_idx` constrain results to the
47//! reading fabric. We store `fab_idx` alongside each entry and apply
48//! the same filter manually on read paths.
49//!
50//! # Validation
51//!
52//! Per spec:
53//! - `Group` and `Endpoint` are mutually exclusive (one of the two
54//!   identifies the target).
55//! - `Node` is required when `Endpoint` is present.
56//! - `Cluster` is optional.
57//!
58//! We reject malformed entries with `ConstraintError`.
59
60use core::num::NonZeroU8;
61
62use crate::dm::{
63    ArrayAttributeRead, ArrayAttributeWrite, Cluster, ClusterId, Dataver, EndptId, HandlerContext,
64    LifecycleOp, ReadContext, WriteContext,
65};
66use crate::error::{Error, ErrorCode};
67use crate::persist::{KvBlobStore, KvBlobStoreAccess, Persist};
68use crate::tlv::{FromTLV, TLVArray, TLVBuilderParent, TLVElement, ToTLV};
69use crate::utils::cell::RefCell;
70use crate::utils::init::{init, Init};
71use crate::utils::storage::Vec;
72use crate::utils::sync::blocking::Mutex;
73use crate::with;
74
75pub use crate::dm::clusters::decl::binding::*;
76pub use crate::persist::BINDINGS_KEY;
77
78/// Cluster metadata exposed by [`BindingHandler`].
79///
80/// Exposed as a free constant so callers can spell out
81/// `EpClMatcher::new(Some(ep), Some(binding::CLUSTER.id))` without
82/// reaching for the lifetime-parameterised handler type.
83pub const CLUSTER: Cluster<'static> = FULL_CLUSTER.with_attrs(with!(required));
84
85/// One binding entry: the *local* endpoint it is attached to, the fabric it
86/// belongs to, and the destination it points at.
87///
88/// This is the single entry type used for storage, persistence, and the
89/// application-facing read API ([`Bindings::get`]). Application code that *acts*
90/// on the device's bindings (e.g. a switch reading its binding list to decide
91/// which node(s) to send a command to) receives it cloned, so the registry lock
92/// is never held across the `await` of the subsequent remote invoke. It is small
93/// but not trivially `Copy`, hence `Clone`.
94///
95/// `local_endpoint` is **this** device's endpoint that hosts the binding — not
96/// to be confused with the remote target's `endpoint`. A *unicast* target has
97/// both `node` and `endpoint` set (`cluster` optional); a *groupcast* target has
98/// `group` set instead. The destination fields mirror the spec's `TargetStruct`.
99///
100/// Wire layout follows the standard `derive(FromTLV, ToTLV)` shape — a TLV
101/// struct with positional context-tagged fields. `Option<T>` fields are
102/// omit-if-`None`. The encoding is decoupled from the wire-level `TargetStruct`
103/// (which puts `FabricIndex` at ctx 254): we own the persisted layout and only
104/// need it to be self-consistent.
105#[derive(Debug, Clone, FromTLV, ToTLV)]
106#[cfg_attr(feature = "defmt", derive(defmt::Format))]
107pub struct Binding {
108    /// The *local* endpoint this binding is attached to.
109    pub local_endpoint: EndptId,
110    /// The fabric this binding belongs to.
111    pub fab_idx: NonZeroU8,
112    /// The remote node id (set for a unicast target).
113    pub node: Option<u64>,
114    /// The group id (set for a groupcast target).
115    pub group: Option<u16>,
116    /// The remote endpoint (set for a unicast target).
117    pub endpoint: Option<EndptId>,
118    /// The cluster to address (optional).
119    pub cluster: Option<ClusterId>,
120}
121
122/// Shared registry of [`Binding`] entries across every endpoint and
123/// fabric. Persisted as a single TLV blob under [`BINDINGS_KEY`].
124///
125/// `N` bounds the total number of entries the device can hold. Per
126/// Matter Core spec, device-type definitions may prescribe a
127/// minimum-per-fabric; the spec also says the total must be
128/// `min_per_fabric × supported_fabrics` — pick `N` accordingly.
129pub struct Bindings<const N: usize> {
130    state: Mutex<RefCell<Vec<Binding, N>>>,
131}
132
133impl<const N: usize> Bindings<N> {
134    /// Create an empty registry. Prefer [`Self::init`] for non-trivial
135    /// `N` so the storage is initialised in BSS.
136    pub const fn new() -> Self {
137        Self {
138            state: Mutex::new(RefCell::new(Vec::new())),
139        }
140    }
141
142    /// Return an in-place initialiser for an empty registry.
143    pub fn init() -> impl Init<Self> {
144        init!(Self {
145            state <- Mutex::init(RefCell::init(Vec::init())),
146        })
147    }
148
149    /// Re-hydrate the registry from `store` under [`BINDINGS_KEY`].
150    ///
151    /// Called on startup via the [`LifecycleOp::Startup`] lifecycle operation
152    /// delivered to the [`BindingHandler`] instance(s) borrowing this registry.
153    pub fn load_persist<S: KvBlobStore>(&self, mut store: S, buf: &mut [u8]) -> Result<(), Error> {
154        let Some(data) = store.load(BINDINGS_KEY, buf)? else {
155            self.state.lock(|cell| cell.borrow_mut().clear());
156            return Ok(());
157        };
158
159        let loaded = Vec::<Binding, N>::from_tlv(&TLVElement::new(data))?;
160        self.state.lock(|cell| *cell.borrow_mut() = loaded);
161
162        info!("Loaded Binding entries for all endpoints from storage");
163        Ok(())
164    }
165
166    /// Reset the registry to empty and remove its persisted blob from `store`
167    /// (under [`BINDINGS_KEY`]).
168    ///
169    /// Called on factory reset via the [`LifecycleOp::FactoryReset`] lifecycle
170    /// operation delivered to the [`BindingHandler`] instance(s) borrowing this
171    /// registry.
172    pub fn reset_persist<S: KvBlobStore>(&self, mut store: S, buf: &mut [u8]) -> Result<(), Error> {
173        self.state.lock(|cell| cell.borrow_mut().clear());
174
175        store.remove(BINDINGS_KEY, buf)
176    }
177
178    /// Drop every binding belonging to `fab_idx`, returning whether anything
179    /// was removed.
180    ///
181    /// Called on fabric removal via the [`LifecycleOp::FabricRemoval`]
182    /// lifecycle operation delivered to the [`BindingHandler`] instance(s)
183    /// borrowing this registry. Idempotent, since each borrowing handler
184    /// instance receives the (broadcast) lifecycle operation.
185    pub fn remove_for_fabric(&self, fab_idx: NonZeroU8) -> bool {
186        self.state.lock(|cell| {
187            let mut state = cell.borrow_mut();
188            let before = state.len();
189            state.retain(|binding| binding.fab_idx != fab_idx);
190            state.len() < before
191        })
192    }
193
194    /// Serialise the registry to `ctx.kv()` under [`BINDINGS_KEY`].
195    fn store_persist<C: HandlerContext>(&self, ctx: &C) -> Result<(), Error> {
196        let mut persist = Persist::new(ctx.kv());
197
198        self.state.lock(|cell| {
199            let state = cell.borrow();
200            persist.store_tlv(BINDINGS_KEY, &*state)
201        })?;
202
203        persist.run()
204    }
205
206    /// Validate a `TargetStruct` against spec and return a fully-built
207    /// [`Binding`]. `local_endpoint` and `fab_idx` come from the dispatch
208    /// context (they are not on the wire entry for this attribute write — the
209    /// framework auto-injects fab_idx).
210    fn parse_target(
211        local_endpoint: EndptId,
212        fab_idx: NonZeroU8,
213        t: &TargetStruct<'_>,
214    ) -> Result<Binding, Error> {
215        let node = t.node()?;
216        let group = t.group()?;
217        let endpoint = t.endpoint()?;
218        let cluster = t.cluster()?;
219
220        // Spec — the conformance columns say Group is
221        // `!Endpoint` and Endpoint is `!Group`, so **exactly one** of
222        // them must identify the target. Node's conformance is
223        // `Endpoint`, i.e. Node is mandatory whenever Endpoint is
224        // present (it names the remote node for the unicast target).
225        if group.is_some() && endpoint.is_some() {
226            return Err(ErrorCode::ConstraintError.into());
227        }
228        if group.is_none() && endpoint.is_none() {
229            // Rejects both "all fields missing" and "only Node
230            // present" (no destination at all).
231            return Err(ErrorCode::ConstraintError.into());
232        }
233        if endpoint.is_some() && node.is_none() {
234            return Err(ErrorCode::ConstraintError.into());
235        }
236
237        Ok(Binding {
238            local_endpoint,
239            fab_idx,
240            node,
241            group,
242            endpoint,
243            cluster,
244        })
245    }
246
247    /// Replace every entry on `(endpoint_id, fab_idx)` with the
248    /// supplied list. Other endpoints / fabrics are untouched.
249    fn replace_entries<'a, C: WriteContext>(
250        &self,
251        ctx: &C,
252        endpoint_id: EndptId,
253        fab_idx: NonZeroU8,
254        list: &TLVArray<'a, TargetStruct<'a>>,
255    ) -> Result<(), Error> {
256        // Two-pass validation: parse every supplied target *before*
257        // mutating state so a malformed input never partially clears
258        // the existing entries on this fabric.
259        let mut parsed: Vec<Binding, N> = Vec::new();
260        for t in list {
261            let t = t?;
262            let sb = Self::parse_target(endpoint_id, fab_idx, &t)?;
263            parsed.push(sb).map_err(|_| ErrorCode::ResourceExhausted)?;
264        }
265
266        let count = parsed.len();
267
268        self.state.lock(|cell| {
269            let mut state = cell.borrow_mut();
270            // Drop every existing entry on this (endpoint, fabric)
271            // in O(N) — one pass, in-place shift.
272            state.retain(|e| !(e.local_endpoint == endpoint_id && e.fab_idx == fab_idx));
273            // Now bulk-insert the validated list. Capacity-exhausted
274            // here means the *combined* fabric counts exceeded `N`.
275            for sb in parsed {
276                state.push(sb).map_err(|_| ErrorCode::ResourceExhausted)?;
277            }
278            Ok::<_, Error>(())
279        })?;
280
281        if count == 0 {
282            info!(
283                "Binding: cleared all targets on endpoint {}, fabric {}",
284                endpoint_id, fab_idx
285            );
286        } else {
287            info!(
288                "Binding: replaced list on endpoint {}, fabric {} with {} target(s)",
289                endpoint_id, fab_idx, count
290            );
291        }
292
293        self.store_persist(ctx)
294    }
295
296    /// Append one entry for `(endpoint_id, fab_idx)`.
297    fn add_entry<'a, C: WriteContext>(
298        &self,
299        ctx: &C,
300        endpoint_id: EndptId,
301        fab_idx: NonZeroU8,
302        entry: &TargetStruct<'a>,
303    ) -> Result<(), Error> {
304        let sb = Self::parse_target(endpoint_id, fab_idx, entry)?;
305
306        info!(
307            "Binding: add on endpoint {}, fabric {} -> node {:?}, group {:?}, endpoint {:?}, cluster {:?}",
308            endpoint_id, fab_idx, sb.node, sb.group, sb.endpoint, sb.cluster
309        );
310
311        self.state.lock(|cell| {
312            cell.borrow_mut()
313                .push(sb)
314                .map_err(|_| -> Error { ErrorCode::ResourceExhausted.into() })?;
315            Ok::<_, Error>(())
316        })?;
317
318        self.store_persist(ctx)
319    }
320
321    /// The total number of [`Binding`] entries in the registry, across **all**
322    /// local endpoints and fabrics. See [`Bindings::get`].
323    pub fn len(&self) -> usize {
324        self.state.lock(|cell| cell.borrow().len())
325    }
326
327    /// Whether the registry holds no bindings.
328    pub fn is_empty(&self) -> bool {
329        self.len() == 0
330    }
331
332    /// The `index`-th [`Binding`] in the registry (across all endpoints and
333    /// fabrics), cloned out so the registry lock is not held by the caller, or
334    /// `None` if `index` is out of range.
335    ///
336    /// This flat, index-based accessor lets application code iterate the bindings
337    /// and perform async work (e.g. a remote invoke) per entry without holding
338    /// the lock across the `await`. Each [`Binding`] carries its `local_endpoint`
339    /// and `fab_idx`, so the caller filters on those itself:
340    ///
341    /// ```ignore
342    /// for i in 0..bindings.len() {
343    ///     let Some(b) = bindings.get(i) else { break };
344    ///     if b.local_endpoint != MY_ENDPOINT { continue; }
345    ///     if let (Some(node), Some(ep)) = (b.node, b.endpoint) {
346    ///         // lock already released here - safe to await
347    ///         let exchange = Exchange::initiate(matter, &crypto, b.fab_idx, node).await?;
348    ///         exchange.on_off().toggle(ep).await?;
349    ///     }
350    /// }
351    /// ```
352    pub fn get(&self, index: usize) -> Option<Binding> {
353        self.state.lock(|cell| cell.borrow().get(index).cloned())
354    }
355
356    /// Render every entry on `endpoint_id` matching the read filter
357    /// into the provided builder. `fab_filter = Some(idx)` constrains
358    /// the output to one fabric; `None` returns every fabric's
359    /// entries (used when the reading accessor opted out of fabric
360    /// filtering via `attr.fab_filter == false`).
361    fn render<P: TLVBuilderParent>(
362        &self,
363        endpoint_id: EndptId,
364        fab_filter: Option<NonZeroU8>,
365        builder: ArrayAttributeRead<TargetStructArrayBuilder<P>, TargetStructBuilder<P>>,
366    ) -> Result<P, Error> {
367        self.state.lock(|cell| {
368            let state = cell.borrow();
369            let mut iter = state
370                .iter()
371                .filter(|e| e.local_endpoint == endpoint_id)
372                .filter(|e| fab_filter.is_none_or(|f| e.fab_idx == f));
373
374            match builder {
375                ArrayAttributeRead::ReadAll(mut array) => {
376                    for e in iter {
377                        let item = array.push()?;
378                        let item = item.node(e.node)?;
379                        let item = item.group(e.group)?;
380                        let item = item.endpoint(e.endpoint)?;
381                        let item = item.cluster(e.cluster)?;
382                        array = item.fabric_index(Some(e.fab_idx.get()))?.end()?;
383                    }
384                    array.end()
385                }
386                ArrayAttributeRead::ReadOne(index, item) => {
387                    let Some(e) = iter.nth(index as usize) else {
388                        return Err(ErrorCode::ConstraintError.into());
389                    };
390                    let item = item.node(e.node)?;
391                    let item = item.group(e.group)?;
392                    let item = item.endpoint(e.endpoint)?;
393                    let item = item.cluster(e.cluster)?;
394                    item.fabric_index(Some(e.fab_idx.get()))?.end()
395                }
396                ArrayAttributeRead::ReadNone(array) => array.end(),
397            }
398        })
399    }
400}
401
402impl<const N: usize> Default for Bindings<N> {
403    fn default() -> Self {
404        Self::new()
405    }
406}
407
408/// Per-`(endpoint, Binding)`-instance handler facade. Holds only a
409/// `Dataver`, the endpoint id it serves, and a borrow of the shared
410/// [`Bindings`] registry. All persisted state lives in the registry.
411pub struct BindingHandler<'a, const N: usize> {
412    dataver: Dataver,
413    endpoint_id: EndptId,
414    bindings: &'a Bindings<N>,
415}
416
417impl<'a, const N: usize> BindingHandler<'a, N> {
418    /// Construct a facade for `(endpoint_id, Binding)` backed by the
419    /// shared `bindings` registry.
420    pub const fn new(dataver: Dataver, endpoint_id: EndptId, bindings: &'a Bindings<N>) -> Self {
421        Self {
422            dataver,
423            endpoint_id,
424            bindings,
425        }
426    }
427
428    /// Adapt the handler instance to the generic `rs-matter` `Handler` trait.
429    pub const fn adapt(self) -> HandlerAdaptor<Self> {
430        HandlerAdaptor(self)
431    }
432}
433
434impl<const N: usize> ClusterHandler for BindingHandler<'_, N> {
435    const CLUSTER: Cluster<'static> = FULL_CLUSTER.with_attrs(with!(required));
436
437    fn dataver(&self) -> u32 {
438        self.dataver.get()
439    }
440
441    fn dataver_changed(&self) {
442        self.dataver.changed();
443    }
444
445    fn lifecycle(&self, ctx: impl HandlerContext, op: LifecycleOp) -> Result<(), Error> {
446        match op {
447            LifecycleOp::Startup => ctx
448                .kv()
449                .access(|store, buf| self.bindings.load_persist(store, buf)),
450            LifecycleOp::FactoryReset => ctx
451                .kv()
452                .access(|store, buf| self.bindings.reset_persist(store, buf)),
453            LifecycleOp::FabricRemoval { fab_idx } => {
454                if self.bindings.remove_for_fabric(fab_idx) {
455                    self.bindings.store_persist(&ctx)?;
456                }
457
458                Ok(())
459            }
460        }
461    }
462
463    fn binding<P: TLVBuilderParent>(
464        &self,
465        ctx: impl ReadContext,
466        builder: ArrayAttributeRead<TargetStructArrayBuilder<P>, TargetStructBuilder<P>>,
467    ) -> Result<P, Error> {
468        let attr = ctx.attr();
469        // Translate the framework's `(fab_filter: bool, fab_idx: u8)` pair
470        // into `Option<NonZeroU8>`. A reader that opted into fabric
471        // filtering but presents an unaccredited fab_idx of 0 gets the
472        // empty list — that's how the spec describes the "no accessing
473        // fabric" state for fabric-scoped attrs.
474        let fab_filter = if attr.fab_filter {
475            Some(NonZeroU8::new(attr.fab_idx).ok_or(ErrorCode::UnsupportedAccess)?)
476        } else {
477            None
478        };
479        self.bindings.render(self.endpoint_id, fab_filter, builder)
480    }
481
482    fn set_binding(
483        &self,
484        ctx: impl WriteContext,
485        value: ArrayAttributeWrite<TLVArray<'_, TargetStruct<'_>>, TargetStruct<'_>>,
486    ) -> Result<(), Error> {
487        // Fabric-scoped writes require a valid accessor fabric — the
488        // `NonZeroU8::new` conversion is the type-system encoding of
489        // that requirement.
490        let fab_idx = NonZeroU8::new(ctx.attr().fab_idx).ok_or(ErrorCode::UnsupportedAccess)?;
491
492        match value {
493            ArrayAttributeWrite::Replace(list) => {
494                self.bindings
495                    .replace_entries(&ctx, self.endpoint_id, fab_idx, &list)
496            }
497            ArrayAttributeWrite::Add(entry) => {
498                self.bindings
499                    .add_entry(&ctx, self.endpoint_id, fab_idx, &entry)
500            }
501            // Per-element list update / remove on fabric-scoped attrs:
502            // the framework converts these to InvalidAction before
503            // reaching us, but match exhaustively to be safe.
504            ArrayAttributeWrite::Update(_, _) | ArrayAttributeWrite::Remove(_) => {
505                Err(ErrorCode::InvalidAction.into())
506            }
507        }
508    }
509}