1use bon::Builder;
11use serde::{Deserialize, Serialize};
12
13use crate::ocpi_enum;
14use crate::types::validate_fields;
15use crate::types::{
16 CiString, CountryCode, Currency, DateTime, Extensions, Number, OcpiString, PartyId, PartyRef, Validate,
17 Validator, ViolationCode,
18};
19
20use super::cdrs::{AuthMethod, CdrDimensionType, CdrToken, ChargingPeriod};
21use super::types::Price;
22
23#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, Builder)]
31#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
32#[builder(on(_, into))]
33pub struct Session {
34 pub country_code: CountryCode,
36 pub party_id: PartyId,
38 pub id: CiString<36>,
40 pub start_date_time: DateTime,
46 #[serde(default, skip_serializing_if = "Option::is_none")]
48 pub end_date_time: Option<DateTime>,
49 pub kwh: Number,
51 pub cdr_token: CdrToken,
53 pub auth_method: AuthMethod,
55 #[serde(default, skip_serializing_if = "Option::is_none")]
57 pub authorization_reference: Option<CiString<36>>,
58 pub location_id: CiString<36>,
60 pub evse_uid: CiString<36>,
65 pub connector_id: CiString<36>,
67 #[serde(default, skip_serializing_if = "Option::is_none")]
69 pub meter_id: Option<OcpiString<255>>,
70 pub currency: Currency,
72 #[serde(default, skip_serializing_if = "Vec::is_empty")]
74 #[builder(default)]
75 pub charging_periods: Vec<ChargingPeriod>,
76 #[serde(default, skip_serializing_if = "Option::is_none")]
81 pub total_cost: Option<Price>,
82 pub status: SessionStatus,
84 pub last_updated: DateTime,
86 #[serde(flatten, default, skip_serializing_if = "Extensions::is_empty")]
88 #[builder(default)]
89 pub extensions: Extensions,
90}
91
92impl Session {
93 #[must_use]
95 pub fn owner_party(&self) -> PartyRef {
96 PartyRef { country_code: self.country_code.clone(), party_id: self.party_id.clone() }
97 }
98
99 #[must_use]
103 pub fn has_assigned_evse(&self) -> bool {
104 !self.evse_uid.is_not_available() && !self.connector_id.is_not_available()
105 }
106
107 #[must_use]
109 pub fn is_final(&self) -> bool {
110 matches!(self.status, SessionStatus::Completed | SessionStatus::Invalid)
111 }
112
113 #[must_use]
115 pub fn dimension_total(&self, dimension: CdrDimensionType) -> Number {
116 self.charging_periods
117 .iter()
118 .flat_map(|p| p.dimensions.iter())
119 .filter(|d| d.dimension_type == dimension)
120 .map(|d| d.volume)
121 .sum()
122 }
123}
124
125impl Validate for Session {
126 fn validate_in(&self, v: &mut Validator) {
127 validate_fields!(
128 self,
129 v,
130 country_code,
131 party_id,
132 id,
133 start_date_time,
134 end_date_time,
135 kwh,
136 cdr_token,
137 auth_method,
138 authorization_reference,
139 location_id,
140 evse_uid,
141 connector_id,
142 meter_id,
143 currency,
144 charging_periods,
145 total_cost,
146 status,
147 last_updated,
148 );
149 crate::v2_3_0::cdrs::validate_period_sequence(
150 &self.charging_periods.iter().map(|p| p.start_date_time).collect::<Vec<_>>(),
151 self.start_date_time,
152 self.end_date_time,
153 v,
154 );
155
156 if self.end_date_time.is_some_and(|end| end < self.start_date_time) {
157 v.report_at(
158 "end_date_time",
159 ViolationCode::Inconsistent,
160 "a session cannot end before it starts",
161 );
162 }
163 if self.kwh.is_negative() {
164 v.report_at("kwh", ViolationCode::OutOfRange, "a session cannot charge negative energy");
165 }
166 if self.status == SessionStatus::Completed && self.end_date_time.is_none() {
169 v.report_at(
170 "end_date_time",
171 ViolationCode::MissingConditional,
172 "a COMPLETED session has finished and should carry the time it finished",
173 );
174 }
175 if self.status == SessionStatus::Reservation && self.has_assigned_evse() {
176 }
179 }
180}
181
182#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, Builder)]
186#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
187#[builder(on(_, into))]
188pub struct ChargingPreferences {
189 pub profile_type: ProfileType,
194 #[serde(default, skip_serializing_if = "Option::is_none")]
196 pub departure_time: Option<DateTime>,
197 #[serde(default, skip_serializing_if = "Option::is_none")]
199 pub energy_need: Option<Number>,
200 #[serde(default, skip_serializing_if = "Option::is_none")]
202 pub discharge_allowed: Option<bool>,
203 #[serde(flatten, default, skip_serializing_if = "Extensions::is_empty")]
205 #[builder(default)]
206 pub extensions: Extensions,
207}
208
209impl ChargingPreferences {
210 #[must_use]
212 pub fn discharge_allowed_or_default(&self) -> bool {
213 self.discharge_allowed.unwrap_or(false)
214 }
215}
216
217impl Validate for ChargingPreferences {
218 fn validate_in(&self, v: &mut Validator) {
219 validate_fields!(self, v, profile_type, departure_time, energy_need);
220 if self.energy_need.is_some_and(Number::is_negative) {
221 v.report_at("energy_need", ViolationCode::OutOfRange, "cannot be negative");
222 }
223 }
224}
225
226ocpi_enum! {
227 pub enum ChargingPreferencesResponse {
235 Accepted = "ACCEPTED",
237 DepartureRequired = "DEPARTURE_REQUIRED",
239 EnergyNeedRequired = "ENERGY_NEED_REQUIRED",
241 NotPossible = "NOT_POSSIBLE",
243 ProfileTypeNotSupported = "PROFILE_TYPE_NOT_SUPPORTED",
245 }
246}
247
248ocpi_enum! {
249 pub enum ProfileType {
256 Cheap = "CHEAP",
258 Fast = "FAST",
260 Green = "GREEN",
262 Regular = "REGULAR",
264 }
265}
266
267ocpi_enum! {
268 pub enum SessionStatus {
272 Active = "ACTIVE",
274 Completed = "COMPLETED",
276 Invalid = "INVALID",
278 Pending = "PENDING",
280 Reservation = "RESERVATION",
282 }
283}
284
285impl SessionStatus {
286 #[must_use]
288 pub const fn is_terminal(self) -> bool {
289 matches!(self, Self::Completed | Self::Invalid)
290 }
291}
292
293#[cfg(test)]
294mod tests {
295 use super::*;
296
297 fn session(status: SessionStatus, end: Option<&str>) -> Session {
298 Session::builder()
299 .country_code("NL")
300 .party_id("STK")
301 .id("101")
302 .start_date_time("2020-03-09T10:17:09Z".parse::<DateTime>().unwrap())
303 .maybe_end_date_time(end.map(|e| e.parse::<DateTime>().unwrap()))
304 .kwh(Number::ZERO)
305 .cdr_token(
306 super::super::cdrs::CdrToken::builder()
307 .country_code("NL")
308 .party_id("TST")
309 .uid("123abc")
310 .token_type(super::super::tokens::TokenType::Rfid)
311 .contract_id("NL-TST-C12345678-S")
312 .build(),
313 )
314 .auth_method(AuthMethod::Whitelist)
315 .location_id("LOC1")
316 .evse_uid("3256")
317 .connector_id("1")
318 .currency("EUR")
319 .status(status)
320 .last_updated("2020-03-09T10:17:09Z".parse::<DateTime>().unwrap())
321 .build()
322 }
323
324 #[test]
325 fn a_completed_session_must_say_when_it_ended() {
326 assert!(session(SessionStatus::Active, None).validate().is_ok());
327 let err = session(SessionStatus::Completed, None).validate().unwrap_err();
328 assert_eq!(err.as_slice()[0].pointer, "/end_date_time");
329 assert!(session(SessionStatus::Completed, Some("2020-03-09T11:21:00Z")).validate().is_ok());
330 }
331
332 #[test]
333 fn a_session_cannot_end_before_it_starts() {
334 let s = session(SessionStatus::Completed, Some("2020-03-09T09:00:00Z"));
335 assert!(s.validate().unwrap_err().as_slice().iter().any(|x| x.code == ViolationCode::Inconsistent));
336 }
337
338 #[test]
339 fn reservation_sessions_may_carry_the_na_sentinel() {
340 let mut s = session(SessionStatus::Reservation, None);
341 s.evse_uid = CiString::new("#NA").unwrap();
342 s.connector_id = CiString::new("#NA").unwrap();
343 assert!(!s.has_assigned_evse());
344 assert!(s.validate().is_ok());
345 }
346
347 #[test]
348 fn terminal_states_match_the_spec_table() {
349 assert!(SessionStatus::Completed.is_terminal());
350 assert!(SessionStatus::Invalid.is_terminal());
351 assert!(!SessionStatus::Pending.is_terminal());
352 }
353}