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}