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}