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, CiText, CountryCode, DateTime, DisplayText, EvseId, Extensions, LocalTime, Number, OcpiString,
PartyId, PartyRef, Url, Validate, Validator, ViolationCode,
};
use super::tokens::TokenType;
#[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 parking_places: Vec<Parking>,
#[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>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub help_phone: Option<CiString<25>>,
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,
parking_places,
directions,
operator,
suboperator,
owner,
facilities,
time_zone,
opening_times,
images,
energy_mix,
help_phone,
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",
);
}
for (i, evse) in self.evses.iter().enumerate() {
for (j, parking) in evse.parking.iter().enumerate() {
if !self.parking_places.iter().any(|p| p.id == parking.parking_id) {
v.enter("evses");
v.enter(&i.to_string());
v.enter("parking");
v.enter(&j.to_string());
v.report_at(
"parking_id",
ViolationCode::Inconsistent,
format!(
"no Parking with id {:?} in this Location's parking_places",
parking.parking_id.as_str()
),
);
v.leave();
v.leave();
v.leave();
v.leave();
}
}
}
}
}
#[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 parking: Vec<EvseParking>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
#[builder(default)]
pub images: Vec<Image>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
#[builder(default)]
pub accepted_service_providers: Vec<OcpiString<50>>,
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 has(&self, capability: &Capability) -> bool {
self.capabilities.contains(capability)
}
#[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,
parking,
images,
accepted_service_providers,
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>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
#[builder(default)]
pub capabilities: Vec<ConnectorCapability>,
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,
capabilities,
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");
}
if let Some(power) = self.max_electric_power
&& power <= 0
{
v.report_at("max_electric_power", ViolationCode::OutOfRange, "must be positive");
}
}
}
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, Builder)]
#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
#[builder(on(_, into))]
pub struct Parking {
pub id: CiString<36>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub physical_reference: Option<OcpiString<12>>,
pub vehicle_types: Vec<VehicleType>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub max_vehicle_weight: Option<Number>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub max_vehicle_height: Option<Number>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub max_vehicle_length: Option<Number>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub max_vehicle_width: Option<Number>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub parking_space_length: Option<Number>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub parking_space_width: Option<Number>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub dangerous_goods_allowed: Option<bool>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub direction: Option<ParkingDirection>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub drive_through: Option<bool>,
pub restricted_to_type: bool,
pub reservation_required: bool,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub time_limit: Option<Number>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub roofed: 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 lighting: Option<bool>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub refrigeration_outlet: Option<bool>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
#[builder(default)]
pub standards: Vec<CiString<36>>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub apds_reference: Option<CiText>,
#[serde(flatten, default, skip_serializing_if = "Extensions::is_empty")]
#[builder(default)]
pub extensions: Extensions,
}
impl Parking {
#[must_use]
pub fn expects_dimensions(&self) -> bool {
self.vehicle_types
.iter()
.any(|t| !matches!(t, VehicleType::PersonalVehicle | VehicleType::Motorcycle))
}
}
impl Validate for Parking {
fn validate_in(&self, v: &mut Validator) {
validate_fields!(
self,
v,
id,
physical_reference,
vehicle_types,
max_vehicle_weight,
max_vehicle_height,
max_vehicle_length,
max_vehicle_width,
parking_space_length,
parking_space_width,
direction,
time_limit,
images,
standards,
apds_reference,
);
if self.vehicle_types.is_empty() {
v.report_at(
"vehicle_types",
ViolationCode::EmptyRequiredList,
"a Parking has cardinality `+` vehicle_types: at least one is required",
);
}
if self.vehicle_types.contains(&VehicleType::Disabled) && self.images.is_empty() {
v.report_at(
"images",
ViolationCode::MissingConditional,
"at least one photograph should be provided when vehicle_types includes DISABLED",
);
}
}
}
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
pub struct AdditionalGeoLocation {
pub latitude: OcpiString<10>,
pub longitude: OcpiString<11>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub name: Option<DisplayText>,
#[serde(flatten, default, skip_serializing_if = "Extensions::is_empty")]
pub extensions: Extensions,
}
impl Validate for AdditionalGeoLocation {
fn validate_in(&self, v: &mut Validator) {
validate_fields!(self, v, latitude, longitude, name);
check_coordinate(v, "latitude", self.latitude.as_str(), 2);
check_coordinate(v, "longitude", self.longitude.as_str(), 3);
}
}
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
pub struct GeoLocation {
pub latitude: OcpiString<10>,
pub longitude: OcpiString<11>,
#[serde(flatten, default, skip_serializing_if = "Extensions::is_empty")]
pub extensions: Extensions,
}
impl GeoLocation {
pub fn new(
latitude: impl Into<String>,
longitude: impl Into<String>,
) -> Result<Self, crate::types::InvalidString> {
Ok(Self {
latitude: OcpiString::new(latitude)?,
longitude: OcpiString::new(longitude)?,
extensions: Extensions::new(),
})
}
#[must_use]
pub fn latitude_decimal(&self) -> Option<Number> {
self.latitude.as_str().parse().ok()
}
#[must_use]
pub fn longitude_decimal(&self) -> Option<Number> {
self.longitude.as_str().parse().ok()
}
}
impl Validate for GeoLocation {
fn validate_in(&self, v: &mut Validator) {
validate_fields!(self, v, latitude, longitude);
check_coordinate(v, "latitude", self.latitude.as_str(), 2);
check_coordinate(v, "longitude", self.longitude.as_str(), 3);
}
}
fn check_coordinate(v: &mut Validator, field: &str, value: &str, max_int_digits: usize) {
let body = value.strip_prefix('-').unwrap_or(value);
let ok = match body.split_once('.') {
Some((int, frac)) => {
!int.is_empty()
&& int.len() <= max_int_digits
&& int.bytes().all(|b| b.is_ascii_digit())
&& (5..=7).contains(&frac.len())
&& frac.bytes().all(|b| b.is_ascii_digit())
}
None => false,
};
if !ok {
v.report_at(
field,
ViolationCode::IllegalCharacter,
format!(
"{value:?} does not match the OCPI coordinate format \
-?[0-9]{{1,{max_int_digits}}}.[0-9]{{5,7}}"
),
);
}
}
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, Builder)]
#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
#[builder(on(_, into))]
pub struct BusinessDetails {
pub name: OcpiString<100>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub website: Option<Url>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub logo: Option<Image>,
#[serde(flatten, default, skip_serializing_if = "Extensions::is_empty")]
#[builder(default)]
pub extensions: Extensions,
}
impl Validate for BusinessDetails {
fn validate_in(&self, v: &mut Validator) {
validate_fields!(self, v, name, website, logo);
}
}
#[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,);
if !self.energy_sources.is_empty() {
let total: Number = self.energy_sources.iter().map(|s| s.percentage).sum();
if total != Number::from(100u32) {
v.report_at(
"energy_sources",
ViolationCode::Inconsistent,
format!("percentages add up to {total}, not 100"),
);
}
}
}
}
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
pub struct EnergySource {
pub source: EnergySourceCategory,
pub percentage: Number,
#[serde(flatten, default, skip_serializing_if = "Extensions::is_empty")]
pub extensions: Extensions,
}
impl Validate for EnergySource {
fn validate_in(&self, v: &mut Validator) {
validate_fields!(self, v, source, percentage);
if self.percentage < Number::ZERO || self.percentage > Number::from(100u32) {
v.report_at("percentage", ViolationCode::OutOfRange, "must be between 0 and 100");
}
}
}
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
pub struct EnvironmentalImpact {
pub category: 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, category, amount);
}
}
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
pub struct EvseParking {
pub parking_id: CiString<36>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub evse_position: Option<EvsePosition>,
#[serde(flatten, default, skip_serializing_if = "Extensions::is_empty")]
pub extensions: Extensions,
}
impl Validate for EvseParking {
fn validate_in(&self, v: &mut Validator) {
validate_fields!(self, v, parking_id, evse_position);
}
}
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
pub struct ExceptionalPeriod {
pub period_begin: DateTime,
pub period_end: DateTime,
#[serde(flatten, default, skip_serializing_if = "Extensions::is_empty")]
pub extensions: Extensions,
}
impl Validate for ExceptionalPeriod {
fn validate_in(&self, v: &mut Validator) {
validate_fields!(self, v, period_begin, period_end);
if self.period_end < self.period_begin {
v.report_at(
"period_end",
ViolationCode::Inconsistent,
"the end of an exceptional period cannot precede its beginning",
);
}
}
}
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, Builder)]
#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
#[builder(on(_, into))]
pub struct Hours {
pub twentyfourseven: bool,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
#[builder(default)]
pub regular_hours: Vec<RegularHours>,
#[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_open_at(&self, instant: DateTime, utc_offset_seconds: i32) -> bool {
let in_period = |p: &ExceptionalPeriod| instant >= p.period_begin && instant < p.period_end;
if self.exceptional_closings.iter().any(in_period) {
return false;
}
if self.exceptional_openings.iter().any(in_period) {
return true;
}
if self.twentyfourseven {
return true;
}
let local = instant.local_parts(utc_offset_seconds);
self.regular_hours
.iter()
.any(|r| r.weekday == local.iso_weekday && local.time.is_within(r.period_begin, r.period_end))
}
}
impl Validate for Hours {
fn validate_in(&self, v: &mut Validator) {
validate_fields!(self, v, regular_hours, exceptional_openings, exceptional_closings);
if !self.twentyfourseven && self.regular_hours.is_empty() {
v.report_at(
"regular_hours",
ViolationCode::MissingConditional,
"when `twentyfourseven` is false this field must contain at least one entry",
);
}
}
}
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
pub struct RegularHours {
pub weekday: u8,
pub period_begin: LocalTime,
pub period_end: LocalTime,
#[serde(flatten, default, skip_serializing_if = "Extensions::is_empty")]
pub extensions: Extensions,
}
impl Validate for RegularHours {
fn validate_in(&self, v: &mut Validator) {
validate_fields!(self, v, period_begin, period_end);
if !(1..=7).contains(&self.weekday) {
v.report_at(
"weekday",
ViolationCode::OutOfRange,
format!("{} is not a day of the week: Monday (1) till Sunday (7)", self.weekday),
);
}
if self.period_end <= self.period_begin {
v.report_at(
"period_end",
ViolationCode::Inconsistent,
format!("{} must be later than period_begin {}", self.period_end, self.period_begin),
);
}
}
}
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, Builder)]
#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
#[builder(on(_, into))]
pub struct Image {
pub url: Url,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub thumbnail: Option<Url>,
pub category: ImageCategory,
#[serde(rename = "type")]
pub image_type: CiString<4>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub width: Option<u32>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub height: Option<u32>,
#[serde(flatten, default, skip_serializing_if = "Extensions::is_empty")]
#[builder(default)]
pub extensions: Extensions,
}
impl Validate for Image {
fn validate_in(&self, v: &mut Validator) {
validate_fields!(self, v, url, thumbnail, category, image_type as "type");
for (name, value) in [("width", self.width), ("height", self.height)] {
if value.is_some_and(|x| x > 99_999) {
v.report_at(name, ViolationCode::OutOfRange, "int(5): at most five digits");
}
}
}
}
#[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",
);
}
}
}
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
pub struct StatusSchedule {
pub period_begin: DateTime,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub period_end: Option<DateTime>,
pub status: Status,
#[serde(flatten, default, skip_serializing_if = "Extensions::is_empty")]
pub extensions: Extensions,
}
impl Validate for StatusSchedule {
fn validate_in(&self, v: &mut Validator) {
validate_fields!(self, v, period_begin, period_end, status);
if self.period_end.is_some_and(|end| end < self.period_begin) {
v.report_at(
"period_end",
ViolationCode::Inconsistent,
"the end of a scheduled period cannot precede its beginning",
);
}
}
}
ocpi_open_enum! {
pub enum Capability {
ChargingProfileCapable = "CHARGING_PROFILE_CAPABLE",
ChargingPreferencesCapable = "CHARGING_PREFERENCES_CAPABLE",
ChipCardSupport = "CHIP_CARD_SUPPORT",
ContactlessCardSupport = "CONTACTLESS_CARD_SUPPORT",
CreditCardPayable = "CREDIT_CARD_PAYABLE",
DebitCardPayable = "DEBIT_CARD_PAYABLE",
PedTerminal = "PED_TERMINAL",
RemoteStartStopCapable = "REMOTE_START_STOP_CAPABLE",
Reservable = "RESERVABLE",
RfidReader = "RFID_READER",
StartSessionConnectorRequired = "START_SESSION_CONNECTOR_REQUIRED",
TokenGroupCapable = "TOKEN_GROUP_CAPABLE",
UnlockCapable = "UNLOCK_CAPABLE",
}
}
ocpi_open_enum! {
pub enum ConnectorCapability {
Iso151182PlugAndCharge = "ISO_15118_2_PLUG_AND_CHARGE",
Iso1511820PlugAndCharge = "ISO_15118_20_PLUG_AND_CHARGE",
}
}
ocpi_enum! {
pub enum ConnectorFormat {
Socket = "SOCKET",
Cable = "CABLE",
}
}
ocpi_open_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",
Mcs = "MCS",
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",
SaeJ3400 = "SAE_J3400",
TeslaR = "TESLA_R",
TeslaS = "TESLA_S",
}
}
ocpi_enum! {
pub enum EnergySourceCategory {
Nuclear = "NUCLEAR",
GeneralFossil = "GENERAL_FOSSIL",
Coal = "COAL",
Gas = "GAS",
GeneralGreen = "GENERAL_GREEN",
Solar = "SOLAR",
Wind = "WIND",
Water = "WATER",
}
}
ocpi_open_enum! {
pub enum EnvironmentalImpactCategory {
NuclearWaste = "NUCLEAR_WASTE",
CarbonDioxide = "CARBON_DIOXIDE",
}
}
ocpi_enum! {
pub enum EvsePosition {
Left = "LEFT",
Right = "RIGHT",
Center = "CENTER",
}
}
ocpi_open_enum! {
pub enum Facility {
Hotel = "HOTEL",
Restaurant = "RESTAURANT",
Cafe = "CAFE",
Mall = "MALL",
Supermarket = "SUPERMARKET",
Sport = "SPORT",
RecreationArea = "RECREATION_AREA",
Nature = "NATURE",
Museum = "MUSEUM",
BikeSharing = "BIKE_SHARING",
BusStop = "BUS_STOP",
TaxiStand = "TAXI_STAND",
TramStop = "TRAM_STOP",
MetroStation = "METRO_STATION",
TrainStation = "TRAIN_STATION",
Airport = "AIRPORT",
ParkingLot = "PARKING_LOT",
CarpoolParking = "CARPOOL_PARKING",
FuelStation = "FUEL_STATION",
Wifi = "WIFI",
}
}
ocpi_open_enum! {
pub enum ImageCategory {
Charger = "CHARGER",
Entrance = "ENTRANCE",
Location = "LOCATION",
Network = "NETWORK",
Operator = "OPERATOR",
Other = "OTHER",
Owner = "OWNER",
}
}
ocpi_enum! {
pub enum ParkingDirection {
Parallel = "PARALLEL",
Perpendicular = "PERPENDICULAR",
Angle = "ANGLE",
}
}
ocpi_open_enum! {
pub enum ParkingRestriction {
Customers = "CUSTOMERS",
Disabled = "DISABLED",
Employees = "EMPLOYEES",
EvOnly = "EV_ONLY",
Motorcycles = "MOTORCYCLES",
Plugged = "PLUGGED",
Taxis = "TAXIS",
Tenants = "TENANTS",
}
}
ocpi_open_enum! {
pub enum ParkingType {
AlongMotorway = "ALONG_MOTORWAY",
ParkingGarage = "PARKING_GARAGE",
ParkingLot = "PARKING_LOT",
OnDriveway = "ON_DRIVEWAY",
OnStreet = "ON_STREET",
UndergroundGarage = "UNDERGROUND_GARAGE",
}
}
ocpi_enum! {
pub enum PowerType {
Ac1Phase = "AC_1_PHASE",
Ac2Phase = "AC_2_PHASE",
Ac2PhaseSplit = "AC_2_PHASE_SPLIT",
Ac3Phase = "AC_3_PHASE",
Dc = "DC",
}
}
ocpi_enum! {
pub enum Status {
Available = "AVAILABLE",
Blocked = "BLOCKED",
Charging = "CHARGING",
Inoperative = "INOPERATIVE",
OutOfOrder = "OUTOFORDER",
Planned = "PLANNED",
Removed = "REMOVED",
Reserved = "RESERVED",
Unknown = "UNKNOWN",
}
}
ocpi_open_enum! {
pub enum VehicleType {
Motorcycle = "MOTORCYCLE",
PersonalVehicle = "PERSONAL_VEHICLE",
PersonalVehicleWithTrailer = "PERSONAL_VEHICLE_WITH_TRAILER",
Van = "VAN",
SemiTractor = "SEMI_TRACTOR",
Rigid = "RIGID",
TruckWithTrailer = "TRUCK_WITH_TRAILER",
Bus = "BUS",
Disabled = "DISABLED",
}
}
#[cfg(test)]
mod tests {
use super::*;
fn geo() -> GeoLocation {
GeoLocation::new("50.770774", "-126.104965").unwrap()
}
#[test]
fn coordinate_format_is_checked_against_the_spec_regex() {
assert!(geo().validate().is_ok());
for (lat, lon) in [
("50.77", "-126.104965"), ("50", "-126.104965"), ("50.7707745678", "-126.1"), ("50.770774", "-1261.104965"), ] {
let g = GeoLocation {
latitude: OcpiString::new_lenient(lat),
longitude: OcpiString::new_lenient(lon),
extensions: Extensions::new(),
};
assert!(g.validate().is_err(), "{lat}/{lon} should be reported");
}
}
#[test]
fn publish_allowed_to_requires_publish_false() {
let mut loc = Location::builder()
.country_code("NL")
.party_id("TNM")
.id("LOC1")
.publish(true)
.address("Street 1")
.city("Amsterdam")
.country("NLD")
.coordinates(geo())
.time_zone("Europe/Amsterdam")
.last_updated("2024-01-01T00:00:00Z".parse::<DateTime>().unwrap())
.build();
assert!(loc.validate().is_ok());
loc.publish_allowed_to =
vec![PublishTokenType { group_id: Some(CiString::new("G1").unwrap()), ..Default::default() }];
let err = loc.validate().unwrap_err();
assert_eq!(err.as_slice()[0].pointer, "/publish_allowed_to");
assert_eq!(err.as_slice()[0].code, ViolationCode::Inconsistent);
}
#[test]
fn publish_token_matching_requires_all_set_fields_to_agree() {
let allowed = PublishTokenType {
visual_number: Some(OcpiString::new("12345").unwrap()),
issuer: Some(OcpiString::new("TheNewMotion").unwrap()),
..Default::default()
};
let same = allowed.clone();
let wrong_issuer =
PublishTokenType { issuer: Some(OcpiString::new("Other").unwrap()), ..allowed.clone() };
let extra_fields = PublishTokenType {
uid: Some(CiString::new("ABC").unwrap()),
token_type: Some(TokenType::Rfid),
..allowed.clone()
};
assert!(allowed.matches(&same));
assert!(!allowed.matches(&wrong_issuer));
assert!(allowed.matches(&extra_fields), "extra information on the candidate is fine");
}
#[test]
fn publish_token_conditional_requirements_are_reported() {
let only_uid = PublishTokenType { uid: Some(CiString::new("ABC").unwrap()), ..Default::default() };
let err = only_uid.validate().unwrap_err();
assert_eq!(err.as_slice()[0].pointer, "/type");
assert!(PublishTokenType::default().validate().is_err(), "one of three must be set");
}
#[test]
fn regular_hours_must_be_a_forward_interval_on_a_real_weekday() {
let ok = RegularHours {
weekday: 1,
period_begin: "08:00".parse().unwrap(),
period_end: "20:00".parse().unwrap(),
extensions: Extensions::new(),
};
assert!(ok.validate().is_ok());
let backwards = RegularHours { period_end: "07:00".parse().unwrap(), ..ok.clone() };
assert!(backwards.validate().is_err());
let no_such_day = RegularHours { weekday: 8, ..ok };
assert!(no_such_day.validate().is_err());
}
#[test]
fn hours_apply_the_precedence_the_spec_gives() {
let dt = |s: &str| s.parse::<DateTime>().unwrap();
let hours = Hours {
twentyfourseven: true,
regular_hours: vec![],
exceptional_openings: vec![],
exceptional_closings: vec![ExceptionalPeriod {
period_begin: dt("2018-12-25T03:00:00Z"),
period_end: dt("2018-12-25T05:00:00Z"),
extensions: Extensions::new(),
}],
extensions: Extensions::new(),
};
assert!(hours.is_open_at(dt("2018-12-25T02:59:59Z"), 0));
assert!(!hours.is_open_at(dt("2018-12-25T04:00:00Z"), 0), "closing beats 24/7");
}
#[test]
fn twentyfourseven_false_needs_regular_hours() {
let empty = Hours {
twentyfourseven: false,
regular_hours: vec![],
exceptional_openings: vec![],
exceptional_closings: vec![],
extensions: Extensions::new(),
};
assert_eq!(empty.validate().unwrap_err().as_slice()[0].code, ViolationCode::MissingConditional);
}
#[test]
fn evse_parking_must_point_at_a_parking_place_of_the_same_location() {
let evse = Evse::builder()
.uid("E1")
.status(Status::Available)
.connectors(vec![])
.parking(vec![EvseParking {
parking_id: CiString::new("P9").unwrap(),
evse_position: None,
extensions: Extensions::new(),
}])
.last_updated("2024-01-01T00:00:00Z".parse::<DateTime>().unwrap())
.build();
let loc = Location::builder()
.country_code("NL")
.party_id("TNM")
.id("LOC1")
.publish(true)
.address("Street 1")
.city("Amsterdam")
.country("NLD")
.coordinates(geo())
.time_zone("Europe/Amsterdam")
.evses(vec![evse])
.last_updated("2024-01-01T00:00:00Z".parse::<DateTime>().unwrap())
.build();
let err = loc.validate().unwrap_err();
assert!(err.as_slice().iter().any(|x| x.pointer == "/evses/0/parking/0/parking_id"), "{err}");
assert!(err.as_slice().iter().any(|x| x.code == ViolationCode::EmptyRequiredList));
}
#[test]
fn unknown_connector_types_survive_a_round_trip() {
let json = r#"{"id":"1","standard":"nltnm-PLUG_X","format":"SOCKET","power_type":"DC","max_voltage":920,"max_amperage":400,"last_updated":"2024-01-01T00:00:00Z"}"#;
let c: Connector = serde_json::from_str(json).unwrap();
assert!(!c.standard.is_known());
assert_eq!(serde_json::to_string(&c).unwrap(), json);
}
}