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`, `0x003E`
43        /// `OperationalCredentials`, or `0x0031` `NetworkCommissioning`.
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. Emitted by
54    /// [`Stage::ReadCommissioningInfo`] and
55    /// [`Stage::ReadNetworkCommissioningInfo`].
56    ReadAttribute {
57        /// Which session to route the Read over.
58        session: SessionContext,
59        /// Matter endpoint (always `0` for commissioning).
60        endpoint: u16,
61        /// Cluster ID.
62        cluster: u32,
63        /// Attribute IDs to read.
64        attributes: &'static [u32],
65        /// The response type the state machine expects next.
66        expect: Expectation,
67    },
68
69    /// Evict any prior CASE session for this fabric/peer pair.
70    ///
71    /// **Never emitted today** — commissioning onto a new fabric has no
72    /// prior CASE session to evict. Reserved for multi-fabric eviction, and
73    /// kept in the enum so that can be wired in without a `SemVer` bump.
74    EvictCase {
75        /// Fabric ID to evict on.
76        fabric_id: u64,
77        /// Peer operational node ID to evict for.
78        peer_node_id: u64,
79    },
80
81    /// Discover the device on its operational network and establish a
82    /// CASE session. Caller calls `Commissioner::on_case_established`
83    /// on success or
84    /// `on_response(Expectation::CaseFailed, &[])` on failure.
85    EstablishCase {
86        /// Fabric ID to establish CASE on.
87        fabric_id: u64,
88        /// Peer operational node ID to establish with.
89        peer_node_id: u64,
90    },
91
92    /// Commissioning succeeded. Caller may persist the
93    /// [`CommissionedFabric`] long-term.
94    Done(CommissionedFabric),
95
96    /// Commissioning failed. If `send_disarm_failsafe` is true, the
97    /// caller should send `ArmFailSafe(expiry_length_seconds=0)` to the
98    /// device over PASE to roll the device back to its
99    /// pre-commissioning state.
100    ///
101    /// `reason` is a rendered, log-friendly summary of the
102    /// `CommissioningError` that caused the abort. The caller will have
103    /// also received that error directly via
104    /// [`super::Commissioner::on_response`]'s `Err` return — `reason`
105    /// here is supplementary, intended for logs.
106    Abort {
107        /// Whether the caller should send `DisarmFailsafe` before
108        /// dropping the PASE session.
109        send_disarm_failsafe: bool,
110        /// Pre-rendered description of the failure cause.
111        reason: String,
112    },
113}
114
115/// The response type the state machine expects after an
116/// [`Action::Invoke`] or [`Action::ReadAttribute`].
117///
118/// Passed back into [`super::Commissioner::on_response`] alongside the
119/// raw TLV payload so the state machine can validate that the response
120/// matches the request without parsing the entire TLV first.
121#[derive(Copy, Clone, Debug, PartialEq, Eq, Hash)]
122#[non_exhaustive]
123pub enum Expectation {
124    /// Response to `Action::ReadAttribute` for `BasicCommissioningInfo` +
125    /// `RegulatoryConfig` + `CapabilityMinima`.
126    CommissioningInfo,
127    /// `GeneralCommissioning::ArmFailSafeResponse` (cluster `0x0030`,
128    /// response `0x01`).
129    ArmFailsafeResponse,
130    /// `GeneralCommissioning::SetRegulatoryConfigResponse` (response `0x03`).
131    SetRegulatoryConfigResponse,
132    /// `OperationalCredentials::CertificateChainResponse` for the PAI.
133    PaiCertChainResponse,
134    /// `OperationalCredentials::CertificateChainResponse` for the DAC.
135    DacCertChainResponse,
136    /// `OperationalCredentials::AttestationResponse` (response `0x01`).
137    AttestationResponse,
138    /// `OperationalCredentials::CSRResponse` (response `0x05`).
139    CsrResponse,
140    /// Status-only ack for `OperationalCredentials::AddTrustedRootCertificate`.
141    AddTrustedRootResponse,
142    /// `OperationalCredentials::NOCResponse` (response `0x08`).
143    NocResponse,
144    /// `GeneralCommissioning::CommissioningCompleteResponse` (response `0x05`).
145    CommissioningCompleteResponse,
146    /// Response to `Action::ReadAttribute` for
147    /// `NetworkCommissioning::FeatureMap` (attribute `0xFFFC`). Caller
148    /// delivers the bare u32 attribute value's TLV bytes, not the
149    /// Interaction Model `AttributeReportIB` envelope.
150    NetworkCommissioningInfo,
151    /// `NetworkCommissioning::NetworkConfigResponse` (cluster `0x0031`
152    /// response `0x05`) — emitted by `AddOrUpdateWiFiNetwork`.
153    NetworkConfigResponse,
154    /// `NetworkCommissioning::ConnectNetworkResponse` (response `0x07`).
155    ConnectNetworkResponse,
156    /// Caller-side signal that CASE establishment failed. Fed into
157    /// `on_response(Expectation::CaseFailed, &[])` after
158    /// [`Action::EstablishCase`].
159    CaseFailed,
160}
161
162/// Output of a successful commissioning run. Returned in
163/// [`Action::Done`].
164#[derive(Debug, Clone)]
165#[non_exhaustive]
166pub struct CommissionedFabric {
167    /// The fabric record the device is now a member of (RCAC + IPK +
168    /// fabric ID).
169    pub fabric: FabricRecord,
170    /// Operational node ID the device was assigned on this fabric.
171    pub peer_node_id: u64,
172    /// Raw SEC1 uncompressed P-256 (65 bytes) — the device's NOC public
173    /// key, extracted from the issued NOC.
174    pub peer_root_public_key: [u8; 65],
175    /// Stage where the run terminated. Always [`Stage::Cleanup`] on
176    /// success; useful for logging.
177    pub terminated_at: Stage,
178}
179
180#[cfg(test)]
181mod tests {
182    use super::*;
183
184    #[test]
185    fn invoke_action_round_trips_through_clone() {
186        let a = Action::Invoke {
187            session: SessionContext::Pase,
188            endpoint: 0,
189            cluster: 0x0030,
190            command: 0x00,
191            payload: vec![0x15, 0x18],
192            expect: Expectation::ArmFailsafeResponse,
193        };
194        let b = a.clone();
195        match (a, b) {
196            (
197                Action::Invoke {
198                    endpoint: e1,
199                    cluster: c1,
200                    command: cmd1,
201                    ..
202                },
203                Action::Invoke {
204                    endpoint: e2,
205                    cluster: c2,
206                    command: cmd2,
207                    ..
208                },
209            ) => {
210                assert_eq!(e1, e2);
211                assert_eq!(c1, c2);
212                assert_eq!(cmd1, cmd2);
213            }
214            _ => panic!("clone produced wrong variant"),
215        }
216    }
217
218    #[test]
219    fn expectation_is_copy() {
220        fn assert_copy<T: Copy>() {}
221        assert_copy::<Expectation>();
222    }
223
224    #[test]
225    fn session_context_distinguishes_pase_and_case() {
226        assert_ne!(SessionContext::Pase, SessionContext::Case);
227    }
228
229    #[test]
230    fn abort_reason_carries_string() {
231        let a = Action::Abort {
232            send_disarm_failsafe: true,
233            reason: "synthetic failure".to_string(),
234        };
235        match a {
236            Action::Abort {
237                reason,
238                send_disarm_failsafe,
239            } => {
240                assert!(send_disarm_failsafe);
241                assert_eq!(reason, "synthetic failure");
242            }
243            _ => panic!("expected Abort"),
244        }
245    }
246}