use bon::Builder;
use serde::{Deserialize, Serialize};
use crate::ocpi_enum;
use crate::types::validate_fields;
use crate::types::{
CiString, CountryCode, Currency, DateTime, Extensions, Number, OcpiString, PartyId, PartyRef, Validate,
Validator, ViolationCode,
};
use super::cdrs::{AuthMethod, CdrDimensionType, CdrToken, ChargingPeriod};
use super::types::Price;
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, Builder)]
#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
#[builder(on(_, into))]
pub struct Session {
pub country_code: CountryCode,
pub party_id: PartyId,
pub id: CiString<36>,
pub start_date_time: DateTime,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub end_date_time: Option<DateTime>,
pub kwh: Number,
pub cdr_token: CdrToken,
pub auth_method: AuthMethod,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub authorization_reference: Option<CiString<36>>,
pub location_id: CiString<36>,
pub evse_uid: CiString<36>,
pub connector_id: CiString<36>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub meter_id: Option<OcpiString<255>>,
pub currency: Currency,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
#[builder(default)]
pub charging_periods: Vec<ChargingPeriod>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub total_cost: Option<Price>,
pub status: SessionStatus,
pub last_updated: DateTime,
#[serde(flatten, default, skip_serializing_if = "Extensions::is_empty")]
#[builder(default)]
pub extensions: Extensions,
}
impl Session {
#[must_use]
pub fn owner_party(&self) -> PartyRef {
PartyRef { country_code: self.country_code.clone(), party_id: self.party_id.clone() }
}
#[must_use]
pub fn has_assigned_evse(&self) -> bool {
!self.evse_uid.is_not_available() && !self.connector_id.is_not_available()
}
#[must_use]
pub fn is_final(&self) -> bool {
matches!(self.status, SessionStatus::Completed | SessionStatus::Invalid)
}
#[must_use]
pub fn dimension_total(&self, dimension: CdrDimensionType) -> Number {
self.charging_periods
.iter()
.flat_map(|p| p.dimensions.iter())
.filter(|d| d.dimension_type == dimension)
.map(|d| d.volume)
.sum()
}
}
impl Validate for Session {
fn validate_in(&self, v: &mut Validator) {
validate_fields!(
self,
v,
country_code,
party_id,
id,
start_date_time,
end_date_time,
kwh,
cdr_token,
auth_method,
authorization_reference,
location_id,
evse_uid,
connector_id,
meter_id,
currency,
charging_periods,
total_cost,
status,
last_updated,
);
crate::v2_3_0::cdrs::validate_period_sequence(
&self.charging_periods.iter().map(|p| p.start_date_time).collect::<Vec<_>>(),
self.start_date_time,
self.end_date_time,
v,
);
if self.end_date_time.is_some_and(|end| end < self.start_date_time) {
v.report_at(
"end_date_time",
ViolationCode::Inconsistent,
"a session cannot end before it starts",
);
}
if self.kwh.is_negative() {
v.report_at("kwh", ViolationCode::OutOfRange, "a session cannot charge negative energy");
}
if self.status == SessionStatus::Completed && self.end_date_time.is_none() {
v.report_at(
"end_date_time",
ViolationCode::MissingConditional,
"a COMPLETED session has finished and should carry the time it finished",
);
}
if self.status == SessionStatus::Reservation && self.has_assigned_evse() {
}
}
}
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, Builder)]
#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
#[builder(on(_, into))]
pub struct ChargingPreferences {
pub profile_type: ProfileType,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub departure_time: Option<DateTime>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub energy_need: Option<Number>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub discharge_allowed: Option<bool>,
#[serde(flatten, default, skip_serializing_if = "Extensions::is_empty")]
#[builder(default)]
pub extensions: Extensions,
}
impl ChargingPreferences {
#[must_use]
pub fn discharge_allowed_or_default(&self) -> bool {
self.discharge_allowed.unwrap_or(false)
}
}
impl Validate for ChargingPreferences {
fn validate_in(&self, v: &mut Validator) {
validate_fields!(self, v, profile_type, departure_time, energy_need);
if self.energy_need.is_some_and(Number::is_negative) {
v.report_at("energy_need", ViolationCode::OutOfRange, "cannot be negative");
}
}
}
ocpi_enum! {
pub enum ChargingPreferencesResponse {
Accepted = "ACCEPTED",
DepartureRequired = "DEPARTURE_REQUIRED",
EnergyNeedRequired = "ENERGY_NEED_REQUIRED",
NotPossible = "NOT_POSSIBLE",
ProfileTypeNotSupported = "PROFILE_TYPE_NOT_SUPPORTED",
}
}
ocpi_enum! {
pub enum ProfileType {
Cheap = "CHEAP",
Fast = "FAST",
Green = "GREEN",
Regular = "REGULAR",
}
}
ocpi_enum! {
pub enum SessionStatus {
Active = "ACTIVE",
Completed = "COMPLETED",
Invalid = "INVALID",
Pending = "PENDING",
Reservation = "RESERVATION",
}
}
impl SessionStatus {
#[must_use]
pub const fn is_terminal(self) -> bool {
matches!(self, Self::Completed | Self::Invalid)
}
}
#[cfg(test)]
mod tests {
use super::*;
fn session(status: SessionStatus, end: Option<&str>) -> Session {
Session::builder()
.country_code("NL")
.party_id("STK")
.id("101")
.start_date_time("2020-03-09T10:17:09Z".parse::<DateTime>().unwrap())
.maybe_end_date_time(end.map(|e| e.parse::<DateTime>().unwrap()))
.kwh(Number::ZERO)
.cdr_token(
super::super::cdrs::CdrToken::builder()
.country_code("NL")
.party_id("TST")
.uid("123abc")
.token_type(super::super::tokens::TokenType::Rfid)
.contract_id("NL-TST-C12345678-S")
.build(),
)
.auth_method(AuthMethod::Whitelist)
.location_id("LOC1")
.evse_uid("3256")
.connector_id("1")
.currency("EUR")
.status(status)
.last_updated("2020-03-09T10:17:09Z".parse::<DateTime>().unwrap())
.build()
}
#[test]
fn a_completed_session_must_say_when_it_ended() {
assert!(session(SessionStatus::Active, None).validate().is_ok());
let err = session(SessionStatus::Completed, None).validate().unwrap_err();
assert_eq!(err.as_slice()[0].pointer, "/end_date_time");
assert!(session(SessionStatus::Completed, Some("2020-03-09T11:21:00Z")).validate().is_ok());
}
#[test]
fn a_session_cannot_end_before_it_starts() {
let s = session(SessionStatus::Completed, Some("2020-03-09T09:00:00Z"));
assert!(s.validate().unwrap_err().as_slice().iter().any(|x| x.code == ViolationCode::Inconsistent));
}
#[test]
fn reservation_sessions_may_carry_the_na_sentinel() {
let mut s = session(SessionStatus::Reservation, None);
s.evse_uid = CiString::new("#NA").unwrap();
s.connector_id = CiString::new("#NA").unwrap();
assert!(!s.has_assigned_evse());
assert!(s.validate().is_ok());
}
#[test]
fn terminal_states_match_the_spec_table() {
assert!(SessionStatus::Completed.is_terminal());
assert!(SessionStatus::Invalid.is_terminal());
assert!(!SessionStatus::Pending.is_terminal());
}
}