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