1use 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#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, Builder)]
22#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
23#[builder(on(_, into))]
24pub struct SetChargingProfile {
25 pub charging_profile: ChargingProfile,
27 pub response_url: Url,
29 #[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#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, Builder)]
45#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
46#[builder(on(_, into))]
47pub struct ChargingProfileResponse {
48 pub result: ChargingProfileResponseType,
50 pub timeout: u32,
52 #[serde(flatten, default, skip_serializing_if = "Extensions::is_empty")]
54 #[builder(default)]
55 pub extensions: Extensions,
56}
57
58impl ChargingProfileResponse {
59 #[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 #[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#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, Builder)]
89#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
90#[builder(on(_, into))]
91pub struct ActiveChargingProfileResult {
92 pub result: ChargingProfileResultType,
94 #[serde(default, skip_serializing_if = "Option::is_none")]
96 pub profile: Option<ActiveChargingProfile>,
97 #[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#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
126#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
127pub struct ChargingProfileResult {
128 pub result: ChargingProfileResultType,
130 #[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#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
145#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
146pub struct ClearProfileResult {
147 pub result: ChargingProfileResultType,
149 #[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#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, Builder)]
164#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
165#[builder(on(_, into))]
166pub struct ActiveChargingProfile {
167 pub start_date_time: DateTime,
171 pub charging_profile: ChargingProfile,
173 #[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#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, Builder)]
189#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
190#[builder(on(_, into))]
191pub struct ChargingProfile {
192 #[serde(default, skip_serializing_if = "Option::is_none")]
196 pub start_date_time: Option<DateTime>,
197 #[serde(default, skip_serializing_if = "Option::is_none")]
202 pub duration: Option<u64>,
203 pub charging_rate_unit: ChargingRateUnit,
205 #[serde(default, skip_serializing_if = "Option::is_none")]
209 pub min_charging_rate: Option<Number>,
210 #[serde(default, skip_serializing_if = "Vec::is_empty")]
212 #[builder(default)]
213 pub charging_profile_period: Vec<ChargingProfilePeriod>,
214 #[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 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#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
263#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
264pub struct ChargingProfilePeriod {
265 pub start_period: u64,
267 pub limit: Number,
271 #[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 pub enum ChargingRateUnit {
293 Watts = "W",
295 Amperes = "A",
297 }
298}
299
300ocpi_enum! {
301 pub enum ChargingProfileResponseType {
305 Accepted = "ACCEPTED",
307 NotSupported = "NOT_SUPPORTED",
309 Rejected = "REJECTED",
311 TooOften = "TOO_OFTEN",
313 UnknownSession = "UNKNOWN_SESSION",
315 }
316}
317
318ocpi_enum! {
319 pub enum ChargingProfileResultType {
325 Accepted = "ACCEPTED",
327 Rejected = "REJECTED",
329 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}