use bon::Builder;
use serde::{Deserialize, Serialize};
use crate::ocpi_lenient_enum;
use crate::types::validate_fields;
use crate::types::{
CiString, CountryCode, DateTime, DisplayText, EvseId, Extensions, OcpiString, PartyId, PartyRef, Url,
Validate, Validator, ViolationCode,
};
use super::tokens::TokenType;
pub use crate::v2_3_0::locations::{
AdditionalGeoLocation, BusinessDetails, Capability, ConnectorFormat, EnergyMix, EnergySource,
EnergySourceCategory, EnvironmentalImpact, EnvironmentalImpactCategory, ExceptionalPeriod, Facility,
GeoLocation, Hours, Image, ImageCategory, ParkingType, PowerType, RegularHours, Status, StatusSchedule,
};
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, Builder)]
#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
#[builder(on(_, into))]
pub struct Location {
pub country_code: CountryCode,
pub party_id: PartyId,
pub id: CiString<36>,
pub publish: bool,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
#[builder(default)]
pub publish_allowed_to: Vec<PublishTokenType>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub name: Option<OcpiString<255>>,
pub address: OcpiString<255>,
pub city: OcpiString<45>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub postal_code: Option<OcpiString<10>>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub state: Option<OcpiString<45>>,
pub country: OcpiString<3>,
pub coordinates: GeoLocation,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
#[builder(default)]
pub related_locations: Vec<AdditionalGeoLocation>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub parking_type: Option<ParkingType>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
#[builder(default)]
pub evses: Vec<Evse>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
#[builder(default)]
pub directions: Vec<DisplayText>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub operator: Option<BusinessDetails>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub suboperator: Option<BusinessDetails>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub owner: Option<BusinessDetails>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
#[builder(default)]
pub facilities: Vec<Facility>,
pub time_zone: OcpiString<255>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub opening_times: Option<Hours>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub charging_when_closed: Option<bool>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
#[builder(default)]
pub images: Vec<Image>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub energy_mix: Option<EnergyMix>,
pub last_updated: DateTime,
#[serde(flatten, default, skip_serializing_if = "Extensions::is_empty")]
#[builder(default)]
pub extensions: Extensions,
}
impl Location {
#[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 charging_when_closed_or_default(&self) -> bool {
self.charging_when_closed.unwrap_or(true)
}
#[must_use]
pub fn evse(&self, uid: &str) -> Option<&Evse> {
self.evses.iter().find(|e| e.uid.eq_ignore_case(uid))
}
#[must_use]
pub fn may_publish_to(&self, token: Option<&PublishTokenType>) -> bool {
if self.publish {
return true;
}
token.is_some_and(|t| self.publish_allowed_to.iter().any(|allowed| allowed.matches(t)))
}
}
impl Validate for Location {
fn validate_in(&self, v: &mut Validator) {
validate_fields!(
self,
v,
country_code,
party_id,
id,
publish_allowed_to,
name,
address,
city,
postal_code,
state,
country,
coordinates,
related_locations,
parking_type,
evses,
directions,
operator,
suboperator,
owner,
facilities,
time_zone,
opening_times,
images,
energy_mix,
last_updated,
);
if self.publish && !self.publish_allowed_to.is_empty() {
v.report_at(
"publish_allowed_to",
ViolationCode::Inconsistent,
"this field may only be used when `publish` is false",
);
}
}
}
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, Builder)]
#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
#[builder(on(_, into))]
pub struct Evse {
pub uid: CiString<36>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub evse_id: Option<EvseId>,
pub status: Status,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
#[builder(default)]
pub status_schedule: Vec<StatusSchedule>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
#[builder(default)]
pub capabilities: Vec<Capability>,
pub connectors: Vec<Connector>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub floor_level: Option<OcpiString<4>>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub coordinates: Option<GeoLocation>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub physical_reference: Option<OcpiString<16>>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
#[builder(default)]
pub directions: Vec<DisplayText>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
#[builder(default)]
pub parking_restrictions: Vec<ParkingRestriction>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
#[builder(default)]
pub images: Vec<Image>,
pub last_updated: DateTime,
#[serde(flatten, default, skip_serializing_if = "Extensions::is_empty")]
#[builder(default)]
pub extensions: Extensions,
}
impl Evse {
#[must_use]
pub fn requires_connector_id_on_start(&self) -> bool {
self.capabilities.contains(&Capability::StartSessionConnectorRequired)
}
#[must_use]
pub fn connector(&self, id: &str) -> Option<&Connector> {
self.connectors.iter().find(|c| c.id.eq_ignore_case(id))
}
}
impl Validate for Evse {
fn validate_in(&self, v: &mut Validator) {
validate_fields!(
self,
v,
uid,
evse_id,
status_schedule,
capabilities,
connectors,
floor_level,
coordinates,
physical_reference,
directions,
parking_restrictions,
images,
last_updated,
);
if self.connectors.is_empty() {
v.report_at(
"connectors",
ViolationCode::EmptyRequiredList,
"an EVSE has cardinality `+` connectors: at least one is required",
);
}
}
}
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, Builder)]
#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
#[builder(on(_, into))]
pub struct Connector {
pub id: CiString<36>,
pub standard: ConnectorType,
pub format: ConnectorFormat,
pub power_type: PowerType,
pub max_voltage: i32,
pub max_amperage: i32,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub max_electric_power: Option<i32>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
#[builder(default)]
pub tariff_ids: Vec<CiString<36>>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub terms_and_conditions: Option<Url>,
pub last_updated: DateTime,
#[serde(flatten, default, skip_serializing_if = "Extensions::is_empty")]
#[builder(default)]
pub extensions: Extensions,
}
impl Validate for Connector {
fn validate_in(&self, v: &mut Validator) {
validate_fields!(
self,
v,
id,
standard,
format,
power_type,
tariff_ids,
terms_and_conditions,
last_updated,
);
if self.max_voltage <= 0 {
v.report_at("max_voltage", ViolationCode::OutOfRange, "must be a positive voltage");
}
if self.max_amperage <= 0 {
v.report_at("max_amperage", ViolationCode::OutOfRange, "must be a positive amperage");
}
}
}
#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize, Builder)]
#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
#[builder(on(_, into))]
pub struct PublishTokenType {
#[serde(default, skip_serializing_if = "Option::is_none")]
pub uid: Option<CiString<36>>,
#[serde(rename = "type", default, skip_serializing_if = "Option::is_none")]
pub token_type: Option<TokenType>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub visual_number: Option<OcpiString<64>>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub issuer: Option<OcpiString<64>>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub group_id: Option<CiString<36>>,
#[serde(flatten, default, skip_serializing_if = "Extensions::is_empty")]
#[builder(default)]
pub extensions: Extensions,
}
impl PublishTokenType {
#[must_use]
pub fn matches(&self, candidate: &Self) -> bool {
fn agree<T: PartialEq>(required: Option<&T>, given: Option<&T>) -> bool {
required.is_none_or(|r| given == Some(r))
}
agree(self.uid.as_ref(), candidate.uid.as_ref())
&& agree(self.token_type.as_ref(), candidate.token_type.as_ref())
&& agree(self.visual_number.as_ref(), candidate.visual_number.as_ref())
&& agree(self.issuer.as_ref(), candidate.issuer.as_ref())
&& agree(self.group_id.as_ref(), candidate.group_id.as_ref())
}
}
impl Validate for PublishTokenType {
fn validate_in(&self, v: &mut Validator) {
validate_fields!(self, v, uid, token_type as "type", visual_number, issuer, group_id);
if self.uid.is_none() && self.visual_number.is_none() && self.group_id.is_none() {
v.report(
ViolationCode::MissingConditional,
"at least one of `uid`, `visual_number` or `group_id` SHALL be set",
);
}
if self.uid.is_some() && self.token_type.is_none() {
v.report_at("type", ViolationCode::MissingConditional, "SHALL be set when `uid` is set");
}
if self.visual_number.is_some() && self.issuer.is_none() {
v.report_at(
"issuer",
ViolationCode::MissingConditional,
"SHALL be set when `visual_number` is set",
);
}
}
}
ocpi_lenient_enum! {
pub enum ConnectorType {
Chademo = "CHADEMO",
ChaoJi = "CHAOJI",
DomesticA = "DOMESTIC_A",
DomesticB = "DOMESTIC_B",
DomesticC = "DOMESTIC_C",
DomesticD = "DOMESTIC_D",
DomesticE = "DOMESTIC_E",
DomesticF = "DOMESTIC_F",
DomesticG = "DOMESTIC_G",
DomesticH = "DOMESTIC_H",
DomesticI = "DOMESTIC_I",
DomesticJ = "DOMESTIC_J",
DomesticK = "DOMESTIC_K",
DomesticL = "DOMESTIC_L",
DomesticM = "DOMESTIC_M",
DomesticN = "DOMESTIC_N",
DomesticO = "DOMESTIC_O",
GbtAc = "GBT_AC",
GbtDc = "GBT_DC",
Iec603092Single16 = "IEC_60309_2_single_16",
Iec603092Three16 = "IEC_60309_2_three_16",
Iec603092Three32 = "IEC_60309_2_three_32",
Iec603092Three64 = "IEC_60309_2_three_64",
Iec62196T1 = "IEC_62196_T1",
Iec62196T1Combo = "IEC_62196_T1_COMBO",
Iec62196T2 = "IEC_62196_T2",
Iec62196T2Combo = "IEC_62196_T2_COMBO",
Iec62196T3A = "IEC_62196_T3A",
Iec62196T3C = "IEC_62196_T3C",
Nema520 = "NEMA_5_20",
Nema630 = "NEMA_6_30",
Nema650 = "NEMA_6_50",
Nema1030 = "NEMA_10_30",
Nema1050 = "NEMA_10_50",
Nema1430 = "NEMA_14_30",
Nema1450 = "NEMA_14_50",
PantographBottomUp = "PANTOGRAPH_BOTTOM_UP",
PantographTopDown = "PANTOGRAPH_TOP_DOWN",
TeslaR = "TESLA_R",
TeslaS = "TESLA_S",
}
}
ocpi_lenient_enum! {
pub enum ParkingRestriction {
EvOnly = "EV_ONLY",
Plugged = "PLUGGED",
Disabled = "DISABLED",
Customers = "CUSTOMERS",
Motorcycles = "MOTORCYCLES",
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn the_2_3_0_connector_types_are_absent_but_still_decode() {
assert!(ConnectorType::ALL_KNOWN_WIRE.iter().all(|v| *v != "MCS"));
let mcs: ConnectorType = "MCS".into();
assert!(!mcs.is_known(), "MCS arrived in OCPI 2.3.0");
assert_eq!(serde_json::to_string(&mcs).unwrap(), "\"MCS\"");
assert!(mcs.validate().is_err());
assert!(ConnectorType::Iec62196T2.validate().is_ok());
}
#[test]
fn the_2_3_0_parking_restrictions_are_absent() {
assert_eq!(ConnectorType::ALL_KNOWN.len(), 40);
assert_eq!(ParkingRestriction::ALL_KNOWN.len(), 5);
assert!(!ParkingRestriction::from("TENANTS").is_known());
}
#[test]
fn wire_identical_types_are_the_same_rust_type_in_both_versions() {
let geo: GeoLocation = crate::v2_3_0::locations::GeoLocation::new("52.010", "4.35000").unwrap();
let _: crate::v2_3_0::locations::GeoLocation = geo;
}
}