use bon::Builder;
use serde::{Deserialize, Serialize};
use crate::ocpi_enum;
use crate::types::validate_fields;
use crate::types::{DateTime, Extensions, Number, Url, Validate, Validator, ViolationCode};
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, Builder)]
#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
#[builder(on(_, into))]
pub struct SetChargingProfile {
pub charging_profile: ChargingProfile,
pub response_url: Url,
#[serde(flatten, default, skip_serializing_if = "Extensions::is_empty")]
#[builder(default)]
pub extensions: Extensions,
}
impl Validate for SetChargingProfile {
fn validate_in(&self, v: &mut Validator) {
validate_fields!(self, v, charging_profile, response_url);
}
}
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, Builder)]
#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
#[builder(on(_, into))]
pub struct ChargingProfileResponse {
pub result: ChargingProfileResponseType,
pub timeout: u32,
#[serde(flatten, default, skip_serializing_if = "Extensions::is_empty")]
#[builder(default)]
pub extensions: Extensions,
}
impl ChargingProfileResponse {
#[must_use]
pub const fn timeout_duration(&self) -> std::time::Duration {
std::time::Duration::from_secs(self.timeout as u64)
}
#[must_use]
pub fn expects_result(&self) -> bool {
self.result == ChargingProfileResponseType::Accepted
}
}
impl Validate for ChargingProfileResponse {
fn validate_in(&self, v: &mut Validator) {
validate_fields!(self, v, result);
if self.result == ChargingProfileResponseType::Accepted && self.timeout == 0 {
v.report_at(
"timeout",
ViolationCode::OutOfRange,
"an accepted request needs a non-zero timeout for the eMSP to wait on",
);
}
}
}
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, Builder)]
#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
#[builder(on(_, into))]
pub struct ActiveChargingProfileResult {
pub result: ChargingProfileResultType,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub profile: Option<ActiveChargingProfile>,
#[serde(flatten, default, skip_serializing_if = "Extensions::is_empty")]
#[builder(default)]
pub extensions: Extensions,
}
impl Validate for ActiveChargingProfileResult {
fn validate_in(&self, v: &mut Validator) {
validate_fields!(self, v, result, profile);
match (self.result, self.profile.is_some()) {
(ChargingProfileResultType::Accepted, false) => v.report_at(
"profile",
ViolationCode::MissingConditional,
"an ACCEPTED result carries the requested ActiveChargingProfile",
),
(ChargingProfileResultType::Rejected | ChargingProfileResultType::Unknown, true) => v.report_at(
"profile",
ViolationCode::Inconsistent,
"a profile is only returned when the result is ACCEPTED",
),
_ => {}
}
}
}
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
pub struct ChargingProfileResult {
pub result: ChargingProfileResultType,
#[serde(flatten, default, skip_serializing_if = "Extensions::is_empty")]
pub extensions: Extensions,
}
impl Validate for ChargingProfileResult {
fn validate_in(&self, v: &mut Validator) {
validate_fields!(self, v, result);
}
}
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
pub struct ClearProfileResult {
pub result: ChargingProfileResultType,
#[serde(flatten, default, skip_serializing_if = "Extensions::is_empty")]
pub extensions: Extensions,
}
impl Validate for ClearProfileResult {
fn validate_in(&self, v: &mut Validator) {
validate_fields!(self, v, result);
}
}
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, Builder)]
#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
#[builder(on(_, into))]
pub struct ActiveChargingProfile {
pub start_date_time: DateTime,
pub charging_profile: ChargingProfile,
#[serde(flatten, default, skip_serializing_if = "Extensions::is_empty")]
#[builder(default)]
pub extensions: Extensions,
}
impl Validate for ActiveChargingProfile {
fn validate_in(&self, v: &mut Validator) {
validate_fields!(self, v, start_date_time, charging_profile);
}
}
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, Builder)]
#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
#[builder(on(_, into))]
pub struct ChargingProfile {
#[serde(default, skip_serializing_if = "Option::is_none")]
pub start_date_time: Option<DateTime>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub duration: Option<u64>,
pub charging_rate_unit: ChargingRateUnit,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub min_charging_rate: Option<Number>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
#[builder(default)]
pub charging_profile_period: Vec<ChargingProfilePeriod>,
#[serde(flatten, default, skip_serializing_if = "Extensions::is_empty")]
#[builder(default)]
pub extensions: Extensions,
}
impl Validate for ChargingProfile {
fn validate_in(&self, v: &mut Validator) {
validate_fields!(self, v, start_date_time, min_charging_rate, charging_profile_period,);
if self.min_charging_rate.is_some_and(|r| r.scale() > 1) {
v.report_at(
"min_charging_rate",
ViolationCode::OutOfRange,
"accepts at most one digit fraction (e.g. 8.1)",
);
}
let mut previous: Option<u64> = None;
for (i, period) in self.charging_profile_period.iter().enumerate() {
if previous.is_some_and(|p| period.start_period <= p) {
v.enter("charging_profile_period");
v.enter(&i.to_string());
v.report_at(
"start_period",
ViolationCode::Inconsistent,
"charging profile periods must be in strictly increasing order",
);
v.leave();
v.leave();
}
previous = Some(period.start_period);
}
if let (Some(duration), Some(last)) = (self.duration, previous)
&& last >= duration
{
v.report_at(
"duration",
ViolationCode::Inconsistent,
"the profile ends before its last period starts",
);
}
}
}
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
pub struct ChargingProfilePeriod {
pub start_period: u64,
pub limit: Number,
#[serde(flatten, default, skip_serializing_if = "Extensions::is_empty")]
pub extensions: Extensions,
}
impl Validate for ChargingProfilePeriod {
fn validate_in(&self, v: &mut Validator) {
validate_fields!(self, v, limit);
if self.limit.is_negative() {
v.report_at("limit", ViolationCode::OutOfRange, "a charging rate limit cannot be negative");
}
if self.limit.scale() > 1 {
v.report_at("limit", ViolationCode::OutOfRange, "accepts at most one digit fraction (e.g. 8.1)");
}
}
}
ocpi_enum! {
pub enum ChargingRateUnit {
Watts = "W",
Amperes = "A",
}
}
ocpi_enum! {
pub enum ChargingProfileResponseType {
Accepted = "ACCEPTED",
NotSupported = "NOT_SUPPORTED",
Rejected = "REJECTED",
TooOften = "TOO_OFTEN",
UnknownSession = "UNKNOWN_SESSION",
}
}
ocpi_enum! {
pub enum ChargingProfileResultType {
Accepted = "ACCEPTED",
Rejected = "REJECTED",
Unknown = "UNKNOWN",
}
}
#[cfg(test)]
mod tests {
use super::*;
fn period(start: u64, limit: &str) -> ChargingProfilePeriod {
ChargingProfilePeriod {
start_period: start,
limit: limit.parse().unwrap(),
extensions: Extensions::new(),
}
}
#[test]
fn profile_periods_must_increase() {
let good = ChargingProfile::builder()
.charging_rate_unit(ChargingRateUnit::Amperes)
.charging_profile_period(vec![period(0, "16"), period(1800, "8")])
.build();
assert!(good.validate().is_ok());
let backwards = ChargingProfile::builder()
.charging_rate_unit(ChargingRateUnit::Amperes)
.charging_profile_period(vec![period(1800, "16"), period(0, "8")])
.build();
let err = backwards.validate().unwrap_err();
assert_eq!(err.as_slice()[0].pointer, "/charging_profile_period/1/start_period");
}
#[test]
fn limits_take_at_most_one_fractional_digit() {
assert!(period(0, "8.1").validate().is_ok());
assert!(period(0, "8.15").validate().is_err());
assert!(period(0, "-1").validate().is_err());
}
#[test]
fn an_accepted_active_profile_result_must_carry_the_profile() {
let empty =
ActiveChargingProfileResult::builder().result(ChargingProfileResultType::Accepted).build();
assert_eq!(empty.validate().unwrap_err().as_slice()[0].pointer, "/profile");
let rejected =
ActiveChargingProfileResult::builder().result(ChargingProfileResultType::Rejected).build();
assert!(rejected.validate().is_ok());
}
#[test]
fn duration_must_cover_the_last_period() {
let p = ChargingProfile::builder()
.charging_rate_unit(ChargingRateUnit::Watts)
.duration(1800u64)
.charging_profile_period(vec![period(0, "11000"), period(3600, "7400")])
.build();
assert!(p.validate().unwrap_err().as_slice().iter().any(|x| x.pointer == "/duration"));
}
}