Skip to main content

ocpi_kit/v2_3_0/
commands.rs

1//! The *Commands* module of OCPI 2.3.0: start, stop, reserve, cancel and unlock.
2//!
3//! *Module Identifier: `commands`*
4//!
5//! Commands are the one place in OCPI with an asynchronous callback: the Receiver answers the
6//! POST immediately with a [`CommandResponse`] carrying a `timeout`, and later POSTs a
7//! [`CommandResult`] to the `response_url` the Sender supplied.
8//!
9//! Spec: 2.3.0 §mod_commands_commands_module
10
11use bon::Builder;
12use serde::{Deserialize, Serialize};
13
14use crate::ocpi_enum;
15use crate::ocpi_open_enum;
16use crate::types::validate_fields;
17use crate::types::{CiString, DateTime, DisplayText, Extensions, Url, Validate, Validator, ViolationCode};
18
19use super::tokens::Token;
20
21/// A request to start a charging session on a Location, EVSE or Connector.
22///
23/// > *The Token provided by the eMSP for the `StartSession` SHALL be authorized by the eMSP
24/// > before sending it to the CPO. Therefore the CPO SHALL NOT check the validity of the Token
25/// > provided before sending the request to the Charge Point.*
26///
27/// Spec: 2.3.0 §mod_commands_startsession_object
28#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, Builder)]
29#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
30#[builder(on(_, into))]
31pub struct StartSession {
32    /// URL that the [`CommandResult`] POST should be sent to.
33    ///
34    /// > *This URL might contain a unique ID to be able to distinguish between StartSession
35    /// > requests.*
36    pub response_url: Url,
37    /// The Token the Charge Point has to use to start a new session.
38    pub token: Token,
39    /// `Location.id` on which a session is to be started.
40    pub location_id: CiString<36>,
41    /// `EVSE.uid` on which a session is to be started. Required when `connector_id` is set.
42    #[serde(default, skip_serializing_if = "Option::is_none")]
43    pub evse_uid: Option<CiString<36>>,
44    /// `Connector.id` on which a session is to be started.
45    ///
46    /// > *This field is required when the capability `START_SESSION_CONNECTOR_REQUIRED` is set on
47    /// > the EVSE.*
48    ///
49    /// See [`Evse::requires_connector_id_on_start`](crate::v2_3_0::locations::Evse::requires_connector_id_on_start).
50    #[serde(default, skip_serializing_if = "Option::is_none")]
51    pub connector_id: Option<CiString<36>>,
52    /// Reference to the authorization given by the eMSP, echoed in the Session and CDR.
53    #[serde(default, skip_serializing_if = "Option::is_none")]
54    pub authorization_reference: Option<CiString<36>>,
55    /// Undocumented JSON fields, preserved verbatim.
56    #[serde(flatten, default, skip_serializing_if = "Extensions::is_empty")]
57    #[builder(default)]
58    pub extensions: Extensions,
59}
60
61impl Validate for StartSession {
62    fn validate_in(&self, v: &mut Validator) {
63        validate_fields!(
64            self,
65            v,
66            response_url,
67            token,
68            location_id,
69            evse_uid,
70            connector_id,
71            authorization_reference,
72        );
73        // "Required when `connector_id` is set."
74        if self.connector_id.is_some() && self.evse_uid.is_none() {
75            v.report_at(
76                "evse_uid",
77                ViolationCode::MissingConditional,
78                "is required when `connector_id` is set",
79            );
80        }
81    }
82}
83
84/// A request to stop an ongoing session.
85///
86/// Spec: 2.3.0 §mod_commands_stopsession_object
87#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, Builder)]
88#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
89#[builder(on(_, into))]
90pub struct StopSession {
91    /// URL that the [`CommandResult`] POST should be sent to.
92    pub response_url: Url,
93    /// `Session.id` of the Session that is requested to be stopped.
94    pub session_id: CiString<36>,
95    /// Undocumented JSON fields, preserved verbatim.
96    #[serde(flatten, default, skip_serializing_if = "Extensions::is_empty")]
97    #[builder(default)]
98    pub extensions: Extensions,
99}
100
101impl Validate for StopSession {
102    fn validate_in(&self, v: &mut Validator) {
103        validate_fields!(self, v, response_url, session_id);
104    }
105}
106
107/// A request to reserve an EVSE for a Token for a certain time, starting now.
108///
109/// > *A successful reservation will result in a new `Session` object being created by the CPO.
110/// > An unused Reservation of a Charge Point/EVSE MAY result in cost being made, thus also a
111/// > CDR.*
112///
113/// Spec: 2.3.0 §mod_commands_reservenow_object
114#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, Builder)]
115#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
116#[builder(on(_, into))]
117pub struct ReserveNow {
118    /// URL that the [`CommandResult`] POST should be sent to.
119    pub response_url: Url,
120    /// The Token for which to reserve the Charge Point (and specific EVSE).
121    pub token: Token,
122    /// When this reservation ends, in UTC.
123    pub expiry_date: DateTime,
124    /// Reservation id, unique for this reservation.
125    ///
126    /// > *The `reservation_id` sent by the Sender (eMSP) to the Receiver (CPO) SHALL NOT be sent
127    /// > directly to a Charge Point. The CPO SHALL make sure the Reservation ID sent to the
128    /// > Charge Point is unique and is not used by another Sender.*
129    pub reservation_id: CiString<36>,
130    /// `Location.id` for which to reserve an EVSE.
131    pub location_id: CiString<36>,
132    /// `EVSE.uid` if a specific EVSE has to be reserved.
133    ///
134    /// > *If no EVSE is specified, the Charge Point should keep one EVSE available for the EV
135    /// > Driver identified by the given Token.*
136    #[serde(default, skip_serializing_if = "Option::is_none")]
137    pub evse_uid: Option<CiString<36>>,
138    /// Reference to the authorization given by the eMSP.
139    #[serde(default, skip_serializing_if = "Option::is_none")]
140    pub authorization_reference: Option<CiString<36>>,
141    /// Undocumented JSON fields, preserved verbatim.
142    #[serde(flatten, default, skip_serializing_if = "Extensions::is_empty")]
143    #[builder(default)]
144    pub extensions: Extensions,
145}
146
147impl Validate for ReserveNow {
148    fn validate_in(&self, v: &mut Validator) {
149        validate_fields!(
150            self,
151            v,
152            response_url,
153            token,
154            expiry_date,
155            reservation_id,
156            location_id,
157            evse_uid,
158            authorization_reference,
159        );
160    }
161}
162
163/// A request to cancel an existing reservation.
164///
165/// > *As there might be cost involved for a Reservation, canceling a reservation might still
166/// > result in a CDR being sent for the reservation.*
167///
168/// Spec: 2.3.0 §mod_commands_cancelreservation_object
169#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, Builder)]
170#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
171#[builder(on(_, into))]
172pub struct CancelReservation {
173    /// URL that the [`CommandResult`] POST should be sent to.
174    pub response_url: Url,
175    /// The `reservation_id` that was given to the [`ReserveNow`].
176    pub reservation_id: CiString<36>,
177    /// Undocumented JSON fields, preserved verbatim.
178    #[serde(flatten, default, skip_serializing_if = "Extensions::is_empty")]
179    #[builder(default)]
180    pub extensions: Extensions,
181}
182
183impl Validate for CancelReservation {
184    fn validate_in(&self, v: &mut Validator) {
185        validate_fields!(self, v, response_url, reservation_id);
186    }
187}
188
189/// A request to unlock a connector.
190///
191/// > *This functionality is for help desk operators only! … This command SHALL never be allowed
192/// > to be sent directly by the EV-Driver.*
193///
194/// Spec: 2.3.0 §mod_commands_unlockconnector_object
195#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, Builder)]
196#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
197#[builder(on(_, into))]
198pub struct UnlockConnector {
199    /// URL that the [`CommandResult`] POST should be sent to.
200    pub response_url: Url,
201    /// `Location.id` of which it is requested to unlock the connector.
202    pub location_id: CiString<36>,
203    /// `EVSE.uid` of which it is requested to unlock the connector.
204    pub evse_uid: CiString<36>,
205    /// `Connector.id` which it is requested to unlock.
206    pub connector_id: CiString<36>,
207    /// Undocumented JSON fields, preserved verbatim.
208    #[serde(flatten, default, skip_serializing_if = "Extensions::is_empty")]
209    #[builder(default)]
210    pub extensions: Extensions,
211}
212
213impl Validate for UnlockConnector {
214    fn validate_in(&self, v: &mut Validator) {
215        validate_fields!(self, v, response_url, location_id, evse_uid, connector_id);
216    }
217}
218
219/// The synchronous answer to a command request.
220///
221/// > *Because OCPI does not allow/require retries, it could happen that the asynchronous result
222/// > url given by the eMSP is never successfully called. … it is important for the eMSP to know
223/// > the timeout on a certain command.*
224///
225/// Spec: 2.3.0 §mod_commands_commandresponse_object
226#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, Builder)]
227#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
228#[builder(on(_, into))]
229pub struct CommandResponse {
230    /// Response from the CPO on the command request.
231    pub result: CommandResponseType,
232    /// Timeout for this command in seconds.
233    ///
234    /// > *When the Result is not received within this timeout, the eMSP can assume that the
235    /// > message might never be sent.*
236    pub timeout: u32,
237    /// Human-readable description of the result, in one or more languages.
238    #[serde(default, skip_serializing_if = "Vec::is_empty")]
239    #[builder(default)]
240    pub message: Vec<DisplayText>,
241    /// Undocumented JSON fields, preserved verbatim.
242    #[serde(flatten, default, skip_serializing_if = "Extensions::is_empty")]
243    #[builder(default)]
244    pub extensions: Extensions,
245}
246
247impl CommandResponse {
248    /// The timeout as a [`std::time::Duration`], for awaiting the asynchronous result.
249    #[must_use]
250    pub const fn timeout_duration(&self) -> std::time::Duration {
251        std::time::Duration::from_secs(self.timeout as u64)
252    }
253
254    /// Whether a [`CommandResult`] should be expected on the `response_url`.
255    ///
256    /// Only an `ACCEPTED` command has been forwarded to the Charge Point; the other outcomes are
257    /// final already.
258    #[must_use]
259    pub fn expects_result(&self) -> bool {
260        self.result == CommandResponseType::Accepted
261    }
262}
263
264impl Validate for CommandResponse {
265    fn validate_in(&self, v: &mut Validator) {
266        validate_fields!(self, v, result, message);
267        if self.result == CommandResponseType::Accepted && self.timeout == 0 {
268            v.report_at(
269                "timeout",
270                ViolationCode::OutOfRange,
271                "an accepted command needs a non-zero timeout for the eMSP to wait on",
272            );
273        }
274    }
275}
276
277/// The asynchronous result, POSTed by the CPO to the `response_url`.
278///
279/// Spec: 2.3.0 §mod_commands_commandresult_object
280#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, Builder)]
281#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
282#[builder(on(_, into))]
283pub struct CommandResult {
284    /// Result of the command request as sent by the Charge Point to the CPO.
285    pub result: CommandResultType,
286    /// Human-readable description of the reason, in one or more languages.
287    #[serde(default, skip_serializing_if = "Vec::is_empty")]
288    #[builder(default)]
289    pub message: Vec<DisplayText>,
290    /// Undocumented JSON fields, preserved verbatim.
291    #[serde(flatten, default, skip_serializing_if = "Extensions::is_empty")]
292    #[builder(default)]
293    pub extensions: Extensions,
294}
295
296impl Validate for CommandResult {
297    fn validate_in(&self, v: &mut Validator) {
298        validate_fields!(self, v, result, message);
299    }
300}
301
302/// Every command body, tagged by the [`CommandType`] it belongs to.
303///
304/// The wire format is one POST per command with the command name in the URL, so this enum is not
305/// itself serialised as a tagged union; it exists so a server handler can take one argument and
306/// a client can queue heterogeneous commands.
307#[derive(Clone, Debug, PartialEq)]
308#[non_exhaustive]
309pub enum Command {
310    /// `POST {commands_endpoint}/CANCEL_RESERVATION`
311    CancelReservation(CancelReservation),
312    /// `POST {commands_endpoint}/RESERVE_NOW`
313    ReserveNow(Box<ReserveNow>),
314    /// `POST {commands_endpoint}/START_SESSION`
315    StartSession(Box<StartSession>),
316    /// `POST {commands_endpoint}/STOP_SESSION`
317    StopSession(StopSession),
318    /// `POST {commands_endpoint}/UNLOCK_CONNECTOR`
319    UnlockConnector(UnlockConnector),
320}
321
322impl Command {
323    /// Which command this is.
324    #[must_use]
325    pub fn command_type(&self) -> CommandType {
326        match self {
327            Self::CancelReservation(_) => CommandType::CancelReservation,
328            Self::ReserveNow(_) => CommandType::ReserveNow,
329            Self::StartSession(_) => CommandType::StartSession,
330            Self::StopSession(_) => CommandType::StopSession,
331            Self::UnlockConnector(_) => CommandType::UnlockConnector,
332        }
333    }
334
335    /// The URL the [`CommandResult`] must be POSTed to.
336    #[must_use]
337    pub fn response_url(&self) -> &Url {
338        match self {
339            Self::CancelReservation(c) => &c.response_url,
340            Self::ReserveNow(c) => &c.response_url,
341            Self::StartSession(c) => &c.response_url,
342            Self::StopSession(c) => &c.response_url,
343            Self::UnlockConnector(c) => &c.response_url,
344        }
345    }
346}
347
348impl Validate for Command {
349    fn validate_in(&self, v: &mut Validator) {
350        match self {
351            Self::CancelReservation(c) => c.validate_in(v),
352            Self::ReserveNow(c) => c.validate_in(v),
353            Self::StartSession(c) => c.validate_in(v),
354            Self::StopSession(c) => c.validate_in(v),
355            Self::UnlockConnector(c) => c.validate_in(v),
356        }
357    }
358}
359
360ocpi_enum! {
361    /// The CPO's immediate answer to a command request.
362    ///
363    /// Spec: 2.3.0 §mod_commands_commandresponsetype_enum
364    pub enum CommandResponseType {
365        /// The requested command is not supported by this CPO, Charge Point or EVSE.
366        NotSupported = "NOT_SUPPORTED",
367        /// Rejected by the CPO; the Session might not be from a customer of the sending eMSP.
368        Rejected = "REJECTED",
369        /// Accepted by the CPO and forwarded to the EVSE.
370        Accepted = "ACCEPTED",
371        /// The Session in the requested command is not known by this CPO.
372        UnknownSession = "UNKNOWN_SESSION",
373    }
374}
375
376ocpi_enum! {
377    /// The Charge Point's eventual answer, delivered to the `response_url`.
378    ///
379    /// Kept deliberately distinct from [`CommandResponseType`]: OCPI 2.1.1 had only one enum, and
380    /// conflating them is a common source of interoperability bugs.
381    ///
382    /// Spec: 2.3.0 §mod_commands_commandresulttype_enum
383    pub enum CommandResultType {
384        /// Accepted by the Charge Point.
385        Accepted = "ACCEPTED",
386        /// The Reservation has been canceled by the CPO.
387        CanceledReservation = "CANCELED_RESERVATION",
388        /// The EVSE is currently occupied; another session is ongoing.
389        EvseOccupied = "EVSE_OCCUPIED",
390        /// The EVSE is currently inoperative or faulted.
391        EvseInoperative = "EVSE_INOPERATIVE",
392        /// Execution of the command failed at the Charge Point.
393        Failed = "FAILED",
394        /// The requested command is not supported by this Charge Point or EVSE.
395        NotSupported = "NOT_SUPPORTED",
396        /// Rejected by the Charge Point.
397        Rejected = "REJECTED",
398        /// No response received from the Charge Point in a reasonable time.
399        Timeout = "TIMEOUT",
400        /// The Reservation in the requested command is not known by this Charge Point.
401        UnknownReservation = "UNKNOWN_RESERVATION",
402    }
403}
404
405ocpi_open_enum! {
406    /// The command being requested, as it appears in the URL.
407    ///
408    /// Spec: 2.3.0 §mod_commands_commandtype_enum
409    pub enum CommandType {
410        /// Cancel a specific reservation.
411        CancelReservation = "CANCEL_RESERVATION",
412        /// Reserve a (specific) EVSE for a Token, starting now.
413        ReserveNow = "RESERVE_NOW",
414        /// Start a transaction on the given EVSE/Connector.
415        StartSession = "START_SESSION",
416        /// Stop an ongoing session.
417        StopSession = "STOP_SESSION",
418        /// Unlock the connector. Help desk operators only.
419        UnlockConnector = "UNLOCK_CONNECTOR",
420    }
421}
422
423#[cfg(test)]
424mod tests {
425    use super::*;
426    use crate::types::Url;
427
428    fn url() -> Url {
429        Url::new("https://msp.example.com/ocpi/emsp/2.3.0/commands/START_SESSION/1234").unwrap()
430    }
431
432    #[test]
433    fn a_connector_id_without_an_evse_uid_is_incomplete() {
434        let token = crate::v2_3_0::tokens::Token::builder()
435            .country_code("NL")
436            .party_id("TNM")
437            .uid("012345678")
438            .token_type(crate::v2_3_0::tokens::TokenType::AppUser)
439            .contract_id("NL-TNM-C12345678-X")
440            .issuer("TheNewMotion")
441            .valid(true)
442            .whitelist(crate::v2_3_0::tokens::WhitelistType::Never)
443            .last_updated("2024-01-01T00:00:00Z".parse::<DateTime>().unwrap())
444            .build();
445        let cmd = StartSession::builder()
446            .response_url(url())
447            .token(token)
448            .location_id("LOC1")
449            .connector_id("1")
450            .build();
451        let err = cmd.validate().unwrap_err();
452        assert_eq!(err.as_slice()[0].pointer, "/evse_uid");
453    }
454
455    #[test]
456    fn only_an_accepted_response_promises_a_result() {
457        let accepted =
458            CommandResponse::builder().result(CommandResponseType::Accepted).timeout(30u32).build();
459        assert!(accepted.expects_result());
460        assert_eq!(accepted.timeout_duration(), std::time::Duration::from_secs(30));
461        assert!(accepted.validate().is_ok());
462
463        let rejected = CommandResponse::builder().result(CommandResponseType::Rejected).timeout(0u32).build();
464        assert!(!rejected.expects_result());
465        assert!(rejected.validate().is_ok(), "a rejected command needs no timeout");
466
467        let bad = CommandResponse::builder().result(CommandResponseType::Accepted).timeout(0u32).build();
468        assert_eq!(bad.validate().unwrap_err().as_slice()[0].pointer, "/timeout");
469    }
470
471    #[test]
472    fn response_and_result_enums_stay_distinct() {
473        assert!("CANCELED_RESERVATION".parse::<CommandResponseType>().is_err());
474        assert!("UNKNOWN_SESSION".parse::<CommandResultType>().is_err());
475        assert_eq!(
476            "CANCELED_RESERVATION".parse::<CommandResultType>().unwrap(),
477            CommandResultType::CanceledReservation
478        );
479    }
480
481    #[test]
482    fn the_command_enum_names_its_own_type_and_callback() {
483        let cmd = Command::StopSession(StopSession::builder().response_url(url()).session_id("101").build());
484        assert_eq!(cmd.command_type(), CommandType::StopSession);
485        assert_eq!(cmd.response_url(), &url());
486    }
487}