Skip to main content

ocpi_kit/v2_3_0/
charging_profiles.rs

1//! The *Charging Profiles* module of OCPI 2.3.0: smart charging limits over time.
2//!
3//! *Module Identifier: `chargingprofiles`*
4//!
5//! Like [`commands`](super::commands), this module is asynchronous: the CPO answers with a
6//! [`ChargingProfileResponse`] carrying a timeout and later POSTs the outcome to the
7//! `response_url`.
8//!
9//! Spec: 2.3.0 §mod_charging_profiles_module
10
11use bon::Builder;
12use serde::{Deserialize, Serialize};
13
14use crate::ocpi_enum;
15use crate::types::validate_fields;
16use crate::types::{DateTime, Extensions, Number, Url, Validate, Validator, ViolationCode};
17
18/// A request to set a charging profile on a session.
19///
20/// Spec: 2.3.0 §mod_charging_profiles_set_charging_profile_object
21#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, Builder)]
22#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
23#[builder(on(_, into))]
24pub struct SetChargingProfile {
25    /// Limits for the available power or current over time.
26    pub charging_profile: ChargingProfile,
27    /// URL that the [`ChargingProfileResult`] POST should be sent to.
28    pub response_url: Url,
29    /// Undocumented JSON fields, preserved verbatim.
30    #[serde(flatten, default, skip_serializing_if = "Extensions::is_empty")]
31    #[builder(default)]
32    pub extensions: Extensions,
33}
34
35impl Validate for SetChargingProfile {
36    fn validate_in(&self, v: &mut Validator) {
37        validate_fields!(self, v, charging_profile, response_url);
38    }
39}
40
41/// The CPO's immediate answer to a Charging Profile request.
42///
43/// Spec: 2.3.0 §mod_charging_profiles_response_object
44#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, Builder)]
45#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
46#[builder(on(_, into))]
47pub struct ChargingProfileResponse {
48    /// Response from the CPO on the ChargingProfile request.
49    pub result: ChargingProfileResponseType,
50    /// Timeout for this request in seconds.
51    pub timeout: u32,
52    /// Undocumented JSON fields, preserved verbatim.
53    #[serde(flatten, default, skip_serializing_if = "Extensions::is_empty")]
54    #[builder(default)]
55    pub extensions: Extensions,
56}
57
58impl ChargingProfileResponse {
59    /// The timeout as a [`std::time::Duration`].
60    #[must_use]
61    pub const fn timeout_duration(&self) -> std::time::Duration {
62        std::time::Duration::from_secs(self.timeout as u64)
63    }
64
65    /// Whether a result should be expected on the `response_url`.
66    #[must_use]
67    pub fn expects_result(&self) -> bool {
68        self.result == ChargingProfileResponseType::Accepted
69    }
70}
71
72impl Validate for ChargingProfileResponse {
73    fn validate_in(&self, v: &mut Validator) {
74        validate_fields!(self, v, result);
75        if self.result == ChargingProfileResponseType::Accepted && self.timeout == 0 {
76            v.report_at(
77                "timeout",
78                ViolationCode::OutOfRange,
79                "an accepted request needs a non-zero timeout for the eMSP to wait on",
80            );
81        }
82    }
83}
84
85/// The asynchronous outcome of a GET for the active charging profile.
86///
87/// Spec: 2.3.0 §mod_charging_profiles_active_charging_profiles_result_object
88#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, Builder)]
89#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
90#[builder(on(_, into))]
91pub struct ActiveChargingProfileResult {
92    /// Whether the EVSE was able to process the request.
93    pub result: ChargingProfileResultType,
94    /// The requested profile, present when `result` is `ACCEPTED`.
95    #[serde(default, skip_serializing_if = "Option::is_none")]
96    pub profile: Option<ActiveChargingProfile>,
97    /// Undocumented JSON fields, preserved verbatim.
98    #[serde(flatten, default, skip_serializing_if = "Extensions::is_empty")]
99    #[builder(default)]
100    pub extensions: Extensions,
101}
102
103impl Validate for ActiveChargingProfileResult {
104    fn validate_in(&self, v: &mut Validator) {
105        validate_fields!(self, v, result, profile);
106        match (self.result, self.profile.is_some()) {
107            (ChargingProfileResultType::Accepted, false) => v.report_at(
108                "profile",
109                ViolationCode::MissingConditional,
110                "an ACCEPTED result carries the requested ActiveChargingProfile",
111            ),
112            (ChargingProfileResultType::Rejected | ChargingProfileResultType::Unknown, true) => v.report_at(
113                "profile",
114                ViolationCode::Inconsistent,
115                "a profile is only returned when the result is ACCEPTED",
116            ),
117            _ => {}
118        }
119    }
120}
121
122/// The asynchronous outcome of a PUT of a charging profile.
123///
124/// Spec: 2.3.0 §mod_charging_profiles_charging_profiles_result_object
125#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
126#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
127pub struct ChargingProfileResult {
128    /// Whether the EVSE was able to process the new or updated charging profile.
129    pub result: ChargingProfileResultType,
130    /// Undocumented JSON fields, preserved verbatim.
131    #[serde(flatten, default, skip_serializing_if = "Extensions::is_empty")]
132    pub extensions: Extensions,
133}
134
135impl Validate for ChargingProfileResult {
136    fn validate_in(&self, v: &mut Validator) {
137        validate_fields!(self, v, result);
138    }
139}
140
141/// The asynchronous outcome of a DELETE of a charging profile.
142///
143/// Spec: 2.3.0 §mod_charging_profiles_clear_profiles_result_object
144#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
145#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
146pub struct ClearProfileResult {
147    /// Whether the EVSE was able to process the removal of the charging profile.
148    pub result: ChargingProfileResultType,
149    /// Undocumented JSON fields, preserved verbatim.
150    #[serde(flatten, default, skip_serializing_if = "Extensions::is_empty")]
151    pub extensions: Extensions,
152}
153
154impl Validate for ClearProfileResult {
155    fn validate_in(&self, v: &mut Validator) {
156        validate_fields!(self, v, result);
157    }
158}
159
160/// The charging profile the Charge Point has calculated, with the time it did so.
161///
162/// Spec: 2.3.0 §mod_charging_profiles_active_charging_profile_class
163#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, Builder)]
164#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
165#[builder(on(_, into))]
166pub struct ActiveChargingProfile {
167    /// When the Charge Point calculated this profile.
168    ///
169    /// > *All time measurements within the profile are relative to this timestamp.*
170    pub start_date_time: DateTime,
171    /// The profile itself.
172    pub charging_profile: ChargingProfile,
173    /// Undocumented JSON fields, preserved verbatim.
174    #[serde(flatten, default, skip_serializing_if = "Extensions::is_empty")]
175    #[builder(default)]
176    pub extensions: Extensions,
177}
178
179impl Validate for ActiveChargingProfile {
180    fn validate_in(&self, v: &mut Validator) {
181        validate_fields!(self, v, start_date_time, charging_profile);
182    }
183}
184
185/// A list of charging periods with a power or current limit each.
186///
187/// Spec: 2.3.0 §mod_charging_profiles_charging_profile_class
188#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, Builder)]
189#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
190#[builder(on(_, into))]
191pub struct ChargingProfile {
192    /// Starting point of an absolute profile.
193    ///
194    /// > *If absent the profile will be relative to start of charging.*
195    #[serde(default, skip_serializing_if = "Option::is_none")]
196    pub start_date_time: Option<DateTime>,
197    /// Duration of the charging profile in seconds.
198    ///
199    /// > *If the duration is left empty, the last period will continue indefinitely or until end
200    /// > of the transaction in case `start_date_time` is absent.*
201    #[serde(default, skip_serializing_if = "Option::is_none")]
202    pub duration: Option<u64>,
203    /// The unit of measure the limits are expressed in.
204    pub charging_rate_unit: ChargingRateUnit,
205    /// Minimum charging rate supported by the EV, in `charging_rate_unit`.
206    ///
207    /// > *Accepts at most one digit fraction (e.g. 8.1).*
208    #[serde(default, skip_serializing_if = "Option::is_none")]
209    pub min_charging_rate: Option<Number>,
210    /// Periods defining maximum power or current usage over time.
211    #[serde(default, skip_serializing_if = "Vec::is_empty")]
212    #[builder(default)]
213    pub charging_profile_period: Vec<ChargingProfilePeriod>,
214    /// Undocumented JSON fields, preserved verbatim.
215    #[serde(flatten, default, skip_serializing_if = "Extensions::is_empty")]
216    #[builder(default)]
217    pub extensions: Extensions,
218}
219
220impl Validate for ChargingProfile {
221    fn validate_in(&self, v: &mut Validator) {
222        validate_fields!(self, v, start_date_time, min_charging_rate, charging_profile_period,);
223        if self.min_charging_rate.is_some_and(|r| r.scale() > 1) {
224            v.report_at(
225                "min_charging_rate",
226                ViolationCode::OutOfRange,
227                "accepts at most one digit fraction (e.g. 8.1)",
228            );
229        }
230        // "The value of StartPeriod also defines the stop time of the previous period", which
231        // only makes sense for a strictly increasing list.
232        let mut previous: Option<u64> = None;
233        for (i, period) in self.charging_profile_period.iter().enumerate() {
234            if previous.is_some_and(|p| period.start_period <= p) {
235                v.enter("charging_profile_period");
236                v.enter(&i.to_string());
237                v.report_at(
238                    "start_period",
239                    ViolationCode::Inconsistent,
240                    "charging profile periods must be in strictly increasing order",
241                );
242                v.leave();
243                v.leave();
244            }
245            previous = Some(period.start_period);
246        }
247        if let (Some(duration), Some(last)) = (self.duration, previous)
248            && last >= duration
249        {
250            v.report_at(
251                "duration",
252                ViolationCode::Inconsistent,
253                "the profile ends before its last period starts",
254            );
255        }
256    }
257}
258
259/// One time period within a [`ChargingProfile`].
260///
261/// Spec: 2.3.0 §mod_charging_profiles_charging_profile_period_class
262#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
263#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
264pub struct ChargingProfilePeriod {
265    /// Start of the period, in seconds from the start of the profile.
266    pub start_period: u64,
267    /// Charging rate limit during this period, in the profile's `charging_rate_unit`.
268    ///
269    /// > *Accepts at most one digit fraction (e.g. 8.1).*
270    pub limit: Number,
271    /// Undocumented JSON fields, preserved verbatim.
272    #[serde(flatten, default, skip_serializing_if = "Extensions::is_empty")]
273    pub extensions: Extensions,
274}
275
276impl Validate for ChargingProfilePeriod {
277    fn validate_in(&self, v: &mut Validator) {
278        validate_fields!(self, v, limit);
279        if self.limit.is_negative() {
280            v.report_at("limit", ViolationCode::OutOfRange, "a charging rate limit cannot be negative");
281        }
282        if self.limit.scale() > 1 {
283            v.report_at("limit", ViolationCode::OutOfRange, "accepts at most one digit fraction (e.g. 8.1)");
284        }
285    }
286}
287
288ocpi_enum! {
289    /// The unit a charging profile is defined in.
290    ///
291    /// Spec: 2.3.0 §mod_charging_profiles_chargingrateunit
292    pub enum ChargingRateUnit {
293        /// Watts: the total allowed charging power, usually convenient for DC.
294        Watts = "W",
295        /// Amperes per phase — not the sum of all phases — usually convenient for AC.
296        Amperes = "A",
297    }
298}
299
300ocpi_enum! {
301    /// The CPO's immediate answer to a Charging Profile request.
302    ///
303    /// Spec: 2.3.0 §mod_charging_profiles_responsetype_enum
304    pub enum ChargingProfileResponseType {
305        /// Accepted by the CPO; the request will be forwarded to the EVSE.
306        Accepted = "ACCEPTED",
307        /// Charging Profiles are not supported by this CPO, Charge Point or EVSE.
308        NotSupported = "NOT_SUPPORTED",
309        /// Rejected by the CPO.
310        Rejected = "REJECTED",
311        /// Rejected by the CPO: requests are sent more often than allowed.
312        TooOften = "TOO_OFTEN",
313        /// The Session in the requested command is not known by this CPO.
314        UnknownSession = "UNKNOWN_SESSION",
315    }
316}
317
318ocpi_enum! {
319    /// The EVSE's eventual answer, delivered to the `response_url`.
320    ///
321    /// Deliberately distinct from [`ChargingProfileResponseType`].
322    ///
323    /// Spec: 2.3.0 §mod_charging_profiles_resulttype_enum
324    pub enum ChargingProfileResultType {
325        /// Accepted by the EVSE.
326        Accepted = "ACCEPTED",
327        /// Rejected by the EVSE.
328        Rejected = "REJECTED",
329        /// No Charging Profiles were found by the EVSE matching the request.
330        Unknown = "UNKNOWN",
331    }
332}
333
334#[cfg(test)]
335mod tests {
336    use super::*;
337
338    fn period(start: u64, limit: &str) -> ChargingProfilePeriod {
339        ChargingProfilePeriod {
340            start_period: start,
341            limit: limit.parse().unwrap(),
342            extensions: Extensions::new(),
343        }
344    }
345
346    #[test]
347    fn profile_periods_must_increase() {
348        let good = ChargingProfile::builder()
349            .charging_rate_unit(ChargingRateUnit::Amperes)
350            .charging_profile_period(vec![period(0, "16"), period(1800, "8")])
351            .build();
352        assert!(good.validate().is_ok());
353
354        let backwards = ChargingProfile::builder()
355            .charging_rate_unit(ChargingRateUnit::Amperes)
356            .charging_profile_period(vec![period(1800, "16"), period(0, "8")])
357            .build();
358        let err = backwards.validate().unwrap_err();
359        assert_eq!(err.as_slice()[0].pointer, "/charging_profile_period/1/start_period");
360    }
361
362    #[test]
363    fn limits_take_at_most_one_fractional_digit() {
364        assert!(period(0, "8.1").validate().is_ok());
365        assert!(period(0, "8.15").validate().is_err());
366        assert!(period(0, "-1").validate().is_err());
367    }
368
369    #[test]
370    fn an_accepted_active_profile_result_must_carry_the_profile() {
371        let empty =
372            ActiveChargingProfileResult::builder().result(ChargingProfileResultType::Accepted).build();
373        assert_eq!(empty.validate().unwrap_err().as_slice()[0].pointer, "/profile");
374
375        let rejected =
376            ActiveChargingProfileResult::builder().result(ChargingProfileResultType::Rejected).build();
377        assert!(rejected.validate().is_ok());
378    }
379
380    #[test]
381    fn duration_must_cover_the_last_period() {
382        let p = ChargingProfile::builder()
383            .charging_rate_unit(ChargingRateUnit::Watts)
384            .duration(1800u64)
385            .charging_profile_period(vec![period(0, "11000"), period(3600, "7400")])
386            .build();
387        assert!(p.validate().unwrap_err().as_slice().iter().any(|x| x.pointer == "/duration"));
388    }
389}