Skip to main content

ocpi_kit/v2_3_0/
sessions.rs

1//! The *Sessions* module of OCPI 2.3.0: the live view of a charging session.
2//!
3//! *Module Identifier: `sessions`* — Data owner: CPO.
4//!
5//! > *The Session object is dynamic as it reflects the current state of the charging session.
6//! > The information is meant to be viewed by the driver while the charging session is ongoing.*
7//!
8//! Spec: 2.3.0 §mod_sessions_sessions_module
9
10use 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/// One charging session, as it stands right now.
24///
25/// > *That doesn't mean it is required that energy has been transferred between EV and the
26/// > Charge Point. … as the EV was connected to the Charge Point, some form of start tariff, park
27/// > tariff or reservation cost might be relevant.*
28///
29/// Spec: 2.3.0 §mod_sessions_session_object
30#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, Builder)]
31#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
32#[builder(on(_, into))]
33pub struct Session {
34    /// ISO-3166 alpha-2 country code of the CPO that 'owns' this Session.
35    pub country_code: CountryCode,
36    /// ID of the CPO that 'owns' this Session.
37    pub party_id: PartyId,
38    /// The unique id that identifies the charging session in the CPO platform.
39    pub id: CiString<36>,
40    /// When the session became `ACTIVE` in the Charge Point.
41    ///
42    /// > *When the session is still `PENDING`, this field SHALL be set to the time the Session
43    /// > was created at the Charge Point. When a Session goes from `PENDING` to `ACTIVE`, this
44    /// > field SHALL be updated to the moment the Session went to `ACTIVE`.*
45    pub start_date_time: DateTime,
46    /// When the session was completed. Charging may have finished earlier.
47    #[serde(default, skip_serializing_if = "Option::is_none")]
48    pub end_date_time: Option<DateTime>,
49    /// How many kWh were charged.
50    pub kwh: Number,
51    /// Token used to start this charging session.
52    pub cdr_token: CdrToken,
53    /// Method used for authentication. This might change during a session.
54    pub auth_method: AuthMethod,
55    /// Reference to the authorization given by the eMSP.
56    #[serde(default, skip_serializing_if = "Option::is_none")]
57    pub authorization_reference: Option<CiString<36>>,
58    /// `Location.id` on which the charging session is or was happening.
59    pub location_id: CiString<36>,
60    /// `EVSE.uid` on which the charging session is or was happening.
61    ///
62    /// > *Allowed to be set to `#NA` when this session is created for a reservation, but no EVSE
63    /// > yet assigned to the driver.*
64    pub evse_uid: CiString<36>,
65    /// `Connector.id` where the charging session is or was happening. May be `#NA`.
66    pub connector_id: CiString<36>,
67    /// Optional identification of the kWh meter.
68    #[serde(default, skip_serializing_if = "Option::is_none")]
69    pub meter_id: Option<OcpiString<255>>,
70    /// ISO 4217 code of the currency used for this session.
71    pub currency: Currency,
72    /// Charging Periods that can be used to calculate and verify the total cost.
73    #[serde(default, skip_serializing_if = "Vec::is_empty")]
74    #[builder(default)]
75    pub charging_periods: Vec<ChargingPeriod>,
76    /// The total cost of the session.
77    ///
78    /// > *A total_cost of 0.00 means free of charge. When omitted … it does not imply the session
79    /// > is/was free of charge.*
80    #[serde(default, skip_serializing_if = "Option::is_none")]
81    pub total_cost: Option<Price>,
82    /// The status of the session.
83    pub status: SessionStatus,
84    /// Timestamp when this Session was last updated (or created).
85    pub last_updated: DateTime,
86    /// Undocumented JSON fields, preserved verbatim.
87    #[serde(flatten, default, skip_serializing_if = "Extensions::is_empty")]
88    #[builder(default)]
89    pub extensions: Extensions,
90}
91
92impl Session {
93    /// The CPO that owns this Session.
94    #[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    /// Whether an EVSE and connector have been assigned yet.
100    ///
101    /// Both fields may carry the `#NA` sentinel while a reservation has not been taken up.
102    #[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    /// Whether the session has reached a state that will not change again.
108    #[must_use]
109    pub fn is_final(&self) -> bool {
110        matches!(self.status, SessionStatus::Completed | SessionStatus::Invalid)
111    }
112
113    /// The total volume of one dimension across every charging period.
114    #[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        // A COMPLETED session has finished: "No more modifications will be made to the Session
167        // object using this state."
168        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            // Not a violation, just worth noting that the spec expects `#NA` here until the
177            // driver arrives; a CPO that already knows the EVSE may legitimately name it.
178        }
179    }
180}
181
182/// The charging preferences an EV driver set for a session.
183///
184/// Spec: 2.3.0 §mod_sessions_charging_preferences_object
185#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, Builder)]
186#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
187#[builder(on(_, into))]
188pub struct ChargingPreferences {
189    /// Type of Smart Charging Profile selected by the driver.
190    ///
191    /// > *The ProfileType has to be supported at the Connector and for every supported
192    /// > ProfileType, a Tariff MUST be provided.*
193    pub profile_type: ProfileType,
194    /// Expected departure, as an estimate given by the driver.
195    #[serde(default, skip_serializing_if = "Option::is_none")]
196    pub departure_time: Option<DateTime>,
197    /// Requested amount of energy in kWh.
198    #[serde(default, skip_serializing_if = "Option::is_none")]
199    pub energy_need: Option<Number>,
200    /// Whether the driver allows their EV to be discharged. Default if omitted: `false`.
201    #[serde(default, skip_serializing_if = "Option::is_none")]
202    pub discharge_allowed: Option<bool>,
203    /// Undocumented JSON fields, preserved verbatim.
204    #[serde(flatten, default, skip_serializing_if = "Extensions::is_empty")]
205    #[builder(default)]
206    pub extensions: Extensions,
207}
208
209impl ChargingPreferences {
210    /// Whether discharging is allowed, applying the spec's default of `false`.
211    #[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    /// Possible responses to a `PUT` of [`ChargingPreferences`].
228    ///
229    /// > *If a PUT with ChargingPreferences is received for an EVSE that does not have the
230    /// > capability `CHARGING_PREFERENCES_CAPABLE`, the receiver should respond with an HTTP
231    /// > status of 404 and an OCPI status code of 2001.*
232    ///
233    /// Spec: 2.3.0 §mod_sessions_charging_preferences_response_enum
234    pub enum ChargingPreferencesResponse {
235        /// Accepted; the EVSE will try to accomplish them, without guarantee.
236        Accepted = "ACCEPTED",
237        /// The CPO requires `departure_time` for preference-based smart charging.
238        DepartureRequired = "DEPARTURE_REQUIRED",
239        /// The CPO requires `energy_need` for preference-based smart charging.
240        EnergyNeedRequired = "ENERGY_NEED_REQUIRED",
241        /// The preferences contain a demand the EVSE knows it cannot fulfil.
242        NotPossible = "NOT_POSSIBLE",
243        /// `profile_type` contains a value the EVSE does not support.
244        ProfileTypeNotSupported = "PROFILE_TYPE_NOT_SUPPORTED",
245    }
246}
247
248ocpi_enum! {
249    /// The smart charging profile a driver can choose between.
250    ///
251    /// Each profile type a Connector supports needs its own Tariff, so the driver can see what
252    /// each option costs. See [`TariffType`](crate::v2_3_0::tariffs::TariffType).
253    ///
254    /// Spec: 2.3.0 §mod_sessions_profile_type_enum
255    pub enum ProfileType {
256        /// The driver wants the cheapest charging profile possible.
257        Cheap = "CHEAP",
258        /// The driver wants their EV charged as quickly as possible.
259        Fast = "FAST",
260        /// The driver wants as much regenerative (green) energy as possible.
261        Green = "GREEN",
262        /// The driver has no special preferences.
263        Regular = "REGULAR",
264    }
265}
266
267ocpi_enum! {
268    /// The state of a session.
269    ///
270    /// Spec: 2.3.0 §mod_sessions_sessionstatus_enum
271    pub enum SessionStatus {
272        /// Accepted and active; all pre-conditions were met.
273        Active = "ACTIVE",
274        /// Finished successfully. No more modifications will be made.
275        Completed = "COMPLETED",
276        /// Declared invalid; will not be billed.
277        Invalid = "INVALID",
278        /// Not yet started; the initial state. It might never become active.
279        Pending = "PENDING",
280        /// Started due to a reservation; charging has not yet started.
281        Reservation = "RESERVATION",
282    }
283}
284
285impl SessionStatus {
286    /// Whether the session can still change.
287    #[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}