Skip to main content

ocpi_kit/v2_1_1/
commands.rs

1//! The *Commands* module of OCPI 2.1.1.
2//!
3//! # The single most important difference
4//!
5//! OCPI 2.1.1 has **no `CommandResult` object**. The Charge Point's eventual answer is POSTed to
6//! the `response_url` as another [`CommandResponse`], whose [`CommandResponseType`] therefore
7//! carries `TIMEOUT` — a value that makes no sense in a synchronous reply and which OCPI 2.2
8//! moved to the separate `CommandResultType`.
9//!
10//! An integration that treats the 2.1.1 callback as a 2.2 `CommandResult` will fail to decode
11//! every one of them, which is why the two are separate types in this crate.
12//!
13//! There is also no `CANCEL_RESERVATION` command, and `ReserveNow.reservation_id` is an **`int`**
14//! rather than a string.
15//!
16//! Spec: 2.1.1 §mod_commands
17
18use bon::Builder;
19use serde::{Deserialize, Serialize};
20
21use crate::ocpi_lenient_enum;
22use crate::types::validate_fields;
23use crate::types::{DateTime, Extensions, OcpiString, Url, Validate, Validator};
24
25use super::tokens::Token;
26
27/// The answer to a command request — used **both** synchronously and on the `response_url`.
28///
29/// Spec: 2.1.1 §mod_commands_commandresponse_object
30#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
31#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
32pub struct CommandResponse {
33    /// Result of the command request as sent by the Charge Point to the CPO.
34    pub result: CommandResponseType,
35    /// Undocumented JSON fields, preserved verbatim.
36    #[serde(flatten, default, skip_serializing_if = "Extensions::is_empty")]
37    pub extensions: Extensions,
38}
39
40impl CommandResponse {
41    /// Creates a response.
42    #[must_use]
43    pub fn new(result: CommandResponseType) -> Self {
44        Self { result, extensions: Extensions::new() }
45    }
46}
47
48impl Validate for CommandResponse {
49    fn validate_in(&self, v: &mut Validator) {
50        validate_fields!(self, v, result);
51    }
52}
53
54/// A request to start a charging session, in OCPI 2.1.1.
55///
56/// There is no `connector_id` and no `authorization_reference`; both arrived in OCPI 2.2.
57///
58/// Spec: 2.1.1 §mod_commands_startsession_object
59#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, Builder)]
60#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
61#[builder(on(_, into))]
62pub struct StartSession {
63    /// URL that the [`CommandResponse`] POST should be sent to.
64    pub response_url: Url,
65    /// The Token the Charge Point has to use to start a new session.
66    pub token: Token,
67    /// `Location.id` on which a session is to be started.
68    pub location_id: OcpiString<39>,
69    /// `EVSE.uid` on which a session is to be started.
70    #[serde(default, skip_serializing_if = "Option::is_none")]
71    pub evse_uid: Option<OcpiString<39>>,
72    /// Undocumented JSON fields, preserved verbatim.
73    #[serde(flatten, default, skip_serializing_if = "Extensions::is_empty")]
74    #[builder(default)]
75    pub extensions: Extensions,
76}
77
78impl Validate for StartSession {
79    fn validate_in(&self, v: &mut Validator) {
80        validate_fields!(self, v, response_url, token, location_id, evse_uid);
81    }
82}
83
84/// A request to stop an ongoing session, in OCPI 2.1.1.
85///
86/// Spec: 2.1.1 §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 [`CommandResponse`] 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: OcpiString<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, in OCPI 2.1.1.
108///
109/// Note that `reservation_id` is an **integer** here; OCPI 2.2 made it a `CiString(36)`.
110///
111/// Spec: 2.1.1 §mod_commands_reservenow_object
112#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, Builder)]
113#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
114#[builder(on(_, into))]
115pub struct ReserveNow {
116    /// URL that the [`CommandResponse`] POST should be sent to.
117    pub response_url: Url,
118    /// The Token for which to reserve the Charge Point.
119    pub token: Token,
120    /// When this reservation ends.
121    pub expiry_date: DateTime,
122    /// Reservation id, unique for this reservation.
123    pub reservation_id: i64,
124    /// `Location.id` for which to reserve an EVSE.
125    pub location_id: OcpiString<39>,
126    /// `EVSE.uid` if a specific EVSE has to be reserved.
127    #[serde(default, skip_serializing_if = "Option::is_none")]
128    pub evse_uid: Option<OcpiString<39>>,
129    /// Undocumented JSON fields, preserved verbatim.
130    #[serde(flatten, default, skip_serializing_if = "Extensions::is_empty")]
131    #[builder(default)]
132    pub extensions: Extensions,
133}
134
135impl Validate for ReserveNow {
136    fn validate_in(&self, v: &mut Validator) {
137        validate_fields!(self, v, response_url, token, expiry_date, location_id, evse_uid);
138    }
139}
140
141/// A request to unlock a connector, in OCPI 2.1.1.
142///
143/// Spec: 2.1.1 §mod_commands_unlockconnector_object
144#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, Builder)]
145#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
146#[builder(on(_, into))]
147pub struct UnlockConnector {
148    /// URL that the [`CommandResponse`] POST should be sent to.
149    pub response_url: Url,
150    /// `Location.id` of which it is requested to unlock the connector.
151    pub location_id: OcpiString<39>,
152    /// `EVSE.uid` of which it is requested to unlock the connector.
153    pub evse_uid: OcpiString<39>,
154    /// `Connector.id` which it is requested to unlock.
155    pub connector_id: OcpiString<36>,
156    /// Undocumented JSON fields, preserved verbatim.
157    #[serde(flatten, default, skip_serializing_if = "Extensions::is_empty")]
158    #[builder(default)]
159    pub extensions: Extensions,
160}
161
162impl Validate for UnlockConnector {
163    fn validate_in(&self, v: &mut Validator) {
164        validate_fields!(self, v, response_url, location_id, evse_uid, connector_id);
165    }
166}
167
168/// Every OCPI 2.1.1 command body, tagged by the [`CommandType`] it belongs to.
169#[derive(Clone, Debug, PartialEq)]
170#[non_exhaustive]
171pub enum Command {
172    /// `POST {commands_endpoint}/RESERVE_NOW`
173    ReserveNow(Box<ReserveNow>),
174    /// `POST {commands_endpoint}/START_SESSION`
175    StartSession(Box<StartSession>),
176    /// `POST {commands_endpoint}/STOP_SESSION`
177    StopSession(StopSession),
178    /// `POST {commands_endpoint}/UNLOCK_CONNECTOR`
179    UnlockConnector(UnlockConnector),
180}
181
182impl Command {
183    /// Which command this is.
184    #[must_use]
185    pub fn command_type(&self) -> CommandType {
186        match self {
187            Self::ReserveNow(_) => CommandType::ReserveNow,
188            Self::StartSession(_) => CommandType::StartSession,
189            Self::StopSession(_) => CommandType::StopSession,
190            Self::UnlockConnector(_) => CommandType::UnlockConnector,
191        }
192    }
193
194    /// The URL the eventual [`CommandResponse`] must be POSTed to.
195    #[must_use]
196    pub fn response_url(&self) -> &Url {
197        match self {
198            Self::ReserveNow(c) => &c.response_url,
199            Self::StartSession(c) => &c.response_url,
200            Self::StopSession(c) => &c.response_url,
201            Self::UnlockConnector(c) => &c.response_url,
202        }
203    }
204}
205
206impl Validate for Command {
207    fn validate_in(&self, v: &mut Validator) {
208        match self {
209            Self::ReserveNow(c) => c.validate_in(v),
210            Self::StartSession(c) => c.validate_in(v),
211            Self::StopSession(c) => c.validate_in(v),
212            Self::UnlockConnector(c) => c.validate_in(v),
213        }
214    }
215}
216
217ocpi_lenient_enum! {
218    /// The answer to a command request, in OCPI 2.1.1.
219    ///
220    /// `TIMEOUT` belongs here because 2.1.1 has no separate result object: the asynchronous
221    /// callback reuses this enum.
222    ///
223    /// Spec: 2.1.1 §mod_commands_commandresponsetype_enum
224    pub enum CommandResponseType {
225        /// The requested command is not supported by this CPO, Charge Point or EVSE.
226        NotSupported = "NOT_SUPPORTED",
227        /// Rejected by the CPO or the Charge Point.
228        Rejected = "REJECTED",
229        /// Accepted.
230        Accepted = "ACCEPTED",
231        /// No response from the Charge Point in a reasonable time.
232        Timeout = "TIMEOUT",
233        /// The Session in the requested command is not known.
234        UnknownSession = "UNKNOWN_SESSION",
235    }
236}
237
238ocpi_lenient_enum! {
239    /// The command being requested, in OCPI 2.1.1.
240    ///
241    /// `CANCEL_RESERVATION` arrived in OCPI 2.2.
242    ///
243    /// Spec: 2.1.1 §mod_commands_commandtype_enum
244    pub enum CommandType {
245        /// Reserve a (specific) EVSE for a Token, starting now.
246        ReserveNow = "RESERVE_NOW",
247        /// Start a transaction on the given EVSE.
248        StartSession = "START_SESSION",
249        /// Stop an ongoing session.
250        StopSession = "STOP_SESSION",
251        /// Unlock the connector. Help desk operators only.
252        UnlockConnector = "UNLOCK_CONNECTOR",
253    }
254}
255
256#[cfg(test)]
257mod tests {
258    use super::*;
259
260    #[test]
261    fn timeout_is_a_response_type_here_because_there_is_no_result_object() {
262        let response: CommandResponse = serde_json::from_str(r#"{"result":"TIMEOUT"}"#).unwrap();
263        assert_eq!(response.result, CommandResponseType::Timeout);
264        // In OCPI 2.2 and later, TIMEOUT is a CommandResultType and not a CommandResponseType.
265        assert!("TIMEOUT".parse::<crate::v2_3_0::commands::CommandResponseType>().is_err());
266        assert!("TIMEOUT".parse::<crate::v2_3_0::commands::CommandResultType>().is_ok());
267    }
268
269    #[test]
270    fn cancel_reservation_arrived_after_2_1_1() {
271        assert_eq!(CommandType::ALL_KNOWN.len(), 4);
272        assert!(!CommandType::from("CANCEL_RESERVATION").is_known());
273    }
274
275    #[test]
276    fn the_reservation_id_is_an_integer() {
277        #[derive(serde::Deserialize)]
278        struct OnlyId {
279            reservation_id: i64,
280        }
281        let json = r#"{"reservation_id":42}"#;
282        let parsed: OnlyId = serde_json::from_str(json).unwrap();
283        assert_eq!(parsed.reservation_id, 42);
284    }
285}