Skip to main content

matter_commissioning/state_machine/
action.rs

1//! `Action` / `Expectation` / `SessionContext` / `CommissionedFabric` —
2//! the outbound vocabulary the [`super::Commissioner`] uses to ask the
3//! caller for work.
4
5#![forbid(unsafe_code)]
6
7use crate::noc::FabricRecord;
8use crate::state_machine::stage::Stage;
9
10/// Whether an `Action::Invoke` or `Action::ReadAttribute` should be
11/// routed over the PASE session (pre-commissioning) or the CASE session
12/// (post-AddNOC, after [`Action::EstablishCase`] is fulfilled).
13#[derive(Copy, Clone, Debug, PartialEq, Eq, Hash)]
14pub enum SessionContext {
15    /// Pre-commissioning session keyed off the device's passcode.
16    Pase,
17    /// Post-AddNOC operational session keyed off the new fabric.
18    Case,
19}
20
21/// The next piece of work the caller must perform.
22///
23/// Returned by [`super::Commissioner::poll`]. The state machine is
24/// idempotent: calling `poll` twice without an intervening `on_response`
25/// returns the same `Action`.
26// `Done(CommissionedFabric)` is intentionally large (~130 B) but is emitted
27// exactly once per successful commission — boxing it would force an alloc on
28// the happy path with no real benefit.
29#[allow(clippy::large_enum_variant)]
30#[derive(Clone, Debug)]
31#[non_exhaustive]
32pub enum Action {
33    /// Invoke a cluster command. The caller frames `payload` into an
34    /// Invoke envelope and routes via `matter-transport` over the
35    /// indicated session. The decoded response payload is fed back via
36    /// [`super::Commissioner::on_response`] with the matching `expect`.
37    Invoke {
38        /// Which session to route the Invoke over.
39        session: SessionContext,
40        /// Matter endpoint (always `0` for commissioning).
41        endpoint: u16,
42        /// Cluster ID (`0x0030` `GeneralCommissioning` or `0x003E`
43        /// `OperationalCredentials` for all M6.4 stages).
44        cluster: u32,
45        /// Cluster command ID.
46        command: u32,
47        /// TLV-encoded command payload.
48        payload: Vec<u8>,
49        /// The response type the state machine expects next.
50        expect: Expectation,
51    },
52
53    /// Read attributes from a cluster. Used only by
54    /// [`Stage::ReadCommissioningInfo`].
55    ReadAttribute {
56        /// Which session to route the Read over.
57        session: SessionContext,
58        /// Matter endpoint (always `0` for commissioning).
59        endpoint: u16,
60        /// Cluster ID.
61        cluster: u32,
62        /// Attribute IDs to read.
63        attributes: &'static [u32],
64        /// The response type the state machine expects next.
65        expect: Expectation,
66    },
67
68    /// Evict any prior CASE session for this fabric/peer pair.
69    ///
70    /// **Reserved for M8 multi-fabric work; never emitted by M6.4's
71    /// state machine.** Kept in the enum so M8 can wire eviction in
72    /// without a `SemVer` bump.
73    EvictCase {
74        /// Fabric ID to evict on.
75        fabric_id: u64,
76        /// Peer operational node ID to evict for.
77        peer_node_id: u64,
78    },
79
80    /// Discover the device on its operational network and establish a
81    /// CASE session. Caller calls `Commissioner::on_case_established`
82    /// (added in M6.4.5) on success or
83    /// `on_response(Expectation::CaseFailed, &[])` on failure.
84    EstablishCase {
85        /// Fabric ID to establish CASE on.
86        fabric_id: u64,
87        /// Peer operational node ID to establish with.
88        peer_node_id: u64,
89    },
90
91    /// Commissioning succeeded. Caller may persist the
92    /// [`CommissionedFabric`] long-term.
93    Done(CommissionedFabric),
94
95    /// Commissioning failed. If `send_disarm_failsafe` is true, the
96    /// caller should send `ArmFailSafe(expiry_length_seconds=0)` to the
97    /// device over PASE to roll the device back to its
98    /// pre-commissioning state.
99    ///
100    /// `reason` is a rendered, log-friendly summary of the
101    /// `CommissioningError` that caused the abort. The caller will have
102    /// also received that error directly via
103    /// [`super::Commissioner::on_response`]'s `Err` return — `reason`
104    /// here is supplementary, intended for logs.
105    Abort {
106        /// Whether the caller should send `DisarmFailsafe` before
107        /// dropping the PASE session.
108        send_disarm_failsafe: bool,
109        /// Pre-rendered description of the failure cause.
110        reason: String,
111    },
112}
113
114/// The response type the state machine expects after an
115/// [`Action::Invoke`] or [`Action::ReadAttribute`].
116///
117/// Passed back into [`super::Commissioner::on_response`] alongside the
118/// raw TLV payload so the state machine can validate that the response
119/// matches the request without parsing the entire TLV first.
120#[derive(Copy, Clone, Debug, PartialEq, Eq, Hash)]
121#[non_exhaustive]
122pub enum Expectation {
123    /// Response to `Action::ReadAttribute` for `BasicCommissioningInfo` +
124    /// `RegulatoryConfig` + `CapabilityMinima`.
125    CommissioningInfo,
126    /// `GeneralCommissioning::ArmFailSafeResponse` (cluster `0x0030`,
127    /// response `0x01`).
128    ArmFailsafeResponse,
129    /// `GeneralCommissioning::SetRegulatoryConfigResponse` (response `0x03`).
130    SetRegulatoryConfigResponse,
131    /// `OperationalCredentials::CertificateChainResponse` for the PAI.
132    PaiCertChainResponse,
133    /// `OperationalCredentials::CertificateChainResponse` for the DAC.
134    DacCertChainResponse,
135    /// `OperationalCredentials::AttestationResponse` (response `0x01`).
136    AttestationResponse,
137    /// `OperationalCredentials::CSRResponse` (response `0x05`).
138    CsrResponse,
139    /// Status-only ack for `OperationalCredentials::AddTrustedRootCertificate`.
140    AddTrustedRootResponse,
141    /// `OperationalCredentials::NOCResponse` (response `0x08`).
142    NocResponse,
143    /// `GeneralCommissioning::CommissioningCompleteResponse` (response `0x05`).
144    CommissioningCompleteResponse,
145    /// Response to `Action::ReadAttribute` for
146    /// `NetworkCommissioning::FeatureMap` (attribute `0xFFFC`). Caller
147    /// delivers the bare u32 attribute value's TLV bytes, not the
148    /// Interaction Model `AttributeReportIB` envelope.
149    NetworkCommissioningInfo,
150    /// `NetworkCommissioning::NetworkConfigResponse` (cluster `0x0031`
151    /// response `0x05`) — emitted by `AddOrUpdateWiFiNetwork`.
152    NetworkConfigResponse,
153    /// `NetworkCommissioning::ConnectNetworkResponse` (response `0x07`).
154    ConnectNetworkResponse,
155    /// Caller-side signal that CASE establishment failed. Fed into
156    /// `on_response(Expectation::CaseFailed, &[])` after
157    /// [`Action::EstablishCase`].
158    CaseFailed,
159}
160
161/// Output of a successful commissioning run. Returned in
162/// [`Action::Done`].
163#[derive(Debug, Clone)]
164#[non_exhaustive]
165pub struct CommissionedFabric {
166    /// The fabric record the device is now a member of (RCAC + IPK +
167    /// fabric ID).
168    pub fabric: FabricRecord,
169    /// Operational node ID the device was assigned on this fabric.
170    pub peer_node_id: u64,
171    /// Raw SEC1 uncompressed P-256 (65 bytes) — the device's NOC public
172    /// key, extracted from the issued NOC.
173    pub peer_root_public_key: [u8; 65],
174    /// Stage where the run terminated. Always [`Stage::Cleanup`] on
175    /// success; useful for logging.
176    pub terminated_at: Stage,
177}
178
179#[cfg(test)]
180mod tests {
181    use super::*;
182
183    #[test]
184    fn invoke_action_round_trips_through_clone() {
185        let a = Action::Invoke {
186            session: SessionContext::Pase,
187            endpoint: 0,
188            cluster: 0x0030,
189            command: 0x00,
190            payload: vec![0x15, 0x18],
191            expect: Expectation::ArmFailsafeResponse,
192        };
193        let b = a.clone();
194        match (a, b) {
195            (
196                Action::Invoke {
197                    endpoint: e1,
198                    cluster: c1,
199                    command: cmd1,
200                    ..
201                },
202                Action::Invoke {
203                    endpoint: e2,
204                    cluster: c2,
205                    command: cmd2,
206                    ..
207                },
208            ) => {
209                assert_eq!(e1, e2);
210                assert_eq!(c1, c2);
211                assert_eq!(cmd1, cmd2);
212            }
213            _ => panic!("clone produced wrong variant"),
214        }
215    }
216
217    #[test]
218    fn expectation_is_copy() {
219        fn assert_copy<T: Copy>() {}
220        assert_copy::<Expectation>();
221    }
222
223    #[test]
224    fn session_context_distinguishes_pase_and_case() {
225        assert_ne!(SessionContext::Pase, SessionContext::Case);
226    }
227
228    #[test]
229    fn abort_reason_carries_string() {
230        let a = Action::Abort {
231            send_disarm_failsafe: true,
232            reason: "synthetic failure".to_string(),
233        };
234        match a {
235            Action::Abort {
236                reason,
237                send_disarm_failsafe,
238            } => {
239                assert!(send_disarm_failsafe);
240                assert_eq!(reason, "synthetic failure");
241            }
242            _ => panic!("expected Abort"),
243        }
244    }
245}