use bon::Builder;
use serde::{Deserialize, Serialize};
use crate::ocpi_enum;
use crate::ocpi_open_enum;
use crate::types::validate_fields;
use crate::types::{CiString, DateTime, DisplayText, Extensions, Url, Validate, Validator, ViolationCode};
use super::tokens::Token;
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, Builder)]
#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
#[builder(on(_, into))]
pub struct StartSession {
pub response_url: Url,
pub token: Token,
pub location_id: CiString<36>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub evse_uid: Option<CiString<36>>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub connector_id: Option<CiString<36>>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub authorization_reference: Option<CiString<36>>,
#[serde(flatten, default, skip_serializing_if = "Extensions::is_empty")]
#[builder(default)]
pub extensions: Extensions,
}
impl Validate for StartSession {
fn validate_in(&self, v: &mut Validator) {
validate_fields!(
self,
v,
response_url,
token,
location_id,
evse_uid,
connector_id,
authorization_reference,
);
if self.connector_id.is_some() && self.evse_uid.is_none() {
v.report_at(
"evse_uid",
ViolationCode::MissingConditional,
"is required when `connector_id` is set",
);
}
}
}
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, Builder)]
#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
#[builder(on(_, into))]
pub struct StopSession {
pub response_url: Url,
pub session_id: CiString<36>,
#[serde(flatten, default, skip_serializing_if = "Extensions::is_empty")]
#[builder(default)]
pub extensions: Extensions,
}
impl Validate for StopSession {
fn validate_in(&self, v: &mut Validator) {
validate_fields!(self, v, response_url, session_id);
}
}
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, Builder)]
#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
#[builder(on(_, into))]
pub struct ReserveNow {
pub response_url: Url,
pub token: Token,
pub expiry_date: DateTime,
pub reservation_id: CiString<36>,
pub location_id: CiString<36>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub evse_uid: Option<CiString<36>>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub authorization_reference: Option<CiString<36>>,
#[serde(flatten, default, skip_serializing_if = "Extensions::is_empty")]
#[builder(default)]
pub extensions: Extensions,
}
impl Validate for ReserveNow {
fn validate_in(&self, v: &mut Validator) {
validate_fields!(
self,
v,
response_url,
token,
expiry_date,
reservation_id,
location_id,
evse_uid,
authorization_reference,
);
}
}
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, Builder)]
#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
#[builder(on(_, into))]
pub struct CancelReservation {
pub response_url: Url,
pub reservation_id: CiString<36>,
#[serde(flatten, default, skip_serializing_if = "Extensions::is_empty")]
#[builder(default)]
pub extensions: Extensions,
}
impl Validate for CancelReservation {
fn validate_in(&self, v: &mut Validator) {
validate_fields!(self, v, response_url, reservation_id);
}
}
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, Builder)]
#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
#[builder(on(_, into))]
pub struct UnlockConnector {
pub response_url: Url,
pub location_id: CiString<36>,
pub evse_uid: CiString<36>,
pub connector_id: CiString<36>,
#[serde(flatten, default, skip_serializing_if = "Extensions::is_empty")]
#[builder(default)]
pub extensions: Extensions,
}
impl Validate for UnlockConnector {
fn validate_in(&self, v: &mut Validator) {
validate_fields!(self, v, response_url, location_id, evse_uid, connector_id);
}
}
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, Builder)]
#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
#[builder(on(_, into))]
pub struct CommandResponse {
pub result: CommandResponseType,
pub timeout: u32,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
#[builder(default)]
pub message: Vec<DisplayText>,
#[serde(flatten, default, skip_serializing_if = "Extensions::is_empty")]
#[builder(default)]
pub extensions: Extensions,
}
impl CommandResponse {
#[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 == CommandResponseType::Accepted
}
}
impl Validate for CommandResponse {
fn validate_in(&self, v: &mut Validator) {
validate_fields!(self, v, result, message);
if self.result == CommandResponseType::Accepted && self.timeout == 0 {
v.report_at(
"timeout",
ViolationCode::OutOfRange,
"an accepted command 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 CommandResult {
pub result: CommandResultType,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
#[builder(default)]
pub message: Vec<DisplayText>,
#[serde(flatten, default, skip_serializing_if = "Extensions::is_empty")]
#[builder(default)]
pub extensions: Extensions,
}
impl Validate for CommandResult {
fn validate_in(&self, v: &mut Validator) {
validate_fields!(self, v, result, message);
}
}
#[derive(Clone, Debug, PartialEq)]
#[non_exhaustive]
pub enum Command {
CancelReservation(CancelReservation),
ReserveNow(Box<ReserveNow>),
StartSession(Box<StartSession>),
StopSession(StopSession),
UnlockConnector(UnlockConnector),
}
impl Command {
#[must_use]
pub fn command_type(&self) -> CommandType {
match self {
Self::CancelReservation(_) => CommandType::CancelReservation,
Self::ReserveNow(_) => CommandType::ReserveNow,
Self::StartSession(_) => CommandType::StartSession,
Self::StopSession(_) => CommandType::StopSession,
Self::UnlockConnector(_) => CommandType::UnlockConnector,
}
}
#[must_use]
pub fn response_url(&self) -> &Url {
match self {
Self::CancelReservation(c) => &c.response_url,
Self::ReserveNow(c) => &c.response_url,
Self::StartSession(c) => &c.response_url,
Self::StopSession(c) => &c.response_url,
Self::UnlockConnector(c) => &c.response_url,
}
}
}
impl Validate for Command {
fn validate_in(&self, v: &mut Validator) {
match self {
Self::CancelReservation(c) => c.validate_in(v),
Self::ReserveNow(c) => c.validate_in(v),
Self::StartSession(c) => c.validate_in(v),
Self::StopSession(c) => c.validate_in(v),
Self::UnlockConnector(c) => c.validate_in(v),
}
}
}
ocpi_enum! {
pub enum CommandResponseType {
NotSupported = "NOT_SUPPORTED",
Rejected = "REJECTED",
Accepted = "ACCEPTED",
UnknownSession = "UNKNOWN_SESSION",
}
}
ocpi_enum! {
pub enum CommandResultType {
Accepted = "ACCEPTED",
CanceledReservation = "CANCELED_RESERVATION",
EvseOccupied = "EVSE_OCCUPIED",
EvseInoperative = "EVSE_INOPERATIVE",
Failed = "FAILED",
NotSupported = "NOT_SUPPORTED",
Rejected = "REJECTED",
Timeout = "TIMEOUT",
UnknownReservation = "UNKNOWN_RESERVATION",
}
}
ocpi_open_enum! {
pub enum CommandType {
CancelReservation = "CANCEL_RESERVATION",
ReserveNow = "RESERVE_NOW",
StartSession = "START_SESSION",
StopSession = "STOP_SESSION",
UnlockConnector = "UNLOCK_CONNECTOR",
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::types::Url;
fn url() -> Url {
Url::new("https://msp.example.com/ocpi/emsp/2.3.0/commands/START_SESSION/1234").unwrap()
}
#[test]
fn a_connector_id_without_an_evse_uid_is_incomplete() {
let token = crate::v2_3_0::tokens::Token::builder()
.country_code("NL")
.party_id("TNM")
.uid("012345678")
.token_type(crate::v2_3_0::tokens::TokenType::AppUser)
.contract_id("NL-TNM-C12345678-X")
.issuer("TheNewMotion")
.valid(true)
.whitelist(crate::v2_3_0::tokens::WhitelistType::Never)
.last_updated("2024-01-01T00:00:00Z".parse::<DateTime>().unwrap())
.build();
let cmd = StartSession::builder()
.response_url(url())
.token(token)
.location_id("LOC1")
.connector_id("1")
.build();
let err = cmd.validate().unwrap_err();
assert_eq!(err.as_slice()[0].pointer, "/evse_uid");
}
#[test]
fn only_an_accepted_response_promises_a_result() {
let accepted =
CommandResponse::builder().result(CommandResponseType::Accepted).timeout(30u32).build();
assert!(accepted.expects_result());
assert_eq!(accepted.timeout_duration(), std::time::Duration::from_secs(30));
assert!(accepted.validate().is_ok());
let rejected = CommandResponse::builder().result(CommandResponseType::Rejected).timeout(0u32).build();
assert!(!rejected.expects_result());
assert!(rejected.validate().is_ok(), "a rejected command needs no timeout");
let bad = CommandResponse::builder().result(CommandResponseType::Accepted).timeout(0u32).build();
assert_eq!(bad.validate().unwrap_err().as_slice()[0].pointer, "/timeout");
}
#[test]
fn response_and_result_enums_stay_distinct() {
assert!("CANCELED_RESERVATION".parse::<CommandResponseType>().is_err());
assert!("UNKNOWN_SESSION".parse::<CommandResultType>().is_err());
assert_eq!(
"CANCELED_RESERVATION".parse::<CommandResultType>().unwrap(),
CommandResultType::CanceledReservation
);
}
#[test]
fn the_command_enum_names_its_own_type_and_callback() {
let cmd = Command::StopSession(StopSession::builder().response_url(url()).session_id("101").build());
assert_eq!(cmd.command_type(), CommandType::StopSession);
assert_eq!(cmd.response_url(), &url());
}
}