use bon::Builder;
use serde::{Deserialize, Serialize};
use crate::ocpi_lenient_enum;
use crate::types::validate_fields;
use crate::types::{
DateTime, DisplayText, Extensions, Number, OcpiString, Url, Validate, Validator, ViolationCode,
};
pub use crate::v2_3_0::locations::{
AdditionalGeoLocation, BusinessDetails, ConnectorFormat, EnergySource, EnergySourceCategory,
EnvironmentalImpactCategory, ExceptionalPeriod, GeoLocation, Image, ImageCategory, RegularHours, Status,
StatusSchedule,
};
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
pub struct EnvironmentalImpact {
pub source: EnvironmentalImpactCategory,
pub amount: Number,
#[serde(flatten, default, skip_serializing_if = "Extensions::is_empty")]
pub extensions: Extensions,
}
impl Validate for EnvironmentalImpact {
fn validate_in(&self, v: &mut Validator) {
validate_fields!(self, v, source, amount);
}
}
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, Builder)]
#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
#[builder(on(_, into))]
pub struct EnergyMix {
pub is_green_energy: bool,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
#[builder(default)]
pub energy_sources: Vec<EnergySource>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
#[builder(default)]
pub environ_impact: Vec<EnvironmentalImpact>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub supplier_name: Option<OcpiString<64>>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub energy_product_name: Option<OcpiString<64>>,
#[serde(flatten, default, skip_serializing_if = "Extensions::is_empty")]
#[builder(default)]
pub extensions: Extensions,
}
impl Validate for EnergyMix {
fn validate_in(&self, v: &mut Validator) {
validate_fields!(self, v, energy_sources, environ_impact, supplier_name, energy_product_name);
}
}
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, Builder)]
#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
#[builder(on(_, into))]
pub struct Hours {
#[serde(default, skip_serializing_if = "Vec::is_empty")]
#[builder(default)]
pub regular_hours: Vec<RegularHours>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub twentyfourseven: Option<bool>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
#[builder(default)]
pub exceptional_openings: Vec<ExceptionalPeriod>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
#[builder(default)]
pub exceptional_closings: Vec<ExceptionalPeriod>,
#[serde(flatten, default, skip_serializing_if = "Extensions::is_empty")]
#[builder(default)]
pub extensions: Extensions,
}
impl Hours {
#[must_use]
pub fn is_always_open(&self) -> bool {
self.twentyfourseven.unwrap_or(false)
}
}
impl Validate for Hours {
fn validate_in(&self, v: &mut Validator) {
validate_fields!(self, v, regular_hours, exceptional_openings, exceptional_closings);
match (self.regular_hours.is_empty(), self.twentyfourseven.is_some()) {
(true, false) => v.report(
ViolationCode::MissingConditional,
"Hours is a choice of one of two: either `regular_hours` or `twentyfourseven` \
must be given",
),
(false, true) => v.report(
ViolationCode::Inconsistent,
"Hours is a choice of one of two: `regular_hours` and `twentyfourseven` are \
alternatives, not a combination",
),
_ => {}
}
}
}
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, Builder)]
#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
#[builder(on(_, into))]
pub struct Location {
pub id: OcpiString<39>,
#[serde(rename = "type")]
pub location_type: LocationType,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub name: Option<OcpiString<255>>,
pub address: OcpiString<45>,
pub city: OcpiString<45>,
pub postal_code: OcpiString<10>,
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 = "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>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub time_zone: Option<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 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.as_str() == uid)
}
}
impl Validate for Location {
fn validate_in(&self, v: &mut Validator) {
validate_fields!(
self, v, id, location_type as "type", name, address, city, postal_code, country,
coordinates, related_locations, evses, directions, operator, suboperator, owner,
facilities, time_zone, opening_times, images, energy_mix, last_updated,
);
}
}
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, Builder)]
#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
#[builder(on(_, into))]
pub struct Evse {
pub uid: OcpiString<39>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub evse_id: Option<OcpiString<48>>,
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 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: OcpiString<36>,
pub standard: ConnectorType,
pub format: ConnectorFormat,
pub power_type: PowerType,
pub voltage: i32,
pub amperage: i32,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub tariff_id: Option<OcpiString<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_id,
terms_and_conditions,
last_updated,
);
if self.voltage <= 0 {
v.report_at("voltage", ViolationCode::OutOfRange, "must be a positive voltage");
}
if self.amperage <= 0 {
v.report_at("amperage", ViolationCode::OutOfRange, "must be a positive amperage");
}
}
}
ocpi_lenient_enum! {
pub enum LocationType {
OnStreet = "ON_STREET",
ParkingGarage = "PARKING_GARAGE",
UndergroundGarage = "UNDERGROUND_GARAGE",
ParkingLot = "PARKING_LOT",
Other = "OTHER",
Unknown = "UNKNOWN",
}
}
ocpi_lenient_enum! {
pub enum Capability {
ChargingProfileCapable = "CHARGING_PROFILE_CAPABLE",
CreditCardPayable = "CREDIT_CARD_PAYABLE",
RemoteStartStopCapable = "REMOTE_START_STOP_CAPABLE",
Reservable = "RESERVABLE",
RfidReader = "RFID_READER",
UnlockCapable = "UNLOCK_CAPABLE",
}
}
ocpi_lenient_enum! {
pub enum ConnectorType {
Chademo = "CHADEMO",
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",
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",
TeslaR = "TESLA_R",
TeslaS = "TESLA_S",
}
}
ocpi_lenient_enum! {
pub enum Facility {
Hotel = "HOTEL",
Restaurant = "RESTAURANT",
Cafe = "CAFE",
Mall = "MALL",
Supermarket = "SUPERMARKET",
Sport = "SPORT",
RecreationArea = "RECREATION_AREA",
Nature = "NATURE",
Museum = "MUSEUM",
BusStop = "BUS_STOP",
TaxiStand = "TAXI_STAND",
TrainStation = "TRAIN_STATION",
Airport = "AIRPORT",
CarpoolParking = "CARPOOL_PARKING",
FuelStation = "FUEL_STATION",
Wifi = "WIFI",
}
}
ocpi_lenient_enum! {
pub enum ParkingRestriction {
EvOnly = "EV_ONLY",
Plugged = "PLUGGED",
Disabled = "DISABLED",
Customers = "CUSTOMERS",
Motorcycles = "MOTORCYCLES",
}
}
ocpi_lenient_enum! {
pub enum PowerType {
Ac1Phase = "AC_1_PHASE",
Ac3Phase = "AC_3_PHASE",
Dc = "DC",
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn the_2_1_1_enums_are_much_smaller_than_the_later_ones() {
assert_eq!(ConnectorType::ALL_KNOWN.len(), 25);
let blue: ConnectorType = "IEC_60309_2_single_16".into();
assert!(blue.is_known(), "the blue 16 A industrial socket is a 2.1.1 value");
assert_eq!(serde_json::to_string(&blue).unwrap(), "\"IEC_60309_2_single_16\"");
assert_eq!(Capability::ALL_KNOWN.len(), 6);
assert_eq!(PowerType::ALL_KNOWN.len(), 3);
let mcs: ConnectorType = "MCS".into();
assert_eq!(serde_json::to_string(&mcs).unwrap(), "\"MCS\"");
assert!(mcs.validate().is_err());
}
#[test]
fn a_2_1_1_location_has_no_owner_fields() {
let json = r#"{"id":"LOC1","type":"ON_STREET","address":"F.Rooseveltlaan 3A","city":"Gent","postal_code":"9000","country":"BEL","coordinates":{"latitude":"51.047599","longitude":"3.729944"},"last_updated":"2015-06-29T20:39:09Z"}"#;
let location: Location = serde_json::from_str(json).unwrap();
assert_eq!(location.location_type, LocationType::OnStreet);
assert!(location.validate().is_ok());
assert_eq!(serde_json::to_string(&location).unwrap(), json);
}
#[test]
fn hours_is_a_choice_of_one_of_two_in_2_1_1() {
let weekdays: Hours = serde_json::from_str(
r#"{"regular_hours":[{"weekday":1,"period_begin":"08:00","period_end":"20:00"}]}"#,
)
.unwrap();
assert!(weekdays.validate().is_ok());
assert!(!weekdays.is_always_open());
let always: Hours = serde_json::from_str(r#"{"twentyfourseven":true}"#).unwrap();
assert!(always.validate().is_ok());
assert!(always.is_always_open());
assert!(serde_json::from_str::<Hours>("{}").unwrap().validate().is_err());
let both: Hours = serde_json::from_str(
r#"{"twentyfourseven":true,"regular_hours":[{"weekday":1,"period_begin":"08:00","period_end":"20:00"}]}"#,
)
.unwrap();
assert!(both.validate().is_err());
}
#[test]
fn the_environmental_impact_field_is_named_source_in_2_1_1() {
let json = r#"{"source":"CARBON_DIOXIDE","amount":230}"#;
let impact: EnvironmentalImpact = serde_json::from_str(json).unwrap();
assert_eq!(impact.source, EnvironmentalImpactCategory::CarbonDioxide);
assert_eq!(serde_json::to_string(&impact).unwrap(), json);
assert!(impact.extensions.is_empty(), "nothing fell through into extensions");
}
#[test]
fn evse_uids_compare_case_sensitively_because_2_1_1_says_string() {
let location: Location = serde_json::from_str(
r#"{"id":"LOC1","type":"ON_STREET","address":"a","city":"b","postal_code":"c","country":"NLD","coordinates":{"latitude":"51.047599","longitude":"3.729944"},"evses":[{"uid":"AB123","status":"AVAILABLE","connectors":[{"id":"1","standard":"IEC_62196_T2","format":"SOCKET","power_type":"AC_3_PHASE","voltage":400,"amperage":32,"last_updated":"2015-06-29T20:39:09Z"}],"last_updated":"2015-06-29T20:39:09Z"}],"last_updated":"2015-06-29T20:39:09Z"}"#,
)
.unwrap();
assert!(location.evse("AB123").is_some());
assert!(location.evse("ab123").is_none(), "2.1.1 types EVSE.uid as string, not CiString");
}
}