Skip to main content

rs_matter/dm/clusters/
icd_mgmt.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//! The ICD Management cluster and Check-In sender.
19//!
20//! An Intermittently Connected Device (ICD) hosts this cluster so clients can
21//! register to receive Check-In notifications when their subscription is lost.
22//!
23//! - [`Icd`] is the shared state (registrations + Check-In counter +
24//!   stay-active deadline) the application owns and lends to the handler and the
25//!   sender.
26//! - [`IcdMgmtHandler`] is the cluster handler (registration commands + the
27//!   Check-In-relevant attributes), layered on an [`Icd`].
28//! - [`Icd::send_check_in`] sends a Check-In to every registered client whose
29//!   subscription is lost; [`Icd::send_one_check_in`] targets a single client.
30//!   Both resolve the address over mDNS and send sessionlessly. The application
31//!   drives them when it decides clients should be nudged.
32
33use core::num::NonZeroU8;
34
35use embassy_time::{Duration, Instant};
36
37use crate::acl::AccessReq;
38use crate::crypto::{CanonAeadKey, Crypto};
39use crate::dm::endpoints::ROOT_ENDPOINT_ID;
40use crate::dm::{
41    Access, ArrayAttributeRead, Cluster, Dataver, HandlerContext, InvokeContext, LifecycleOp,
42    ReadContext,
43};
44use crate::error::{Error, ErrorCode};
45use crate::fabric::MAX_FABRICS;
46use crate::im::encoding::GenericPath;
47use crate::persist::{KvBlobStore, Persist, ICD_REGISTERED_CLIENTS_KEY};
48use crate::sc::checkin::{CheckIn, CheckInCounter};
49use crate::tlv::{FromTLV, TLVBuilderParent, TLVElement, ToTLV};
50use crate::utils::cell::RefCell;
51use crate::utils::init::{init, Init};
52use crate::utils::storage::Vec;
53use crate::utils::sync::blocking::Mutex;
54use crate::utils::sync::Notification;
55use crate::with;
56use crate::Matter;
57
58pub use crate::dm::clusters::decl::icd_management::*;
59
60/// The maximum number of clients that can register per fabric — the value
61/// reported by the cluster's `ClientsSupportedPerFabric` attribute.
62///
63/// The spec floor is 1; two (matching CHIP's default) covers a fabric whose
64/// ecosystem monitors the device from more than one client. Raise it if a
65/// fabric needs still more independent Check-In clients.
66pub const CLIENTS_PER_FABRIC: usize = 2;
67
68/// The total capacity of the registration store, across all fabrics.
69pub const MAX_REGISTERED_CLIENTS: usize = CLIENTS_PER_FABRIC * MAX_FABRICS;
70
71/// The maximum stay-active duration (milliseconds) a `StayActiveRequest` will be
72/// honored for — the "guaranteed" duration the device must be able to grant. A
73/// request longer than this is clamped to it (though the *promised* remaining
74/// time may still be longer if the deadline was already further out).
75pub const STAY_ACTIVE_MAX_MS: u32 = 30_000;
76
77/// A single client registration (one entry of the `RegisteredClients` list).
78///
79/// Fabric-scoped: an entry belongs to the fabric it was registered on and is
80/// only ever matched, replaced or removed within that fabric.
81#[derive(Debug, Clone, FromTLV, ToTLV)]
82#[cfg_attr(feature = "defmt", derive(defmt::Format))]
83pub struct MonitoringRegistration {
84    /// The fabric this registration belongs to.
85    pub fab_idx: NonZeroU8,
86    /// The node to which Check-In messages are sent.
87    pub check_in_node_id: u64,
88    /// The subject whose active subscription suppresses Check-Ins for this entry.
89    pub monitored_subject: u64,
90    /// The client's type (permanent or ephemeral).
91    pub client_type: ClientTypeEnum,
92    /// The shared symmetric key used to encrypt this client's Check-In messages.
93    ///
94    /// Write-only from the outside: it is provided at registration and used to
95    /// build Check-In messages, but never read back as an attribute.
96    pub key: CanonAeadKey,
97}
98
99/// The outcome of checking a presented verification key against a stored
100/// registration, used to gate non-administrator register/unregister requests.
101#[derive(Debug, Clone, Copy, Eq, PartialEq)]
102pub enum KeyVerdict {
103    /// No registration exists for the given `(fabric, node)`.
104    NotFound,
105    /// A registration exists and the presented key matches its stored key.
106    Match,
107    /// A registration exists but the presented key is absent or does not match.
108    Mismatch,
109}
110
111/// The timing parameters an ICD advertises through the cluster's mandatory
112/// mode-duration / threshold attributes.
113///
114/// These describe the device's own power-management behavior; the application
115/// supplies them.
116#[derive(Debug, Clone, Copy, Eq, PartialEq, Hash)]
117#[cfg_attr(feature = "defmt", derive(defmt::Format))]
118pub struct IcdModeConfig {
119    /// Maximum time (seconds) the device may stay in idle mode. Must not be
120    /// smaller than `active_mode_duration_ms` converted to seconds.
121    pub idle_mode_duration_s: u32,
122    /// Minimum time (milliseconds) the device stays active after leaving idle.
123    pub active_mode_duration_ms: u32,
124    /// Minimum time (milliseconds) the device stays active after network
125    /// activity. Also the ICD Check-In application data.
126    pub active_mode_threshold_ms: u16,
127    /// The `UserActiveModeTriggerHint` bitmap: how a user can return the device
128    /// to active mode. `0` means no trigger advertised.
129    pub user_active_mode_trigger_hint: u32,
130    /// The `UserActiveModeTriggerInstruction` string paired with the hint (empty
131    /// when the hint needs no free-form instruction). Must be `<= 128` bytes.
132    pub user_active_mode_trigger_instruction: &'static str,
133}
134
135/// The interior, mutable ICD state guarded by a single lock: the registrations,
136/// the Check-In counter, and the stay-active deadline. These are always touched
137/// together, so one lock keeps them consistent and cheap.
138struct IcdState {
139    /// The registered Check-In clients (persisted).
140    clients: Vec<MonitoringRegistration, MAX_REGISTERED_CLIENTS>,
141    /// The Check-In counter.
142    counter: CheckInCounter,
143    /// The instant until which a `StayActiveRequest` has asked this device to
144    /// stay active, or `None` if no request is outstanding.
145    stay_active_until: Option<Instant>,
146}
147
148impl IcdState {
149    fn init(counter: CheckInCounter) -> impl Init<Self> {
150        init!(Self {
151            clients <- Vec::init(),
152            counter: counter,
153            stay_active_until: None,
154        })
155    }
156}
157
158/// The shared ICD state.
159///
160/// The cluster handler ([`IcdMgmtHandler`]) and the Check-In sender both operate
161/// on one instance the application owns and lends to each: the handler mutates
162/// the registrations and reads the counter; the sender reads the registrations
163/// and advances the counter. All of it lives behind a single lock.
164///
165/// Two [`Notification`]s (outside the lock) let the application react: the
166/// registration set changing ([`wait_registrations_changed`](Self::wait_registrations_changed))
167/// and the stay-active deadline being extended ([`wait_active_extended`](Self::wait_active_extended)).
168pub struct Icd {
169    state: Mutex<RefCell<IcdState>>,
170    /// The advertised mode timings; `active_mode_threshold_ms` is also the
171    /// Check-In application data.
172    mode: IcdModeConfig,
173    /// Signalled whenever the registration set changes.
174    registrations_changed: Notification,
175    /// Signalled whenever the stay-active deadline is extended.
176    active_extended: Notification,
177}
178
179impl Icd {
180    /// Create the ICD state from a starting Check-In counter and mode timings.
181    ///
182    /// `counter` should be constructed from the persisted counter value (or a
183    /// random one on first use); persist [`CheckInCounter::persist_value`] right
184    /// after, as its docs describe.
185    pub const fn new(counter: CheckInCounter, mode: IcdModeConfig) -> Self {
186        Self {
187            state: Mutex::new(RefCell::new(IcdState {
188                clients: Vec::new(),
189                counter,
190                stay_active_until: None,
191            })),
192            mode,
193            registrations_changed: Notification::new(),
194            active_extended: Notification::new(),
195        }
196    }
197
198    /// An in-place initializer, mirroring [`Self::new`]. Prefer this over `new`
199    /// to avoid the registration array transiting the stack.
200    pub fn init(counter: CheckInCounter, mode: IcdModeConfig) -> impl Init<Self> {
201        init!(Self {
202            state <- Mutex::init(RefCell::init(IcdState::init(counter))),
203            mode: mode,
204            registrations_changed <- Notification::init(),
205            active_extended <- Notification::init(),
206        })
207    }
208
209    /// The mode timings this ICD advertises.
210    pub fn mode(&self) -> IcdModeConfig {
211        self.mode
212    }
213
214    // --- Registrations ---
215
216    /// The total number of registrations across all fabrics.
217    pub fn registrations_len(&self) -> usize {
218        self.state.lock(|s| s.borrow().clients.len())
219    }
220
221    /// Whether there are no registrations.
222    pub fn registrations_is_empty(&self) -> bool {
223        self.registrations_len() == 0
224    }
225
226    /// The current operating mode: `LIT` while any client is registered,
227    /// otherwise `SIT`.
228    ///
229    /// This is the value of the `OperatingMode` attribute and of the `ICD`
230    /// operational DNS-SD TXT key. It changes only when the registration set
231    /// transitions empty↔non-empty, so
232    /// [`wait_registrations_changed`](Self::wait_registrations_changed) is the
233    /// signal to re-read it (e.g. to re-advertise mDNS).
234    pub fn operating_mode(&self) -> OperatingModeEnum {
235        if self.registrations_is_empty() {
236            OperatingModeEnum::SIT
237        } else {
238            OperatingModeEnum::LIT
239        }
240    }
241
242    /// The number of registrations on `fab_idx`.
243    pub fn fabric_registrations_len(&self, fab_idx: NonZeroU8) -> usize {
244        self.state.lock(|s| {
245            s.borrow()
246                .clients
247                .iter()
248                .filter(|c| c.fab_idx == fab_idx)
249                .count()
250        })
251    }
252
253    /// Register a client, or update the existing registration with the same
254    /// `(fab_idx, check_in_node_id)`.
255    ///
256    /// Returns `Err(ResourceExhausted)` if a *new* entry would exceed the
257    /// per-fabric limit ([`CLIENTS_PER_FABRIC`]).
258    pub fn register(&self, registration: MonitoringRegistration) -> Result<(), Error> {
259        self.state.lock(|s| -> Result<(), Error> {
260            let clients = &mut s.borrow_mut().clients;
261
262            if let Some(existing) = clients.iter_mut().find(|c| {
263                c.fab_idx == registration.fab_idx
264                    && c.check_in_node_id == registration.check_in_node_id
265            }) {
266                *existing = registration;
267            } else {
268                if clients
269                    .iter()
270                    .filter(|c| c.fab_idx == registration.fab_idx)
271                    .count()
272                    >= CLIENTS_PER_FABRIC
273                {
274                    Err(ErrorCode::ResourceExhausted)?;
275                }
276                clients
277                    .push(registration)
278                    .map_err(|_| ErrorCode::ResourceExhausted)?;
279            }
280
281            Ok(())
282        })?;
283
284        self.registrations_changed.notify();
285
286        Ok(())
287    }
288
289    /// Remove the registration for `(fab_idx, check_in_node_id)`.
290    ///
291    /// Returns `Err(NotFound)` if there is no such registration.
292    pub fn unregister(&self, fab_idx: NonZeroU8, check_in_node_id: u64) -> Result<(), Error> {
293        let removed = self.state.lock(|s| {
294            let clients = &mut s.borrow_mut().clients;
295            let before = clients.len();
296            clients.retain(|c| !(c.fab_idx == fab_idx && c.check_in_node_id == check_in_node_id));
297            clients.len() != before
298        });
299
300        if !removed {
301            Err(ErrorCode::NotFound)?;
302        }
303
304        self.registrations_changed.notify();
305
306        Ok(())
307    }
308
309    /// Check a presented verification `key` against the stored registration for
310    /// `(fab_idx, check_in_node_id)`.
311    ///
312    /// Non-administrator clients may only modify or remove an entry they own,
313    /// proven by re-presenting the same key the entry was registered with. A
314    /// missing or wrong key yields [`KeyVerdict::Mismatch`].
315    pub fn verify_key(
316        &self,
317        fab_idx: NonZeroU8,
318        check_in_node_id: u64,
319        key: Option<&[u8]>,
320    ) -> KeyVerdict {
321        self.state.lock(|s| {
322            let state = s.borrow();
323            let Some(entry) = state
324                .clients
325                .iter()
326                .find(|c| c.fab_idx == fab_idx && c.check_in_node_id == check_in_node_id)
327            else {
328                return KeyVerdict::NotFound;
329            };
330
331            match key {
332                Some(key) if key == entry.key.access() => KeyVerdict::Match,
333                _ => KeyVerdict::Mismatch,
334            }
335        })
336    }
337
338    /// Drop every registration belonging to `fab_idx`.
339    ///
340    /// Call when a fabric is removed. Returns whether anything was removed.
341    pub fn remove_fabric(&self, fab_idx: NonZeroU8) -> bool {
342        let removed = self.state.lock(|s| {
343            let clients = &mut s.borrow_mut().clients;
344            let before = clients.len();
345            clients.retain(|c| c.fab_idx != fab_idx);
346            clients.len() != before
347        });
348
349        if removed {
350            self.registrations_changed.notify();
351        }
352
353        removed
354    }
355
356    /// Run `f` with the registrations while the lock is held.
357    ///
358    /// The closure runs under the lock, so it must not re-enter the ICD state and
359    /// must not `.await`.
360    pub fn with_registrations<R>(&self, f: impl FnOnce(&[MonitoringRegistration]) -> R) -> R {
361        self.state.lock(|s| f(&s.borrow().clients))
362    }
363
364    /// Wait until the set of registrations changes.
365    pub async fn wait_registrations_changed(&self) {
366        self.registrations_changed.wait().await;
367    }
368
369    /// Re-hydrate the registrations from `kv`. Call once at startup, before
370    /// exposing the data model.
371    pub fn load_registrations<S: KvBlobStore>(
372        &self,
373        mut kv: S,
374        buf: &mut [u8],
375    ) -> Result<(), Error> {
376        let clients = match kv.load(ICD_REGISTERED_CLIENTS_KEY, buf)? {
377            Some(data) => Vec::from_tlv(&TLVElement::new(data))?,
378            None => Vec::new(),
379        };
380
381        self.state.lock(|s| s.borrow_mut().clients = clients);
382
383        Ok(())
384    }
385
386    /// Persist the current registrations to `ctx.kv()`.
387    pub fn store_registrations<C: HandlerContext>(&self, ctx: &C) -> Result<(), Error> {
388        let mut persist = Persist::new(ctx.kv());
389
390        self.state
391            .lock(|s| persist.store_tlv(ICD_REGISTERED_CLIENTS_KEY, &s.borrow().clients))?;
392
393        persist.run()
394    }
395
396    // --- Stay-active deadline ---
397
398    /// The instant until which a client has asked this device to stay active via
399    /// `StayActiveRequest`, or `None` if no such request is outstanding.
400    ///
401    /// NOTE: this reflects *only* the `StayActiveRequest`-driven deadline. A real
402    /// ICD stays active for other reasons too — a baseline period after waking
403    /// (`ActiveModeDuration`) and a top-up after each message
404    /// (`ActiveModeThreshold`). Those depend on the device's own power state, so
405    /// they are the firmware's concern: combine this deadline with your own when
406    /// deciding whether it is safe to sleep.
407    pub fn active_until(&self) -> Option<Instant> {
408        self.state.lock(|s| s.borrow().stay_active_until)
409    }
410
411    /// Wait until [`active_until`](Self::active_until) is extended by a new
412    /// `StayActiveRequest`, so a sleep loop can re-read the deadline.
413    pub async fn wait_active_extended(&self) {
414        self.active_extended.wait().await;
415    }
416
417    /// Extend the stay-active deadline by `duration_ms` from now, returning the
418    /// resulting remaining active time in milliseconds.
419    ///
420    /// The deadline only ever moves later: `deadline = max(deadline, now + d)`.
421    /// So the returned value can exceed `duration_ms` if an earlier request
422    /// already extended further — it is the *actual* remaining time, which is
423    /// what the `StayActiveResponse` promises.
424    fn extend_active(&self, duration_ms: u32) -> u32 {
425        let now = Instant::now();
426        let requested = now.saturating_add(Duration::from_millis(duration_ms as u64));
427
428        let deadline = self.state.lock(|s| {
429            let stay = &mut s.borrow_mut().stay_active_until;
430            let deadline = stay.map_or(requested, |current| current.max(requested));
431            *stay = Some(deadline);
432            deadline
433        });
434
435        self.active_extended.notify();
436
437        // Remaining time to the deadline (0 if it somehow already passed).
438        deadline.saturating_duration_since(now).as_millis() as u32
439    }
440
441    // --- Check-In counter ---
442
443    /// The counter value the next Check-In message will use (a peek).
444    pub fn next_counter(&self) -> u32 {
445        self.state.lock(|s| s.borrow().counter.next())
446    }
447
448    /// Advance the Check-In counter after sending, persisting to `kv` when a new
449    /// epoch boundary is crossed.
450    ///
451    /// Call once per Check-In *batch* (all messages in the batch used the same
452    /// [`next_counter`](Self::next_counter) value).
453    pub fn advance_counter<S: KvBlobStore>(&self, mut kv: S, buf: &mut [u8]) -> Result<(), Error> {
454        let to_persist = self.state.lock(|s| s.borrow_mut().counter.advance());
455
456        if let Some(value) = to_persist {
457            kv.store(
458                crate::persist::ICD_CHECK_IN_COUNTER_KEY,
459                &value.to_le_bytes(),
460                buf,
461            )?;
462        }
463
464        Ok(())
465    }
466
467    /// Jump the Check-In counter forward by `delta` (wrapping). Used to
468    /// invalidate outstanding counter values in one step; the new value is
469    /// visible immediately via [`next_counter`](Self::next_counter).
470    ///
471    /// Returns `true` if the jump moved the persist boundary, in which case
472    /// [`persist_counter`](Self::persist_counter) must run before the device
473    /// restarts (defer it if the caller has no storage access here).
474    #[must_use = "a moved boundary must be persisted via persist_counter"]
475    pub fn invalidate_counter(&self, delta: u32) -> bool {
476        self.state
477            .lock(|s| s.borrow_mut().counter.advance_by(delta))
478            .is_some()
479    }
480
481    /// Persist the current Check-In counter boundary to `kv`.
482    pub fn persist_counter<S: KvBlobStore>(&self, mut kv: S, buf: &mut [u8]) -> Result<(), Error> {
483        let value = self.state.lock(|s| s.borrow().counter.persist_value());
484        kv.store(
485            crate::persist::ICD_CHECK_IN_COUNTER_KEY,
486            &value.to_le_bytes(),
487            buf,
488        )
489    }
490
491    /// Load the persisted Check-In counter epoch and reset the counter to resume
492    /// from it. Call once at startup, before any Check-In is sent.
493    pub fn load_counter<S: KvBlobStore>(
494        &self,
495        mut kv: S,
496        epoch: u32,
497        buf: &mut [u8],
498    ) -> Result<(), Error> {
499        let start = match kv.load(crate::persist::ICD_CHECK_IN_COUNTER_KEY, buf)? {
500            Some(data) => u32::from_le_bytes(data.try_into().map_err(|_| ErrorCode::Invalid)?),
501            // No persisted value yet: the caller's initial (random) counter stands.
502            None => return Ok(()),
503        };
504
505        self.state
506            .lock(|s| s.borrow_mut().counter = CheckInCounter::new(start, epoch));
507
508        Ok(())
509    }
510
511    // --- Sending Check-In messages ---
512
513    /// Send a Check-In message to the registered client `(fab_idx, node_id)`,
514    /// using the given counter value.
515    ///
516    /// A per-client convenience over [`CheckIn::send_to`]; it looks up the
517    /// client's key and sends with the ICD application data (the
518    /// `ActiveModeThreshold`). It does *not* advance the counter — the caller
519    /// owns that so a batch can share one value (see [`send_check_in`](Self::send_check_in)).
520    ///
521    /// Requires a running mDNS responder to service the address resolve.
522    pub async fn send_one_check_in<C: Crypto>(
523        &self,
524        matter: &Matter<'_>,
525        crypto: C,
526        fab_idx: NonZeroU8,
527        node_id: u64,
528        counter: u32,
529        buf: &mut [u8],
530    ) -> Result<(), Error> {
531        // Copy the key out under the lock, then send outside it (sending is
532        // `async`; the lock is not held across the `await`).
533        let key = self
534            .state
535            .lock(|s| {
536                s.borrow()
537                    .clients
538                    .iter()
539                    .find(|c| c.fab_idx == fab_idx && c.check_in_node_id == node_id)
540                    .map(|c| c.key.clone())
541            })
542            .ok_or(ErrorCode::NotFound)?;
543
544        let app_data = self.mode.active_mode_threshold_ms.to_le_bytes();
545
546        CheckIn::new(key.reference())
547            .send_to(matter, crypto, fab_idx, node_id, counter, &app_data, buf)
548            .await
549    }
550
551    /// Send a Check-In message to every registered client whose monitored
552    /// subject has **no active subscription** — the clients that have lost touch
553    /// and need a nudge.
554    ///
555    /// All messages in the batch share one counter value; the counter is advanced
556    /// and persisted once at the end. Errors sending to individual clients are
557    /// swallowed (best-effort) so one unreachable client does not block the rest;
558    /// only a counter-persist failure is returned.
559    ///
560    /// Requires a running mDNS responder to service the address resolves.
561    pub async fn send_check_in<C: Crypto, const NS: usize>(
562        &self,
563        matter: &Matter<'_>,
564        crypto: C,
565        subscriptions: &crate::im::subscriptions::Subscriptions<NS>,
566        kv: impl KvBlobStore,
567        buf: &mut [u8],
568    ) -> Result<(), Error> {
569        // Snapshot the eligible clients under the lock — sending is `async`, so
570        // neither the ICD nor the subscriptions lock may be held across an
571        // `await`. The subscription-liveness check is a quick locked lookup.
572        let mut targets: Vec<(NonZeroU8, u64, CanonAeadKey), MAX_REGISTERED_CLIENTS> = Vec::new();
573
574        let counter = self.state.lock(|s| {
575            let state = s.borrow();
576            for c in &state.clients {
577                // A CAT-valued monitored subject won't match here (we compare
578                // against subscriber node ids), so such a client is treated as
579                // unsubscribed and always nudged.
580                if subscriptions.has_subscription_for(c.fab_idx, c.monitored_subject) {
581                    continue;
582                }
583                // Capacity matches the client list, so this cannot overflow.
584                let _ = targets.push((c.fab_idx, c.check_in_node_id, c.key.clone()));
585            }
586            state.counter.next()
587        });
588
589        if targets.is_empty() {
590            return Ok(());
591        }
592
593        let app_data = self.mode.active_mode_threshold_ms.to_le_bytes();
594
595        for (fab_idx, node_id, key) in &targets {
596            // Best-effort: keep sending to the rest even if one fails to resolve.
597            let _ = CheckIn::new(key.reference())
598                .send_to(matter, &crypto, *fab_idx, *node_id, counter, &app_data, buf)
599                .await;
600        }
601
602        self.advance_counter(kv, buf)
603    }
604}
605
606/// The server-side handler for the ICD Management cluster.
607///
608/// Backed by the shared [`Icd`] state: the registration commands mutate its
609/// store, and the ICD Counter reported to clients comes from its counter. Only
610/// the Check-In Protocol subset is implemented — the mode-duration / threshold
611/// attributes plus the `RegisteredClients` / `ICDCounter` /
612/// `ClientsSupportedPerFabric` attributes and the `RegisterClient` /
613/// `UnregisterClient` / `StayActiveRequest` commands.
614pub struct IcdMgmtHandler<'a> {
615    dataver: Dataver,
616    icd: &'a Icd,
617}
618
619impl<'a> IcdMgmtHandler<'a> {
620    /// Create a handler backed by the shared [`Icd`] state.
621    pub const fn new(dataver: Dataver, icd: &'a Icd) -> Self {
622        Self { dataver, icd }
623    }
624
625    /// Adapt this handler to the generic `rs-matter` `Handler` trait.
626    pub const fn adapt(self) -> HandlerAdaptor<Self> {
627        HandlerAdaptor(self)
628    }
629
630    /// The accessing fabric of the current command.
631    fn cmd_fabric(ctx: &impl InvokeContext) -> Result<NonZeroU8, Error> {
632        ctx.accessor()?.fab_idx()
633    }
634
635    /// Whether the caller holds Administer privilege on this command's path.
636    ///
637    /// Administrators may register/unregister any client; everyone else must
638    /// prove ownership of an existing entry with its verification key.
639    fn caller_is_admin(ctx: &impl InvokeContext) -> Result<bool, Error> {
640        let accessor = ctx.accessor()?;
641        let cmd = ctx.cmd();
642        let path = GenericPath::new(
643            Some(cmd.endpoint_id),
644            Some(cmd.cluster_id),
645            Some(cmd.cmd_id),
646        );
647
648        let mut req = AccessReq::new(&accessor, path, Access::WRITE, &[]);
649        req.set_target_perms(Access::WRITE | Access::NEED_ADMIN);
650
651        Ok(req.allow())
652    }
653
654    /// Publish the current operating mode to the mDNS layer. This handler serves
655    /// the LITS feature, so the device is always ICD-capable — the mode flips
656    /// between SIT and LIT as the registration set empties and fills.
657    fn sync_icd_mode(&self, ctx: &impl HandlerContext) {
658        ctx.matter().set_icd_mode(Some(self.icd.operating_mode()));
659    }
660}
661
662impl ClusterHandler for IcdMgmtHandler<'_> {
663    // We claim the full ICD feature set: Check-In Protocol, Long-Idle-Time,
664    // User-Active-Mode-Trigger and Dynamic-SIT-LIT. CIP makes the registration
665    // attributes/commands and MaximumCheckInBackoff mandatory; LITS makes
666    // OperatingMode and StayActiveRequest mandatory; UAT makes
667    // UserActiveModeTriggerHint mandatory; DSLS (which is exactly our
668    // registration-driven SIT/LIT switching) adds only its feature bit.
669    const CLUSTER: Cluster<'static> = FULL_CLUSTER
670        .with_features(
671            Feature::CHECK_IN_PROTOCOL_SUPPORT
672                .union(Feature::LONG_IDLE_TIME_SUPPORT)
673                .union(Feature::USER_ACTIVE_MODE_TRIGGER)
674                .union(Feature::DYNAMIC_SIT_LIT_SUPPORT)
675                .bits(),
676        )
677        .with_attrs(with!(required;
678            AttributeId::RegisteredClients
679                | AttributeId::ICDCounter
680                | AttributeId::ClientsSupportedPerFabric
681                | AttributeId::MaximumCheckInBackOff
682                | AttributeId::OperatingMode
683                | AttributeId::UserActiveModeTriggerHint
684                | AttributeId::UserActiveModeTriggerInstruction));
685
686    fn dataver(&self) -> u32 {
687        self.dataver.get()
688    }
689
690    fn dataver_changed(&self) {
691        self.dataver.changed();
692    }
693
694    fn lifecycle(&self, ctx: impl HandlerContext, op: LifecycleOp) -> Result<(), Error> {
695        match op {
696            // Registration re-hydration and factory reset are app-driven
697            // (`Icd::load_registrations` / KV wipe), since the `Icd` state is
698            // owned by the application and shared with the Check-In machinery.
699            LifecycleOp::Startup | LifecycleOp::FactoryReset => Ok(()),
700            LifecycleOp::FabricRemoval { fab_idx } => {
701                let mode_before = self.icd.operating_mode();
702
703                if self.icd.remove_fabric(fab_idx) {
704                    self.icd.store_registrations(&ctx)?;
705
706                    // Dropping the last LIT registration flips the operating
707                    // mode to SIT - a global (not fabric-scoped) observable,
708                    // so subscribers and the mDNS layer must learn about it.
709                    // ICD Management is a root-node cluster, hence the fixed
710                    // endpoint.
711                    if self.icd.operating_mode() != mode_before {
712                        ctx.notify_attr_changed(
713                            ROOT_ENDPOINT_ID,
714                            Self::CLUSTER.id,
715                            AttributeId::OperatingMode as _,
716                        );
717                    }
718
719                    self.sync_icd_mode(&ctx);
720                }
721
722                Ok(())
723            }
724        }
725    }
726
727    fn idle_mode_duration(&self, _ctx: impl ReadContext) -> Result<u32, Error> {
728        Ok(self.icd.mode.idle_mode_duration_s)
729    }
730
731    fn active_mode_duration(&self, _ctx: impl ReadContext) -> Result<u32, Error> {
732        Ok(self.icd.mode.active_mode_duration_ms)
733    }
734
735    fn active_mode_threshold(&self, _ctx: impl ReadContext) -> Result<u16, Error> {
736        Ok(self.icd.mode.active_mode_threshold_ms)
737    }
738
739    fn clients_supported_per_fabric(&self, _ctx: impl ReadContext) -> Result<u16, Error> {
740        Ok(CLIENTS_PER_FABRIC as u16)
741    }
742
743    // The lower bound of the allowed range: this device does not back its
744    // Check-Ins off, so its maximum equals its idle-mode duration.
745    fn maximum_check_in_back_off(&self, _ctx: impl ReadContext) -> Result<u32, Error> {
746        Ok(self.icd.mode.idle_mode_duration_s)
747    }
748
749    fn operating_mode(&self, _ctx: impl ReadContext) -> Result<OperatingModeEnum, Error> {
750        Ok(self.icd.operating_mode())
751    }
752
753    fn user_active_mode_trigger_hint(
754        &self,
755        _ctx: impl ReadContext,
756    ) -> Result<UserActiveModeTriggerBitmap, Error> {
757        Ok(UserActiveModeTriggerBitmap::from_bits_truncate(
758            self.icd.mode.user_active_mode_trigger_hint,
759        ))
760    }
761
762    fn user_active_mode_trigger_instruction<P: TLVBuilderParent>(
763        &self,
764        _ctx: impl ReadContext,
765        builder: crate::tlv::Utf8StrBuilder<P>,
766    ) -> Result<P, Error> {
767        builder.set(self.icd.mode.user_active_mode_trigger_instruction)
768    }
769
770    fn icd_counter(&self, _ctx: impl ReadContext) -> Result<u32, Error> {
771        Ok(self.icd.next_counter())
772    }
773
774    fn registered_clients<P: TLVBuilderParent>(
775        &self,
776        ctx: impl ReadContext,
777        builder: ArrayAttributeRead<
778            MonitoringRegistrationStructArrayBuilder<P>,
779            MonitoringRegistrationStructBuilder<P>,
780        >,
781    ) -> Result<P, Error> {
782        let attr = ctx.attr();
783        let fab_filter = attr
784            .fab_filter
785            .then(|| NonZeroU8::new(attr.fab_idx).ok_or(ErrorCode::UnsupportedAccess))
786            .transpose()?;
787
788        self.icd.with_registrations(|clients| {
789            let mut iter = clients
790                .iter()
791                .filter(|c| fab_filter.is_none_or(|f| c.fab_idx == f));
792
793            match builder {
794                ArrayAttributeRead::ReadAll(mut array) => {
795                    for c in iter {
796                        array = array
797                            .push()?
798                            .check_in_node_id(Some(c.check_in_node_id))?
799                            .monitored_subject(Some(c.monitored_subject))?
800                            .client_type(Some(c.client_type))?
801                            .fabric_index(Some(c.fab_idx.get()))?
802                            .end()?;
803                    }
804                    array.end()
805                }
806                ArrayAttributeRead::ReadOne(index, item) => {
807                    let Some(c) = iter.nth(index as usize) else {
808                        return Err(ErrorCode::ConstraintError.into());
809                    };
810                    item.check_in_node_id(Some(c.check_in_node_id))?
811                        .monitored_subject(Some(c.monitored_subject))?
812                        .client_type(Some(c.client_type))?
813                        .fabric_index(Some(c.fab_idx.get()))?
814                        .end()
815                }
816                ArrayAttributeRead::ReadNone(array) => array.end(),
817            }
818        })
819    }
820
821    fn handle_register_client<P: TLVBuilderParent>(
822        &self,
823        ctx: impl InvokeContext,
824        request: RegisterClientRequest<'_>,
825        response: RegisterClientResponseBuilder<P>,
826    ) -> Result<P, Error> {
827        let fab_idx = Self::cmd_fabric(&ctx)?;
828        let node_id = request.check_in_node_id()?;
829
830        // A non-administrator replacing an existing entry must present its
831        // verification key. A new entry (NotFound) needs no key.
832        if !Self::caller_is_admin(&ctx)? {
833            let presented = request.verification_key()?.map(|k| k.0);
834            if self.icd.verify_key(fab_idx, node_id, presented) == KeyVerdict::Mismatch {
835                Err(ErrorCode::Failure)?;
836            }
837        }
838
839        let key = request.key()?;
840
841        self.icd.register(MonitoringRegistration {
842            fab_idx,
843            check_in_node_id: node_id,
844            monitored_subject: request.monitored_subject()?,
845            // An out-of-range client type or wrong-length key is a constraint
846            // violation, not a generic failure.
847            client_type: request
848                .client_type()
849                .map_err(|_| ErrorCode::ConstraintError)?,
850            key: key.0.try_into().map_err(|_| ErrorCode::ConstraintError)?,
851        })?;
852
853        self.icd.store_registrations(&ctx)?;
854        ctx.notify_own_cluster_changed();
855        self.sync_icd_mode(&ctx);
856
857        // The client stores this as its starting Check-In counter reference.
858        response.icd_counter(self.icd.next_counter())?.end()
859    }
860
861    fn handle_unregister_client(
862        &self,
863        ctx: impl InvokeContext,
864        request: UnregisterClientRequest<'_>,
865    ) -> Result<(), Error> {
866        let fab_idx = Self::cmd_fabric(&ctx)?;
867        let node_id = request.check_in_node_id()?;
868
869        // A non-administrator must prove ownership with the verification key
870        // before the entry is removed; a missing entry is `NotFound` regardless.
871        if !Self::caller_is_admin(&ctx)? {
872            let presented = request.verification_key()?.map(|k| k.0);
873            match self.icd.verify_key(fab_idx, node_id, presented) {
874                KeyVerdict::NotFound => Err(ErrorCode::NotFound)?,
875                KeyVerdict::Mismatch => Err(ErrorCode::Failure)?,
876                KeyVerdict::Match => {}
877            }
878        }
879
880        self.icd.unregister(fab_idx, node_id)?;
881
882        self.icd.store_registrations(&ctx)?;
883        ctx.notify_own_cluster_changed();
884        self.sync_icd_mode(&ctx);
885
886        Ok(())
887    }
888
889    fn handle_stay_active_request<P: TLVBuilderParent>(
890        &self,
891        _ctx: impl InvokeContext,
892        request: StayActiveRequestRequest<'_>,
893        response: StayActiveResponseBuilder<P>,
894    ) -> Result<P, Error> {
895        // Honor at most the maximum guaranteed stay-active duration, then extend
896        // the deadline and report the actual resulting remaining time (which may
897        // be longer if a prior request already extended further).
898        let requested = request.stay_active_duration()?.min(STAY_ACTIVE_MAX_MS);
899        let promised = self.icd.extend_active(requested);
900
901        response.promised_active_duration(promised)?.end()
902    }
903}
904
905#[cfg(test)]
906mod tests {
907    use super::*;
908
909    fn fab(i: u8) -> NonZeroU8 {
910        NonZeroU8::new(i).unwrap()
911    }
912
913    fn reg(fab: u8, node: u64) -> MonitoringRegistration {
914        MonitoringRegistration {
915            fab_idx: NonZeroU8::new(fab).unwrap(),
916            check_in_node_id: node,
917            monitored_subject: node,
918            client_type: ClientTypeEnum::Permanent,
919            key: CanonAeadKey::new(),
920        }
921    }
922
923    fn icd() -> Icd {
924        Icd::new(CheckInCounter::new(0, 10), mode())
925    }
926
927    /// Collect the node ids of the registrations matching `fab_filter`.
928    fn nodes(icd: &Icd, fab_filter: Option<NonZeroU8>) -> alloc::vec::Vec<u64> {
929        icd.with_registrations(|clients| {
930            clients
931                .iter()
932                .filter(|c| fab_filter.is_none_or(|f| c.fab_idx == f))
933                .map(|c| c.check_in_node_id)
934                .collect()
935        })
936    }
937
938    #[test]
939    fn register_adds_and_updates() {
940        let icd = icd();
941
942        icd.register(reg(1, 100)).unwrap();
943        assert_eq!(icd.registrations_len(), 1);
944        assert_eq!(icd.fabric_registrations_len(fab(1)), 1);
945
946        // Same (fabric, node) -> update in place, not a second entry.
947        let mut updated = reg(1, 100);
948        updated.monitored_subject = 999;
949        icd.register(updated).unwrap();
950        assert_eq!(icd.registrations_len(), 1);
951        let subject = icd.with_registrations(|c| c[0].monitored_subject);
952        assert_eq!(subject, 999);
953    }
954
955    #[test]
956    fn per_fabric_limit_is_enforced_independently() {
957        let icd = icd();
958
959        // Fill fabric 1 up to the per-fabric limit.
960        for i in 0..CLIENTS_PER_FABRIC {
961            icd.register(reg(1, 100 + i as u64)).unwrap();
962        }
963        assert_eq!(icd.fabric_registrations_len(fab(1)), CLIENTS_PER_FABRIC);
964
965        // One more distinct client on fabric 1 exceeds the limit...
966        assert!(icd
967            .register(reg(1, 100 + CLIENTS_PER_FABRIC as u64))
968            .is_err());
969        assert_eq!(icd.fabric_registrations_len(fab(1)), CLIENTS_PER_FABRIC);
970
971        // ...updating an existing fabric-1 client still works...
972        icd.register(reg(1, 100)).unwrap();
973        assert_eq!(icd.fabric_registrations_len(fab(1)), CLIENTS_PER_FABRIC);
974
975        // ...and fabric 2 has its own independent budget.
976        icd.register(reg(2, 200)).unwrap();
977        assert_eq!(icd.fabric_registrations_len(fab(2)), 1);
978    }
979
980    #[test]
981    fn unregister_and_remove_fabric() {
982        let icd = icd();
983        icd.register(reg(1, 100)).unwrap();
984        icd.register(reg(2, 200)).unwrap();
985
986        assert!(icd.unregister(fab(1), 999).is_err()); // no such node
987        icd.unregister(fab(1), 100).unwrap();
988        assert_eq!(icd.registrations_len(), 1);
989
990        // Removing a fabric drops only its entries.
991        assert!(icd.remove_fabric(fab(2)));
992        assert!(icd.registrations_is_empty());
993        assert!(!icd.remove_fabric(fab(2))); // nothing left
994    }
995
996    #[test]
997    fn operating_mode_follows_the_registration_set() {
998        let icd = icd();
999        assert_eq!(icd.operating_mode(), OperatingModeEnum::SIT);
1000
1001        icd.register(reg(1, 100)).unwrap();
1002        assert_eq!(icd.operating_mode(), OperatingModeEnum::LIT);
1003
1004        icd.register(reg(1, 101)).unwrap();
1005        icd.unregister(fab(1), 100).unwrap();
1006        assert_eq!(icd.operating_mode(), OperatingModeEnum::LIT);
1007
1008        icd.unregister(fab(1), 101).unwrap();
1009        assert_eq!(icd.operating_mode(), OperatingModeEnum::SIT);
1010    }
1011
1012    #[test]
1013    fn verify_key_matches_only_the_stored_key() {
1014        let icd = icd();
1015
1016        let mut r = reg(1, 100);
1017        let stored = [7u8; 16];
1018        r.key.try_load_from_slice(&stored).unwrap();
1019        icd.register(r).unwrap();
1020
1021        // Unknown node.
1022        assert_eq!(
1023            icd.verify_key(fab(1), 999, Some(&stored)),
1024            KeyVerdict::NotFound
1025        );
1026        // Right node, wrong fabric.
1027        assert_eq!(
1028            icd.verify_key(fab(2), 100, Some(&stored)),
1029            KeyVerdict::NotFound
1030        );
1031        // Correct key.
1032        assert_eq!(
1033            icd.verify_key(fab(1), 100, Some(&stored)),
1034            KeyVerdict::Match
1035        );
1036        // Wrong key and absent key both mismatch.
1037        assert_eq!(
1038            icd.verify_key(fab(1), 100, Some(&[0u8; 16])),
1039            KeyVerdict::Mismatch
1040        );
1041        assert_eq!(icd.verify_key(fab(1), 100, None), KeyVerdict::Mismatch);
1042    }
1043
1044    #[test]
1045    fn with_registrations_honors_the_fabric_filter() {
1046        let icd = icd();
1047        icd.register(reg(1, 100)).unwrap();
1048        icd.register(reg(2, 200)).unwrap();
1049
1050        let mut all = nodes(&icd, None);
1051        all.sort_unstable();
1052        assert_eq!(all, [100, 200]);
1053
1054        assert_eq!(nodes(&icd, Some(fab(1))), [100]);
1055    }
1056
1057    /// A minimal in-memory single-key store, enough to test the counter
1058    /// persist/reload roundtrip.
1059    #[derive(Default)]
1060    struct MemKv {
1061        value: Option<alloc::vec::Vec<u8>>,
1062    }
1063
1064    impl KvBlobStore for &mut MemKv {
1065        fn load<'a>(&mut self, _key: u16, buf: &'a mut [u8]) -> Result<Option<&'a [u8]>, Error> {
1066            Ok(self.value.as_ref().map(|v| {
1067                buf[..v.len()].copy_from_slice(v);
1068                &buf[..v.len()]
1069            }))
1070        }
1071
1072        fn store(&mut self, _key: u16, data: &[u8], _buf: &mut [u8]) -> Result<(), Error> {
1073            self.value = Some(data.to_vec());
1074            Ok(())
1075        }
1076
1077        fn remove(&mut self, _key: u16, _buf: &mut [u8]) -> Result<(), Error> {
1078            self.value = None;
1079            Ok(())
1080        }
1081    }
1082
1083    fn mode() -> IcdModeConfig {
1084        IcdModeConfig {
1085            idle_mode_duration_s: 60,
1086            active_mode_duration_ms: 300,
1087            active_mode_threshold_ms: 500,
1088            user_active_mode_trigger_hint: 0,
1089            user_active_mode_trigger_instruction: "",
1090        }
1091    }
1092
1093    #[test]
1094    fn stay_active_combines_with_max_and_reports_remaining() {
1095        let icd = Icd::new(CheckInCounter::new(0, 10), mode());
1096
1097        // No request yet: no stay-active deadline.
1098        assert!(icd.active_until().is_none());
1099
1100        // A request sets the deadline and promises ~its duration.
1101        let promised = icd.extend_active(STAY_ACTIVE_MAX_MS);
1102        assert!(promised <= STAY_ACTIVE_MAX_MS);
1103        assert!(promised > STAY_ACTIVE_MAX_MS - 1_000, "promised {promised}");
1104        let deadline = icd.active_until().expect("deadline now set");
1105
1106        // A shorter request does NOT shrink the deadline (max-combine): it still
1107        // promises ~the earlier, longer remaining time, not its own 1s.
1108        let promised2 = icd.extend_active(1_000);
1109        assert!(
1110            promised2 > 1_000,
1111            "shorter request must not shrink: {promised2}"
1112        );
1113        assert_eq!(icd.active_until(), Some(deadline), "deadline unchanged");
1114
1115        // A longer request DOES push the deadline out.
1116        icd.extend_active(2 * STAY_ACTIVE_MAX_MS);
1117        assert!(icd.active_until().unwrap() > deadline);
1118    }
1119
1120    #[test]
1121    fn stay_active_request_clamps_to_the_guaranteed_max() {
1122        // The clamp lives in the command handler, not `extend_active` — verify it
1123        // via the same `.min(STAY_ACTIVE_MAX_MS)` the handler applies.
1124        let icd = Icd::new(CheckInCounter::new(0, 10), mode());
1125
1126        let requested = STAY_ACTIVE_MAX_MS + 5_000;
1127        let promised = icd.extend_active(requested.min(STAY_ACTIVE_MAX_MS));
1128        assert!(promised <= STAY_ACTIVE_MAX_MS, "must clamp: {promised}");
1129    }
1130
1131    #[test]
1132    fn counter_persists_at_boundary_and_resumes_across_restart() {
1133        const EPOCH: u32 = 10;
1134        let mut kv = MemKv::default();
1135        let mut buf = [0u8; 16];
1136
1137        // Session 1: counter starts at 100, boundary at 110.
1138        let icd = Icd::new(CheckInCounter::new(100, EPOCH), mode());
1139
1140        // Peeks are stable; advancing before the boundary writes nothing.
1141        assert_eq!(icd.next_counter(), 101);
1142        for _ in 0..9 {
1143            icd.advance_counter(&mut kv, &mut buf).unwrap();
1144        }
1145        assert_eq!(kv.value, None, "no persist before the boundary");
1146
1147        // Crossing the boundary persists the next one (120).
1148        let last_used = icd.next_counter();
1149        icd.advance_counter(&mut kv, &mut buf).unwrap();
1150        assert_eq!(last_used, 110);
1151        assert!(kv.value.is_some(), "boundary crossing must persist");
1152
1153        // Session 2 (a restart): a fresh Icd whose counter resumes from the
1154        // persisted boundary. Every value it hands out is past session 1's.
1155        let icd2 = Icd::new(CheckInCounter::new(0, EPOCH), mode());
1156        icd2.load_counter(&mut kv, EPOCH, &mut buf).unwrap();
1157        assert!(icd2.next_counter() > last_used);
1158    }
1159}