Skip to main content

rs_matter/dm/clusters/
gen_comm.rs

1/*
2 *
3 *    Copyright (c) 2022-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//! This module contains the implementation of the General Commissioning cluster and its handler.
19
20use core::fmt::Debug;
21
22use either::Either;
23
24use rand_core::RngCore;
25
26use crate::crypto::Crypto;
27use crate::dm::clusters::net_comm::NetworksAccess;
28use crate::dm::{Cluster, Dataver, InvokeContext, OperationContext, ReadContext, WriteContext};
29use crate::error::{Error, ErrorCode};
30use crate::fabric::FabricPersist;
31use crate::persist::{Persist, NETWORKS_KEY};
32use crate::sc::pase::MAX_COMM_WINDOW_TIMEOUT_SECS;
33use crate::tlv::{Nullable, Octets, OctetsBuilder, TLVBuilderParent};
34use crate::transport::session::SessionMode;
35use crate::utils::sync::DynBase;
36use crate::{except, with, MatterState};
37
38pub use crate::dm::clusters::decl::general_commissioning::*;
39
40impl CommissioningErrorEnum {
41    fn map(result: Result<(), Error>) -> Result<Self, Error> {
42        Self::map_result(result).map(Self::ok)
43    }
44
45    fn map_result<T>(result: Result<T, Error>) -> Result<Either<T, Self>, Error> {
46        match result {
47            Ok(value) => Ok(Either::Left(value)),
48            Err(err) => match err.code() {
49                ErrorCode::Busy | ErrorCode::NocInvalidFabricIndex => {
50                    Ok(Either::Right(Self::BusyWithOtherAdmin))
51                }
52                ErrorCode::GennCommInvalidAuthentication => {
53                    Ok(Either::Right(Self::InvalidAuthentication))
54                }
55                ErrorCode::FailSafeRequired => Ok(Either::Right(Self::NoFailSafe)),
56                _ => Err(err),
57            },
58        }
59    }
60
61    fn ok<T>(value: Either<T, Self>) -> Self {
62        match value {
63            Either::Left(_) => Self::OK,
64            Either::Right(code) => code,
65        }
66    }
67}
68
69/// A trait indicating the commissioning policy supported by `rs-matter`.
70/// (i.e. co-existence of the BLE/BTP network and the operational network during commissioning).
71pub trait CommPolicy: DynBase {
72    /// Return true if the device supports concurrent connection
73    /// (i.e. co-existence of the BLE/BTP network and the operational network during commissioning).
74    fn concurrent_connection_supported(&self) -> bool;
75
76    /// Return the expiry length of the fail-safe in seconds.
77    fn failsafe_expiry_len_secs(&self) -> u16;
78
79    /// Return the maximum cumulative fail-safe time in seconds.
80    fn failsafe_max_cml_secs(&self) -> u16;
81
82    /// Return the location capability of the device.
83    fn location_cap(&self) -> RegulatoryLocationTypeEnum;
84}
85
86impl<T> CommPolicy for &T
87where
88    T: CommPolicy,
89{
90    fn concurrent_connection_supported(&self) -> bool {
91        (*self).concurrent_connection_supported()
92    }
93
94    fn failsafe_expiry_len_secs(&self) -> u16 {
95        (*self).failsafe_expiry_len_secs()
96    }
97
98    fn failsafe_max_cml_secs(&self) -> u16 {
99        (*self).failsafe_max_cml_secs()
100    }
101
102    fn location_cap(&self) -> RegulatoryLocationTypeEnum {
103        (*self).location_cap()
104    }
105}
106
107impl DynBase for bool {}
108
109impl CommPolicy for bool {
110    fn concurrent_connection_supported(&self) -> bool {
111        *self
112    }
113
114    fn failsafe_expiry_len_secs(&self) -> u16 {
115        120
116    }
117
118    fn failsafe_max_cml_secs(&self) -> u16 {
119        // Aligned with the Matter reference SDK example implementations and
120        // with `MAX_COMM_WINDOW_TIMEOUT_SECS` in `sc::pase`. Some Python tests
121        // (e.g. TC_ACL_2_9) read this attribute and reuse it as the
122        // `commissioning_timeout` for `OpenCommissioningWindow`, which the spec
123        // bounds at [180, 900] seconds; reporting 900 keeps such tests within
124        // the valid range while still being a reasonable upper bound.
125        MAX_COMM_WINDOW_TIMEOUT_SECS
126    }
127
128    fn location_cap(&self) -> RegulatoryLocationTypeEnum {
129        RegulatoryLocationTypeEnum::IndoorOutdoor
130    }
131}
132
133/// The system implementation of a handler for the General Commissioning Matter cluster.
134pub struct GenCommHandler<'a> {
135    dataver: Dataver,
136    commissioning_policy: &'a dyn CommPolicy,
137}
138
139impl<'a> GenCommHandler<'a> {
140    /// Create a new instance of `GenCommHandler` with the given `Dataver` and `CommissioningPolicy`.
141    pub const fn new(dataver: Dataver, commissioning_policy: &'a dyn CommPolicy) -> Self {
142        Self {
143            dataver,
144            commissioning_policy,
145        }
146    }
147
148    /// Adapt the handler instance to the generic `rs-matter` `Handler` trait
149    pub const fn adapt(self) -> HandlerAdaptor<Self> {
150        HandlerAdaptor(self)
151    }
152
153    /// Execute the provided closure after checking that the failsafe is armed for the
154    /// fabric of this session.
155    ///
156    /// If the check fail, an appropriate error is returned.
157    pub(crate) fn with_armed_failsafe<F, T>(ctx: impl OperationContext, f: F) -> Result<T, Error>
158    where
159        F: FnOnce(&mut MatterState, &mut dyn FnMut()) -> Result<T, Error>,
160    {
161        Self::with_armed_failsafe_ex(ctx, f)
162    }
163
164    /// Return whether the supplied `NewRegulatoryConfig` value is allowed
165    /// given the device's `LocationCapability`. Mirrors the matrix in Matter
166    /// Core spec.
167    fn is_regulatory_config_supported(
168        policy: &dyn CommPolicy,
169        new_config: RegulatoryLocationTypeEnum,
170    ) -> bool {
171        match policy.location_cap() {
172            RegulatoryLocationTypeEnum::Indoor => {
173                matches!(new_config, RegulatoryLocationTypeEnum::Indoor)
174            }
175            RegulatoryLocationTypeEnum::Outdoor => {
176                matches!(new_config, RegulatoryLocationTypeEnum::Outdoor)
177            }
178            RegulatoryLocationTypeEnum::IndoorOutdoor => true,
179        }
180    }
181
182    /// Execute the provided closure after checking that the failsafe is armed for the
183    /// fabric of this session.
184    ///
185    /// If the check fail, an appropriate error is returned.
186    pub(crate) fn with_armed_failsafe_ex<F, T, E>(ctx: impl OperationContext, f: F) -> Result<T, E>
187    where
188        F: FnOnce(&mut MatterState, &mut dyn FnMut()) -> Result<T, E>,
189        E: From<Error>,
190    {
191        let mut notify_mdns = || ctx.exchange().matter().transport().notify_mdns_changed();
192
193        ctx.exchange().with_state_ex(|state| {
194            let sess = ctx.exchange().id().session(&mut state.sessions);
195
196            state
197                .failsafe
198                .check_armed(sess.get_session_mode())
199                .map_err(|err| match err.code() {
200                    ErrorCode::NocInvalidFabricIndex => {
201                        Error::new(ErrorCode::GennCommInvalidAuthentication)
202                    }
203                    _ => err,
204                })?;
205
206            f(state, &mut notify_mdns)
207        })
208    }
209
210    /// Return the node's `RecoveryIdentifier`, minting and persisting a stable
211    /// random 64-bit value on first access.
212    fn recovery_identifier_value(ctx: &impl ReadContext) -> Result<u64, Error> {
213        // Fast path: the value has already been minted (this read, a prior
214        // read, or a reload from persistence).
215        if let Some(id) = ctx
216            .exchange()
217            .with_state(|state| Ok(state.basic_info_settings.recovery_identifier))?
218        {
219            return Ok(id);
220        }
221
222        // Mint a fresh 64-bit value from the CSPRNG.
223        let fresh = ctx.crypto().rand()?.next_u64();
224
225        let mut persist = Persist::new(ctx.kv());
226
227        let id = ctx.exchange().with_state(|state| {
228            // Re-check under the state borrow: a concurrent read may have
229            // minted the value first, in which case we keep the stored one.
230            let id = *state
231                .basic_info_settings
232                .recovery_identifier
233                .get_or_insert(fresh);
234
235            state.basic_info_settings.store_persist(&mut persist)?;
236
237            Ok(id)
238        })?;
239
240        persist.run()?;
241
242        Ok(id)
243    }
244}
245
246/// Opt-in General Commissioning metadata that additionally advertises the
247/// **Network Recovery** feature (Matter 1.6, provisional): the `NR` FeatureMap
248/// bit plus the `RecoveryIdentifier` and `NetworkRecoveryReason` attributes.
249///
250/// This mirrors [`GenCommHandler::CLUSTER`] but exposes those two provisional
251/// attributes and sets the feature bit. The runtime [`GenCommHandler`] always
252/// implements both reads, so - exactly like
253/// [`basic_info::CLUSTER_DEVICE_LOCATION`](crate::dm::clusters::basic_info::CLUSTER_DEVICE_LOCATION) -
254/// an application opts in purely by substituting this metadata for
255/// [`GenCommHandler::CLUSTER`] in its endpoint's cluster list; nothing else in
256/// the wiring changes.
257pub const CLUSTER_NETWORK_RECOVERY: Cluster<'static> = FULL_CLUSTER
258    .with_attrs(
259        with!(required; AttributeId::RecoveryIdentifier | AttributeId::NetworkRecoveryReason),
260    )
261    .with_cmds(except!(CommandId::SetTCAcknowledgements))
262    .with_features(Feature::NETWORK_RECOVERY.bits());
263
264impl ClusterHandler for GenCommHandler<'_> {
265    const CLUSTER: Cluster<'static> = FULL_CLUSTER
266        .with_attrs(with!(required))
267        .with_cmds(except!(CommandId::SetTCAcknowledgements));
268
269    fn dataver(&self) -> u32 {
270        self.dataver.get()
271    }
272
273    fn dataver_changed(&self) {
274        self.dataver.changed();
275    }
276
277    fn breadcrumb(&self, ctx: impl ReadContext) -> Result<u64, Error> {
278        ctx.exchange()
279            .with_state(|state| Ok(state.failsafe.breadcrumb()))
280    }
281
282    fn set_breadcrumb(&self, ctx: impl WriteContext, value: u64) -> Result<(), Error> {
283        ctx.exchange().with_state(|state| {
284            state.failsafe.set_breadcrumb(value);
285
286            Ok(())
287        })
288    }
289
290    fn basic_commissioning_info<P: TLVBuilderParent>(
291        &self,
292        _ctx: impl ReadContext,
293        builder: BasicCommissioningInfoBuilder<P>,
294    ) -> Result<P, Error> {
295        builder
296            .fail_safe_expiry_length_seconds(self.commissioning_policy.failsafe_expiry_len_secs())?
297            .max_cumulative_failsafe_seconds(self.commissioning_policy.failsafe_max_cml_secs())?
298            .end()
299    }
300
301    fn regulatory_config(
302        &self,
303        ctx: impl ReadContext,
304    ) -> Result<RegulatoryLocationTypeEnum, Error> {
305        // Until `SetRegulatoryConfig` stores an explicit value, report
306        // `LocationCapability` - per the Matter Core spec that is the
307        // default of `RegulatoryConfig`.
308        ctx.exchange().with_state(|state| {
309            Ok(state
310                .basic_info_settings
311                .location_type
312                .unwrap_or(self.commissioning_policy.location_cap()))
313        })
314    }
315
316    fn location_capability(
317        &self,
318        _ctx: impl ReadContext,
319    ) -> Result<RegulatoryLocationTypeEnum, Error> {
320        Ok(self.commissioning_policy.location_cap())
321    }
322
323    fn supports_concurrent_connection(&self, _ctx: impl ReadContext) -> Result<bool, Error> {
324        Ok(self.commissioning_policy.concurrent_connection_supported())
325    }
326
327    fn recovery_identifier<P: TLVBuilderParent>(
328        &self,
329        ctx: impl ReadContext,
330        builder: OctetsBuilder<P>,
331    ) -> Result<P, Error> {
332        // The 8-byte, big-endian encoding of the stable random identifier.
333        // The same byte sequence is what a Recovery Node would carry in its
334        // BLE Network-Recovery advertisement (see `RecoveryAdvData`).
335        let id = Self::recovery_identifier_value(&ctx)?;
336
337        builder.set(Octets::new(&id.to_be_bytes()))
338    }
339
340    fn network_recovery_reason(
341        &self,
342        _ctx: impl ReadContext,
343    ) -> Result<Nullable<NetworkRecoveryReasonEnum>, Error> {
344        // Matter Core Spec 11.10.6.12: this attribute is null whenever the
345        // node is not undergoing a Network Recovery flow. rs-matter core never
346        // autonomously enters recovery mode (the flow is a platform-layer
347        // concern), so the reason is always null.
348        Ok(Nullable::none())
349    }
350
351    fn handle_arm_fail_safe<P: TLVBuilderParent>(
352        &self,
353        ctx: impl InvokeContext,
354        request: ArmFailSafeRequest<'_>,
355        response: ArmFailSafeResponseBuilder<P>,
356    ) -> Result<P, Error> {
357        let expiry_length_seconds = request.expiry_length_seconds()?;
358
359        info!(
360            "Got Arm Fail Safe Request, expiry {}s",
361            expiry_length_seconds
362        );
363
364        // `ArmFailSafe(0)` means "force-expire the fail-safe context" per
365        // Matter Core spec: if the fail-safe is armed,
366        // the device SHALL roll back any uncommitted fabric / network state
367        // and reset the breadcrumb. Route through `force_expiry` so that
368        // in-flight `AddNOC` / `SetRegulatoryConfig` changes are reverted —
369        // the bare `failsafe.arm(0, ...)` path only flips the state to
370        // `Idle` and would leave the staged fabric committed.
371        let mut removed_fabric = None;
372
373        let status = if expiry_length_seconds == 0 {
374            let notify_mdns = || ctx.exchange().matter().transport().notify_mdns_changed();
375            let notify_change = |endpt_id, clust_id| ctx.notify_cluster_changed(endpt_id, clust_id);
376
377            CommissioningErrorEnum::map(ctx.exchange().with_state(|state| {
378                let sess = ctx.exchange().id().session(&mut state.sessions);
379                let pase_sess_id =
380                    matches!(sess.get_session_mode(), SessionMode::Pase { .. }).then(|| sess.id());
381
382                removed_fabric = state.failsafe.expire(
383                    &mut state.fabrics,
384                    &mut state.sessions,
385                    pase_sess_id,
386                    ctx.networks(),
387                    ctx.kv(),
388                    notify_mdns,
389                    notify_change,
390                )?;
391
392                Ok(())
393            }))?
394        } else {
395            CommissioningErrorEnum::map(ctx.exchange().with_state(|state| {
396                let sess = ctx.exchange().id().session(&mut state.sessions);
397
398                state.failsafe.arm(
399                    expiry_length_seconds,
400                    request.breadcrumb()?,
401                    sess.get_session_mode(),
402                    &mut state.pase,
403                )
404            }))?
405        };
406
407        // Broadcast for a fabric the expiry dropped (a not-yet-committed
408        // `AddNOC` one; an `UpdateNOC`-mutated fabric is resurrected instead
409        // and not reported). Outside `with_state`, since the broadcast runs
410        // the handlers inline.
411        if let Some(fab_idx) = removed_fabric {
412            ctx.notify_fabric_removed(fab_idx);
413        }
414
415        // Breadcrumb (and possibly failsafe-arm state) may have changed
416        ctx.notify_own_cluster_changed();
417
418        response.error_code(status)?.debug_text("")?.end()
419    }
420
421    fn handle_set_regulatory_config<P: TLVBuilderParent>(
422        &self,
423        ctx: impl InvokeContext,
424        request: SetRegulatoryConfigRequest<'_>,
425        response: SetRegulatoryConfigResponseBuilder<P>,
426    ) -> Result<P, Error> {
427        info!("Got Set Regulatory Config Request");
428
429        let country_code = request.country_code()?;
430        if country_code.len() != 2 {
431            return Err(ErrorCode::ConstraintError.into());
432        }
433
434        // Per Matter Core spec, `NewRegulatoryConfig`
435        // SHALL be one of the values supported by the device's
436        // `LocationCapability`:
437        //
438        //   * `LocationCapability::Indoor`         -> only `Indoor`
439        //   * `LocationCapability::Outdoor`        -> only `Outdoor`
440        //   * `LocationCapability::IndoorOutdoor`  -> any of the three
441        //
442        // A request that violates this — including an enum value the device
443        // doesn't even recognise — must be rejected with the cluster-level
444        // `ValueOutsideRange` rather than a generic IM `Failure`. Decode the
445        // enum defensively because TLV decoding will reject an unknown
446        // variant before we ever see it (the test sends `3`).
447        let location_type = request.new_regulatory_config();
448        let breadcrumb = request.breadcrumb()?;
449
450        let location_type = match location_type {
451            Ok(loc) if Self::is_regulatory_config_supported(self.commissioning_policy, loc) => loc,
452            _ => {
453                return response
454                    .error_code(CommissioningErrorEnum::ValueOutsideRange)?
455                    .debug_text("")?
456                    .end();
457            }
458        };
459
460        let mut persist = Persist::new(ctx.kv());
461
462        let status = CommissioningErrorEnum::map(ctx.exchange().with_state(|state| {
463            state.basic_info_settings.set_location(country_code);
464            state.basic_info_settings.location_type = Some(location_type);
465
466            state.failsafe.set_breadcrumb(breadcrumb);
467
468            state.basic_info_settings.store_persist(&mut persist)?;
469
470            Ok(())
471        }))?;
472
473        persist.run()?;
474
475        // Regulatory config mutates both this cluster (RegulatoryConfig, Breadcrumb)
476        // and Basic Information (Location) on the same endpoint
477        ctx.notify_own_endpoint_changed();
478
479        response.error_code(status)?.debug_text("")?.end()
480    }
481
482    fn handle_commissioning_complete<P: TLVBuilderParent>(
483        &self,
484        ctx: impl InvokeContext,
485        response: CommissioningCompleteResponseBuilder<P>,
486    ) -> Result<P, Error> {
487        info!("Got Commissioning Complete Request");
488
489        let notify_change = |endpt_id, clust_id| ctx.notify_cluster_changed(endpt_id, clust_id);
490
491        let mut persist = FabricPersist::new(ctx.kv());
492
493        let status =
494            CommissioningErrorEnum::map(Self::with_armed_failsafe(&ctx, |state, notify_mdns| {
495                let sess = ctx.exchange().id().session(&mut state.sessions);
496                // Spec: on
497                // `CommissioningComplete` the PASE session SHALL be
498                // terminated. The current command is being delivered over
499                // that PASE, so mark it `expired` (response can still go
500                // out, no further exchanges accepted) and let the LRU
501                // eviction reclaim the slot. Without this the promoted
502                // PASE leaks across commissioning rounds and eventually
503                // exhausts the session table — visible as `BUSY` on the
504                // next round's `PBKDFParamRequest` (TC_CADMIN_1_19 hit
505                // this on the 4th round of `SupportedFabrics` rounds).
506                // Modern controllers (CHIP SDK 1.4+) run a
507                // `FindOperationalForCommissioningComplete` step before
508                // sending `CommissioningComplete`, so this command
509                // typically arrives over the new operational CASE session
510                // rather than over PASE. In that case the current session
511                // doesn't need preserving and we just drop every PASE
512                // session unconditionally. (For legacy behaviour where the
513                // command does come over PASE we still preserve the
514                // current one.)
515                let pase_sess_id =
516                    matches!(sess.get_session_mode(), SessionMode::Pase { .. }).then(|| sess.id());
517
518                let fabric = state
519                    .failsafe
520                    .disarm(sess.get_session_mode(), &mut state.fabrics)?;
521
522                state.pase.close_comm_window(notify_mdns, notify_change)?;
523                state.sessions.remove_pase(pase_sess_id);
524                ctx.exchange().matter().transport().notify_session_removed();
525
526                // Finally, persist the fabric and the network settings, prior to sending the other party a "success" status
527                persist.store(fabric)?;
528                ctx.networks().access(|networks| {
529                    networks.set_managed(true)?;
530
531                    persist
532                        .persist_mut()
533                        .store(NETWORKS_KEY, |buf| networks.save(buf))
534                })?;
535
536                info!("Commissioning complete, fabric and network settings persisted");
537
538                Ok(())
539            }))?;
540
541        persist.run()?;
542
543        // Commissioning-complete mutates many clusters on the root endpoint:
544        // breadcrumb (this cluster), fabrics (NOC), networks (NetCommissioning).
545        // The closed commissioning window was already notified via `notify_change`.
546        ctx.notify_own_endpoint_changed();
547
548        response.error_code(status)?.debug_text("")?.end()
549    }
550
551    fn handle_set_tc_acknowledgements<P: TLVBuilderParent>(
552        &self,
553        _ctx: impl InvokeContext,
554        _request: SetTCAcknowledgementsRequest<'_>,
555        _response: SetTCAcknowledgementsResponseBuilder<P>,
556    ) -> Result<P, Error> {
557        Err(ErrorCode::CommandNotFound.into())
558    }
559}
560
561impl Debug for GenCommHandler<'_> {
562    fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
563        f.debug_struct("GenCommHandler")
564            .field("dataver", &self.dataver)
565            .finish()
566    }
567}
568
569#[cfg(feature = "defmt")]
570impl defmt::Format for GenCommHandler<'_> {
571    fn format(&self, fmt: defmt::Formatter) {
572        defmt::write!(fmt, "GenCommHandler {{ dataver: {} }}", self.dataver);
573    }
574}