1use bon::Builder;
8use serde::{Deserialize, Serialize};
9
10use crate::ocpi_enum;
11use crate::ocpi_open_enum;
12use crate::types::validate_fields;
13use crate::types::{
14 CiString, CiText, CountryCode, DateTime, DisplayText, EvseId, Extensions, LocalTime, Number, OcpiString,
15 PartyId, PartyRef, Url, Validate, Validator, ViolationCode,
16};
17
18use super::tokens::TokenType;
19
20#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, Builder)]
27#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
28#[builder(on(_, into))]
29pub struct Location {
30 pub country_code: CountryCode,
32 pub party_id: PartyId,
34 pub id: CiString<36>,
36 pub publish: bool,
41 #[serde(default, skip_serializing_if = "Vec::is_empty")]
43 #[builder(default)]
44 pub publish_allowed_to: Vec<PublishTokenType>,
45 #[serde(default, skip_serializing_if = "Option::is_none")]
47 pub name: Option<OcpiString<255>>,
48 pub address: OcpiString<255>,
52 pub city: OcpiString<45>,
54 #[serde(default, skip_serializing_if = "Option::is_none")]
56 pub postal_code: Option<OcpiString<10>>,
57 #[serde(default, skip_serializing_if = "Option::is_none")]
61 pub state: Option<OcpiString<45>>,
62 pub country: OcpiString<3>,
64 pub coordinates: GeoLocation,
66 #[serde(default, skip_serializing_if = "Vec::is_empty")]
68 #[builder(default)]
69 pub related_locations: Vec<AdditionalGeoLocation>,
70 #[serde(default, skip_serializing_if = "Option::is_none")]
72 pub parking_type: Option<ParkingType>,
73 #[serde(default, skip_serializing_if = "Vec::is_empty")]
75 #[builder(default)]
76 pub evses: Vec<Evse>,
77 #[serde(default, skip_serializing_if = "Vec::is_empty")]
79 #[builder(default)]
80 pub parking_places: Vec<Parking>,
81 #[serde(default, skip_serializing_if = "Vec::is_empty")]
83 #[builder(default)]
84 pub directions: Vec<DisplayText>,
85 #[serde(default, skip_serializing_if = "Option::is_none")]
87 pub operator: Option<BusinessDetails>,
88 #[serde(default, skip_serializing_if = "Option::is_none")]
90 pub suboperator: Option<BusinessDetails>,
91 #[serde(default, skip_serializing_if = "Option::is_none")]
93 pub owner: Option<BusinessDetails>,
94 #[serde(default, skip_serializing_if = "Vec::is_empty")]
96 #[builder(default)]
97 pub facilities: Vec<Facility>,
98 pub time_zone: OcpiString<255>,
103 #[serde(default, skip_serializing_if = "Option::is_none")]
105 pub opening_times: Option<Hours>,
106 #[serde(default, skip_serializing_if = "Option::is_none")]
108 pub charging_when_closed: Option<bool>,
109 #[serde(default, skip_serializing_if = "Vec::is_empty")]
111 #[builder(default)]
112 pub images: Vec<Image>,
113 #[serde(default, skip_serializing_if = "Option::is_none")]
115 pub energy_mix: Option<EnergyMix>,
116 #[serde(default, skip_serializing_if = "Option::is_none")]
118 pub help_phone: Option<CiString<25>>,
119 pub last_updated: DateTime,
121 #[serde(flatten, default, skip_serializing_if = "Extensions::is_empty")]
123 #[builder(default)]
124 pub extensions: Extensions,
125}
126
127impl Location {
128 #[must_use]
130 pub fn owner_party(&self) -> PartyRef {
131 PartyRef { country_code: self.country_code.clone(), party_id: self.party_id.clone() }
132 }
133
134 #[must_use]
138 pub fn charging_when_closed_or_default(&self) -> bool {
139 self.charging_when_closed.unwrap_or(true)
140 }
141
142 #[must_use]
144 pub fn evse(&self, uid: &str) -> Option<&Evse> {
145 self.evses.iter().find(|e| e.uid.eq_ignore_case(uid))
146 }
147
148 #[must_use]
160 pub fn may_publish_to(&self, token: Option<&PublishTokenType>) -> bool {
161 if self.publish {
162 return true;
163 }
164 token.is_some_and(|t| self.publish_allowed_to.iter().any(|allowed| allowed.matches(t)))
165 }
166}
167
168impl Validate for Location {
169 fn validate_in(&self, v: &mut Validator) {
170 validate_fields!(
171 self,
172 v,
173 country_code,
174 party_id,
175 id,
176 publish_allowed_to,
177 name,
178 address,
179 city,
180 postal_code,
181 state,
182 country,
183 coordinates,
184 related_locations,
185 parking_type,
186 evses,
187 parking_places,
188 directions,
189 operator,
190 suboperator,
191 owner,
192 facilities,
193 time_zone,
194 opening_times,
195 images,
196 energy_mix,
197 help_phone,
198 last_updated,
199 );
200 if self.publish && !self.publish_allowed_to.is_empty() {
201 v.report_at(
202 "publish_allowed_to",
203 ViolationCode::Inconsistent,
204 "this field may only be used when `publish` is false",
205 );
206 }
207 for (i, evse) in self.evses.iter().enumerate() {
210 for (j, parking) in evse.parking.iter().enumerate() {
211 if !self.parking_places.iter().any(|p| p.id == parking.parking_id) {
212 v.enter("evses");
213 v.enter(&i.to_string());
214 v.enter("parking");
215 v.enter(&j.to_string());
216 v.report_at(
217 "parking_id",
218 ViolationCode::Inconsistent,
219 format!(
220 "no Parking with id {:?} in this Location's parking_places",
221 parking.parking_id.as_str()
222 ),
223 );
224 v.leave();
225 v.leave();
226 v.leave();
227 v.leave();
228 }
229 }
230 }
231 }
232}
233
234#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, Builder)]
241#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
242#[builder(on(_, into))]
243pub struct Evse {
244 pub uid: CiString<36>,
250 #[serde(default, skip_serializing_if = "Option::is_none")]
255 pub evse_id: Option<EvseId>,
256 pub status: Status,
258 #[serde(default, skip_serializing_if = "Vec::is_empty")]
260 #[builder(default)]
261 pub status_schedule: Vec<StatusSchedule>,
262 #[serde(default, skip_serializing_if = "Vec::is_empty")]
264 #[builder(default)]
265 pub capabilities: Vec<Capability>,
266 pub connectors: Vec<Connector>,
268 #[serde(default, skip_serializing_if = "Option::is_none")]
270 pub floor_level: Option<OcpiString<4>>,
271 #[serde(default, skip_serializing_if = "Option::is_none")]
273 pub coordinates: Option<GeoLocation>,
274 #[serde(default, skip_serializing_if = "Option::is_none")]
276 pub physical_reference: Option<OcpiString<16>>,
277 #[serde(default, skip_serializing_if = "Vec::is_empty")]
279 #[builder(default)]
280 pub directions: Vec<DisplayText>,
281 #[serde(default, skip_serializing_if = "Vec::is_empty")]
283 #[builder(default)]
284 pub parking_restrictions: Vec<ParkingRestriction>,
285 #[serde(default, skip_serializing_if = "Vec::is_empty")]
287 #[builder(default)]
288 pub parking: Vec<EvseParking>,
289 #[serde(default, skip_serializing_if = "Vec::is_empty")]
291 #[builder(default)]
292 pub images: Vec<Image>,
293 #[serde(default, skip_serializing_if = "Vec::is_empty")]
300 #[builder(default)]
301 pub accepted_service_providers: Vec<OcpiString<50>>,
302 pub last_updated: DateTime,
304 #[serde(flatten, default, skip_serializing_if = "Extensions::is_empty")]
306 #[builder(default)]
307 pub extensions: Extensions,
308}
309
310impl Evse {
311 #[must_use]
318 pub fn requires_connector_id_on_start(&self) -> bool {
319 self.capabilities.contains(&Capability::StartSessionConnectorRequired)
320 }
321
322 #[must_use]
324 pub fn has(&self, capability: &Capability) -> bool {
325 self.capabilities.contains(capability)
326 }
327
328 #[must_use]
330 pub fn connector(&self, id: &str) -> Option<&Connector> {
331 self.connectors.iter().find(|c| c.id.eq_ignore_case(id))
332 }
333}
334
335impl Validate for Evse {
336 fn validate_in(&self, v: &mut Validator) {
337 validate_fields!(
338 self,
339 v,
340 uid,
341 evse_id,
342 status_schedule,
343 capabilities,
344 connectors,
345 floor_level,
346 coordinates,
347 physical_reference,
348 directions,
349 parking_restrictions,
350 parking,
351 images,
352 accepted_service_providers,
353 last_updated,
354 );
355 if self.connectors.is_empty() {
356 v.report_at(
357 "connectors",
358 ViolationCode::EmptyRequiredList,
359 "an EVSE has cardinality `+` connectors: at least one is required",
360 );
361 }
362 }
363}
364
365#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, Builder)]
369#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
370#[builder(on(_, into))]
371pub struct Connector {
372 pub id: CiString<36>,
376 pub standard: ConnectorType,
378 pub format: ConnectorFormat,
380 pub power_type: PowerType,
382 pub max_voltage: i32,
384 pub max_amperage: i32,
386 #[serde(default, skip_serializing_if = "Option::is_none")]
391 pub max_electric_power: Option<i32>,
392 #[serde(default, skip_serializing_if = "Vec::is_empty")]
398 #[builder(default)]
399 pub tariff_ids: Vec<CiString<36>>,
400 #[serde(default, skip_serializing_if = "Option::is_none")]
402 pub terms_and_conditions: Option<Url>,
403 #[serde(default, skip_serializing_if = "Vec::is_empty")]
405 #[builder(default)]
406 pub capabilities: Vec<ConnectorCapability>,
407 pub last_updated: DateTime,
409 #[serde(flatten, default, skip_serializing_if = "Extensions::is_empty")]
411 #[builder(default)]
412 pub extensions: Extensions,
413}
414
415impl Validate for Connector {
416 fn validate_in(&self, v: &mut Validator) {
417 validate_fields!(
418 self,
419 v,
420 id,
421 standard,
422 format,
423 power_type,
424 tariff_ids,
425 terms_and_conditions,
426 capabilities,
427 last_updated,
428 );
429 if self.max_voltage <= 0 {
430 v.report_at("max_voltage", ViolationCode::OutOfRange, "must be a positive voltage");
431 }
432 if self.max_amperage <= 0 {
433 v.report_at("max_amperage", ViolationCode::OutOfRange, "must be a positive amperage");
434 }
435 if let Some(power) = self.max_electric_power
436 && power <= 0
437 {
438 v.report_at("max_electric_power", ViolationCode::OutOfRange, "must be positive");
439 }
440 }
441}
442
443#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, Builder)]
452#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
453#[builder(on(_, into))]
454pub struct Parking {
455 pub id: CiString<36>,
457 #[serde(default, skip_serializing_if = "Option::is_none")]
459 pub physical_reference: Option<OcpiString<12>>,
460 pub vehicle_types: Vec<VehicleType>,
462 #[serde(default, skip_serializing_if = "Option::is_none")]
464 pub max_vehicle_weight: Option<Number>,
465 #[serde(default, skip_serializing_if = "Option::is_none")]
467 pub max_vehicle_height: Option<Number>,
468 #[serde(default, skip_serializing_if = "Option::is_none")]
470 pub max_vehicle_length: Option<Number>,
471 #[serde(default, skip_serializing_if = "Option::is_none")]
473 pub max_vehicle_width: Option<Number>,
474 #[serde(default, skip_serializing_if = "Option::is_none")]
476 pub parking_space_length: Option<Number>,
477 #[serde(default, skip_serializing_if = "Option::is_none")]
479 pub parking_space_width: Option<Number>,
480 #[serde(default, skip_serializing_if = "Option::is_none")]
482 pub dangerous_goods_allowed: Option<bool>,
483 #[serde(default, skip_serializing_if = "Option::is_none")]
485 pub direction: Option<ParkingDirection>,
486 #[serde(default, skip_serializing_if = "Option::is_none")]
488 pub drive_through: Option<bool>,
489 pub restricted_to_type: bool,
491 pub reservation_required: bool,
493 #[serde(default, skip_serializing_if = "Option::is_none")]
495 pub time_limit: Option<Number>,
496 #[serde(default, skip_serializing_if = "Option::is_none")]
498 pub roofed: Option<bool>,
499 #[serde(default, skip_serializing_if = "Vec::is_empty")]
504 #[builder(default)]
505 pub images: Vec<Image>,
506 #[serde(default, skip_serializing_if = "Option::is_none")]
508 pub lighting: Option<bool>,
509 #[serde(default, skip_serializing_if = "Option::is_none")]
511 pub refrigeration_outlet: Option<bool>,
512 #[serde(default, skip_serializing_if = "Vec::is_empty")]
514 #[builder(default)]
515 pub standards: Vec<CiString<36>>,
516 #[serde(default, skip_serializing_if = "Option::is_none")]
519 pub apds_reference: Option<CiText>,
520 #[serde(flatten, default, skip_serializing_if = "Extensions::is_empty")]
522 #[builder(default)]
523 pub extensions: Extensions,
524}
525
526impl Parking {
527 #[must_use]
532 pub fn expects_dimensions(&self) -> bool {
533 self.vehicle_types
534 .iter()
535 .any(|t| !matches!(t, VehicleType::PersonalVehicle | VehicleType::Motorcycle))
536 }
537}
538
539impl Validate for Parking {
540 fn validate_in(&self, v: &mut Validator) {
541 validate_fields!(
542 self,
543 v,
544 id,
545 physical_reference,
546 vehicle_types,
547 max_vehicle_weight,
548 max_vehicle_height,
549 max_vehicle_length,
550 max_vehicle_width,
551 parking_space_length,
552 parking_space_width,
553 direction,
554 time_limit,
555 images,
556 standards,
557 apds_reference,
558 );
559 if self.vehicle_types.is_empty() {
560 v.report_at(
561 "vehicle_types",
562 ViolationCode::EmptyRequiredList,
563 "a Parking has cardinality `+` vehicle_types: at least one is required",
564 );
565 }
566 if self.vehicle_types.contains(&VehicleType::Disabled) && self.images.is_empty() {
567 v.report_at(
568 "images",
569 ViolationCode::MissingConditional,
570 "at least one photograph should be provided when vehicle_types includes DISABLED",
571 );
572 }
573 }
574}
575
576#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
586#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
587pub struct AdditionalGeoLocation {
588 pub latitude: OcpiString<10>,
590 pub longitude: OcpiString<11>,
592 #[serde(default, skip_serializing_if = "Option::is_none")]
594 pub name: Option<DisplayText>,
595 #[serde(flatten, default, skip_serializing_if = "Extensions::is_empty")]
597 pub extensions: Extensions,
598}
599
600impl Validate for AdditionalGeoLocation {
601 fn validate_in(&self, v: &mut Validator) {
602 validate_fields!(self, v, latitude, longitude, name);
603 check_coordinate(v, "latitude", self.latitude.as_str(), 2);
604 check_coordinate(v, "longitude", self.longitude.as_str(), 3);
605 }
606}
607
608#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
619#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
620pub struct GeoLocation {
621 pub latitude: OcpiString<10>,
623 pub longitude: OcpiString<11>,
625 #[serde(flatten, default, skip_serializing_if = "Extensions::is_empty")]
627 pub extensions: Extensions,
628}
629
630impl GeoLocation {
631 pub fn new(
637 latitude: impl Into<String>,
638 longitude: impl Into<String>,
639 ) -> Result<Self, crate::types::InvalidString> {
640 Ok(Self {
641 latitude: OcpiString::new(latitude)?,
642 longitude: OcpiString::new(longitude)?,
643 extensions: Extensions::new(),
644 })
645 }
646
647 #[must_use]
649 pub fn latitude_decimal(&self) -> Option<Number> {
650 self.latitude.as_str().parse().ok()
651 }
652
653 #[must_use]
655 pub fn longitude_decimal(&self) -> Option<Number> {
656 self.longitude.as_str().parse().ok()
657 }
658}
659
660impl Validate for GeoLocation {
661 fn validate_in(&self, v: &mut Validator) {
662 validate_fields!(self, v, latitude, longitude);
663 check_coordinate(v, "latitude", self.latitude.as_str(), 2);
664 check_coordinate(v, "longitude", self.longitude.as_str(), 3);
665 }
666}
667
668fn check_coordinate(v: &mut Validator, field: &str, value: &str, max_int_digits: usize) {
670 let body = value.strip_prefix('-').unwrap_or(value);
671 let ok = match body.split_once('.') {
672 Some((int, frac)) => {
673 !int.is_empty()
674 && int.len() <= max_int_digits
675 && int.bytes().all(|b| b.is_ascii_digit())
676 && (5..=7).contains(&frac.len())
677 && frac.bytes().all(|b| b.is_ascii_digit())
678 }
679 None => false,
680 };
681 if !ok {
682 v.report_at(
683 field,
684 ViolationCode::IllegalCharacter,
685 format!(
686 "{value:?} does not match the OCPI coordinate format \
687 -?[0-9]{{1,{max_int_digits}}}.[0-9]{{5,7}}"
688 ),
689 );
690 }
691}
692
693#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, Builder)]
697#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
698#[builder(on(_, into))]
699pub struct BusinessDetails {
700 pub name: OcpiString<100>,
702 #[serde(default, skip_serializing_if = "Option::is_none")]
704 pub website: Option<Url>,
705 #[serde(default, skip_serializing_if = "Option::is_none")]
707 pub logo: Option<Image>,
708 #[serde(flatten, default, skip_serializing_if = "Extensions::is_empty")]
710 #[builder(default)]
711 pub extensions: Extensions,
712}
713
714impl Validate for BusinessDetails {
715 fn validate_in(&self, v: &mut Validator) {
716 validate_fields!(self, v, name, website, logo);
717 }
718}
719
720#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, Builder)]
724#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
725#[builder(on(_, into))]
726pub struct EnergyMix {
727 pub is_green_energy: bool,
729 #[serde(default, skip_serializing_if = "Vec::is_empty")]
731 #[builder(default)]
732 pub energy_sources: Vec<EnergySource>,
733 #[serde(default, skip_serializing_if = "Vec::is_empty")]
735 #[builder(default)]
736 pub environ_impact: Vec<EnvironmentalImpact>,
737 #[serde(default, skip_serializing_if = "Option::is_none")]
739 pub supplier_name: Option<OcpiString<64>>,
740 #[serde(default, skip_serializing_if = "Option::is_none")]
742 pub energy_product_name: Option<OcpiString<64>>,
743 #[serde(flatten, default, skip_serializing_if = "Extensions::is_empty")]
745 #[builder(default)]
746 pub extensions: Extensions,
747}
748
749impl Validate for EnergyMix {
750 fn validate_in(&self, v: &mut Validator) {
751 validate_fields!(self, v, energy_sources, environ_impact, supplier_name, energy_product_name,);
752 if !self.energy_sources.is_empty() {
754 let total: Number = self.energy_sources.iter().map(|s| s.percentage).sum();
755 if total != Number::from(100u32) {
756 v.report_at(
757 "energy_sources",
758 ViolationCode::Inconsistent,
759 format!("percentages add up to {total}, not 100"),
760 );
761 }
762 }
763 }
764}
765
766#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
770#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
771pub struct EnergySource {
772 pub source: EnergySourceCategory,
774 pub percentage: Number,
776 #[serde(flatten, default, skip_serializing_if = "Extensions::is_empty")]
778 pub extensions: Extensions,
779}
780
781impl Validate for EnergySource {
782 fn validate_in(&self, v: &mut Validator) {
783 validate_fields!(self, v, source, percentage);
784 if self.percentage < Number::ZERO || self.percentage > Number::from(100u32) {
785 v.report_at("percentage", ViolationCode::OutOfRange, "must be between 0 and 100");
786 }
787 }
788}
789
790#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
794#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
795pub struct EnvironmentalImpact {
796 pub category: EnvironmentalImpactCategory,
798 pub amount: Number,
800 #[serde(flatten, default, skip_serializing_if = "Extensions::is_empty")]
802 pub extensions: Extensions,
803}
804
805impl Validate for EnvironmentalImpact {
806 fn validate_in(&self, v: &mut Validator) {
807 validate_fields!(self, v, category, amount);
808 }
809}
810
811#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
815#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
816pub struct EvseParking {
817 pub parking_id: CiString<36>,
819 #[serde(default, skip_serializing_if = "Option::is_none")]
821 pub evse_position: Option<EvsePosition>,
822 #[serde(flatten, default, skip_serializing_if = "Extensions::is_empty")]
824 pub extensions: Extensions,
825}
826
827impl Validate for EvseParking {
828 fn validate_in(&self, v: &mut Validator) {
829 validate_fields!(self, v, parking_id, evse_position);
830 }
831}
832
833#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
837#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
838pub struct ExceptionalPeriod {
839 pub period_begin: DateTime,
841 pub period_end: DateTime,
843 #[serde(flatten, default, skip_serializing_if = "Extensions::is_empty")]
845 pub extensions: Extensions,
846}
847
848impl Validate for ExceptionalPeriod {
849 fn validate_in(&self, v: &mut Validator) {
850 validate_fields!(self, v, period_begin, period_end);
851 if self.period_end < self.period_begin {
852 v.report_at(
853 "period_end",
854 ViolationCode::Inconsistent,
855 "the end of an exceptional period cannot precede its beginning",
856 );
857 }
858 }
859}
860
861#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, Builder)]
865#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
866#[builder(on(_, into))]
867pub struct Hours {
868 pub twentyfourseven: bool,
870 #[serde(default, skip_serializing_if = "Vec::is_empty")]
872 #[builder(default)]
873 pub regular_hours: Vec<RegularHours>,
874 #[serde(default, skip_serializing_if = "Vec::is_empty")]
876 #[builder(default)]
877 pub exceptional_openings: Vec<ExceptionalPeriod>,
878 #[serde(default, skip_serializing_if = "Vec::is_empty")]
880 #[builder(default)]
881 pub exceptional_closings: Vec<ExceptionalPeriod>,
882 #[serde(flatten, default, skip_serializing_if = "Extensions::is_empty")]
884 #[builder(default)]
885 pub extensions: Extensions,
886}
887
888impl Hours {
889 #[must_use]
896 pub fn is_open_at(&self, instant: DateTime, utc_offset_seconds: i32) -> bool {
897 let in_period = |p: &ExceptionalPeriod| instant >= p.period_begin && instant < p.period_end;
898 if self.exceptional_closings.iter().any(in_period) {
899 return false;
900 }
901 if self.exceptional_openings.iter().any(in_period) {
902 return true;
903 }
904 if self.twentyfourseven {
905 return true;
906 }
907 let local = instant.as_offset_date_time() + time::Duration::seconds(i64::from(utc_offset_seconds));
908 let weekday = local.weekday().number_from_monday();
909 let Ok(now) = LocalTime::new(local.hour(), local.minute()) else {
910 return false;
911 };
912 self.regular_hours.iter().any(|r| r.weekday == weekday && now.is_within(r.period_begin, r.period_end))
913 }
914}
915
916impl Validate for Hours {
917 fn validate_in(&self, v: &mut Validator) {
918 validate_fields!(self, v, regular_hours, exceptional_openings, exceptional_closings);
919 if !self.twentyfourseven && self.regular_hours.is_empty() {
920 v.report_at(
921 "regular_hours",
922 ViolationCode::MissingConditional,
923 "when `twentyfourseven` is false this field must contain at least one entry",
924 );
925 }
926 }
927}
928
929#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
933#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
934pub struct RegularHours {
935 pub weekday: u8,
937 pub period_begin: LocalTime,
939 pub period_end: LocalTime,
941 #[serde(flatten, default, skip_serializing_if = "Extensions::is_empty")]
943 pub extensions: Extensions,
944}
945
946impl Validate for RegularHours {
947 fn validate_in(&self, v: &mut Validator) {
948 validate_fields!(self, v, period_begin, period_end);
949 if !(1..=7).contains(&self.weekday) {
950 v.report_at(
951 "weekday",
952 ViolationCode::OutOfRange,
953 format!("{} is not a day of the week: Monday (1) till Sunday (7)", self.weekday),
954 );
955 }
956 if self.period_end <= self.period_begin {
959 v.report_at(
960 "period_end",
961 ViolationCode::Inconsistent,
962 format!("{} must be later than period_begin {}", self.period_end, self.period_begin),
963 );
964 }
965 }
966}
967
968#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, Builder)]
972#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
973#[builder(on(_, into))]
974pub struct Image {
975 pub url: Url,
977 #[serde(default, skip_serializing_if = "Option::is_none")]
979 pub thumbnail: Option<Url>,
980 pub category: ImageCategory,
982 #[serde(rename = "type")]
984 pub image_type: CiString<4>,
985 #[serde(default, skip_serializing_if = "Option::is_none")]
987 pub width: Option<u32>,
988 #[serde(default, skip_serializing_if = "Option::is_none")]
990 pub height: Option<u32>,
991 #[serde(flatten, default, skip_serializing_if = "Extensions::is_empty")]
993 #[builder(default)]
994 pub extensions: Extensions,
995}
996
997impl Validate for Image {
998 fn validate_in(&self, v: &mut Validator) {
999 validate_fields!(self, v, url, thumbnail, category, image_type as "type");
1000 for (name, value) in [("width", self.width), ("height", self.height)] {
1002 if value.is_some_and(|x| x > 99_999) {
1003 v.report_at(name, ViolationCode::OutOfRange, "int(5): at most five digits");
1004 }
1005 }
1006 }
1007}
1008
1009#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize, Builder)]
1017#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
1018#[builder(on(_, into))]
1019pub struct PublishTokenType {
1020 #[serde(default, skip_serializing_if = "Option::is_none")]
1022 pub uid: Option<CiString<36>>,
1023 #[serde(rename = "type", default, skip_serializing_if = "Option::is_none")]
1025 pub token_type: Option<TokenType>,
1026 #[serde(default, skip_serializing_if = "Option::is_none")]
1028 pub visual_number: Option<OcpiString<64>>,
1029 #[serde(default, skip_serializing_if = "Option::is_none")]
1031 pub issuer: Option<OcpiString<64>>,
1032 #[serde(default, skip_serializing_if = "Option::is_none")]
1034 pub group_id: Option<CiString<36>>,
1035 #[serde(flatten, default, skip_serializing_if = "Extensions::is_empty")]
1037 #[builder(default)]
1038 pub extensions: Extensions,
1039}
1040
1041impl PublishTokenType {
1042 #[must_use]
1050 pub fn matches(&self, candidate: &Self) -> bool {
1051 fn agree<T: PartialEq>(required: Option<&T>, given: Option<&T>) -> bool {
1052 required.is_none_or(|r| given == Some(r))
1053 }
1054 agree(self.uid.as_ref(), candidate.uid.as_ref())
1055 && agree(self.token_type.as_ref(), candidate.token_type.as_ref())
1056 && agree(self.visual_number.as_ref(), candidate.visual_number.as_ref())
1057 && agree(self.issuer.as_ref(), candidate.issuer.as_ref())
1058 && agree(self.group_id.as_ref(), candidate.group_id.as_ref())
1059 }
1060}
1061
1062impl Validate for PublishTokenType {
1063 fn validate_in(&self, v: &mut Validator) {
1064 validate_fields!(self, v, uid, token_type as "type", visual_number, issuer, group_id);
1065 if self.uid.is_none() && self.visual_number.is_none() && self.group_id.is_none() {
1066 v.report(
1067 ViolationCode::MissingConditional,
1068 "at least one of `uid`, `visual_number` or `group_id` SHALL be set",
1069 );
1070 }
1071 if self.uid.is_some() && self.token_type.is_none() {
1072 v.report_at("type", ViolationCode::MissingConditional, "SHALL be set when `uid` is set");
1073 }
1074 if self.visual_number.is_some() && self.issuer.is_none() {
1075 v.report_at(
1076 "issuer",
1077 ViolationCode::MissingConditional,
1078 "SHALL be set when `visual_number` is set",
1079 );
1080 }
1081 }
1082}
1083
1084#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
1091#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
1092pub struct StatusSchedule {
1093 pub period_begin: DateTime,
1095 #[serde(default, skip_serializing_if = "Option::is_none")]
1097 pub period_end: Option<DateTime>,
1098 pub status: Status,
1100 #[serde(flatten, default, skip_serializing_if = "Extensions::is_empty")]
1102 pub extensions: Extensions,
1103}
1104
1105impl Validate for StatusSchedule {
1106 fn validate_in(&self, v: &mut Validator) {
1107 validate_fields!(self, v, period_begin, period_end, status);
1108 if self.period_end.is_some_and(|end| end < self.period_begin) {
1109 v.report_at(
1110 "period_end",
1111 ViolationCode::Inconsistent,
1112 "the end of a scheduled period cannot precede its beginning",
1113 );
1114 }
1115 }
1116}
1117
1118ocpi_open_enum! {
1123 pub enum Capability {
1127 ChargingProfileCapable = "CHARGING_PROFILE_CAPABLE",
1129 ChargingPreferencesCapable = "CHARGING_PREFERENCES_CAPABLE",
1131 ChipCardSupport = "CHIP_CARD_SUPPORT",
1133 ContactlessCardSupport = "CONTACTLESS_CARD_SUPPORT",
1135 CreditCardPayable = "CREDIT_CARD_PAYABLE",
1137 DebitCardPayable = "DEBIT_CARD_PAYABLE",
1139 PedTerminal = "PED_TERMINAL",
1141 RemoteStartStopCapable = "REMOTE_START_STOP_CAPABLE",
1143 Reservable = "RESERVABLE",
1145 RfidReader = "RFID_READER",
1147 StartSessionConnectorRequired = "START_SESSION_CONNECTOR_REQUIRED",
1149 TokenGroupCapable = "TOKEN_GROUP_CAPABLE",
1151 UnlockCapable = "UNLOCK_CAPABLE",
1153 }
1154}
1155
1156ocpi_open_enum! {
1157 pub enum ConnectorCapability {
1165 Iso151182PlugAndCharge = "ISO_15118_2_PLUG_AND_CHARGE",
1167 Iso1511820PlugAndCharge = "ISO_15118_20_PLUG_AND_CHARGE",
1169 }
1170}
1171
1172ocpi_enum! {
1173 pub enum ConnectorFormat {
1177 Socket = "SOCKET",
1179 Cable = "CABLE",
1181 }
1182}
1183
1184ocpi_open_enum! {
1185 pub enum ConnectorType {
1193 Chademo = "CHADEMO",
1195 ChaoJi = "CHAOJI",
1197 DomesticA = "DOMESTIC_A",
1199 DomesticB = "DOMESTIC_B",
1201 DomesticC = "DOMESTIC_C",
1203 DomesticD = "DOMESTIC_D",
1205 DomesticE = "DOMESTIC_E",
1207 DomesticF = "DOMESTIC_F",
1209 DomesticG = "DOMESTIC_G",
1211 DomesticH = "DOMESTIC_H",
1213 DomesticI = "DOMESTIC_I",
1215 DomesticJ = "DOMESTIC_J",
1217 DomesticK = "DOMESTIC_K",
1219 DomesticL = "DOMESTIC_L",
1221 DomesticM = "DOMESTIC_M",
1223 DomesticN = "DOMESTIC_N",
1225 DomesticO = "DOMESTIC_O",
1227 GbtAc = "GBT_AC",
1229 GbtDc = "GBT_DC",
1231 Iec603092Single16 = "IEC_60309_2_single_16",
1233 Iec603092Three16 = "IEC_60309_2_three_16",
1235 Iec603092Three32 = "IEC_60309_2_three_32",
1237 Iec603092Three64 = "IEC_60309_2_three_64",
1239 Iec62196T1 = "IEC_62196_T1",
1241 Iec62196T1Combo = "IEC_62196_T1_COMBO",
1243 Iec62196T2 = "IEC_62196_T2",
1245 Iec62196T2Combo = "IEC_62196_T2_COMBO",
1247 Iec62196T3A = "IEC_62196_T3A",
1249 Iec62196T3C = "IEC_62196_T3C",
1251 Mcs = "MCS",
1253 Nema520 = "NEMA_5_20",
1255 Nema630 = "NEMA_6_30",
1257 Nema650 = "NEMA_6_50",
1259 Nema1030 = "NEMA_10_30",
1261 Nema1050 = "NEMA_10_50",
1263 Nema1430 = "NEMA_14_30",
1265 Nema1450 = "NEMA_14_50",
1267 PantographBottomUp = "PANTOGRAPH_BOTTOM_UP",
1269 PantographTopDown = "PANTOGRAPH_TOP_DOWN",
1271 SaeJ3400 = "SAE_J3400",
1273 TeslaR = "TESLA_R",
1275 TeslaS = "TESLA_S",
1277 }
1278}
1279
1280ocpi_enum! {
1281 pub enum EnergySourceCategory {
1285 Nuclear = "NUCLEAR",
1287 GeneralFossil = "GENERAL_FOSSIL",
1289 Coal = "COAL",
1291 Gas = "GAS",
1293 GeneralGreen = "GENERAL_GREEN",
1295 Solar = "SOLAR",
1297 Wind = "WIND",
1299 Water = "WATER",
1301 }
1302}
1303
1304ocpi_open_enum! {
1305 pub enum EnvironmentalImpactCategory {
1309 NuclearWaste = "NUCLEAR_WASTE",
1311 CarbonDioxide = "CARBON_DIOXIDE",
1313 }
1314}
1315
1316ocpi_enum! {
1317 pub enum EvsePosition {
1321 Left = "LEFT",
1323 Right = "RIGHT",
1325 Center = "CENTER",
1327 }
1328}
1329
1330ocpi_open_enum! {
1331 pub enum Facility {
1335 Hotel = "HOTEL",
1337 Restaurant = "RESTAURANT",
1339 Cafe = "CAFE",
1341 Mall = "MALL",
1343 Supermarket = "SUPERMARKET",
1345 Sport = "SPORT",
1347 RecreationArea = "RECREATION_AREA",
1349 Nature = "NATURE",
1351 Museum = "MUSEUM",
1353 BikeSharing = "BIKE_SHARING",
1355 BusStop = "BUS_STOP",
1357 TaxiStand = "TAXI_STAND",
1359 TramStop = "TRAM_STOP",
1361 MetroStation = "METRO_STATION",
1363 TrainStation = "TRAIN_STATION",
1365 Airport = "AIRPORT",
1367 ParkingLot = "PARKING_LOT",
1369 CarpoolParking = "CARPOOL_PARKING",
1371 FuelStation = "FUEL_STATION",
1373 Wifi = "WIFI",
1375 }
1376}
1377
1378ocpi_open_enum! {
1379 pub enum ImageCategory {
1383 Charger = "CHARGER",
1385 Entrance = "ENTRANCE",
1387 Location = "LOCATION",
1389 Network = "NETWORK",
1391 Operator = "OPERATOR",
1393 Other = "OTHER",
1395 Owner = "OWNER",
1397 }
1398}
1399
1400ocpi_enum! {
1401 pub enum ParkingDirection {
1405 Parallel = "PARALLEL",
1407 Perpendicular = "PERPENDICULAR",
1409 Angle = "ANGLE",
1411 }
1412}
1413
1414ocpi_open_enum! {
1415 pub enum ParkingRestriction {
1421 Customers = "CUSTOMERS",
1423 Disabled = "DISABLED",
1425 Employees = "EMPLOYEES",
1427 EvOnly = "EV_ONLY",
1429 Motorcycles = "MOTORCYCLES",
1431 Plugged = "PLUGGED",
1433 Taxis = "TAXIS",
1435 Tenants = "TENANTS",
1437 }
1438}
1439
1440ocpi_open_enum! {
1441 pub enum ParkingType {
1445 AlongMotorway = "ALONG_MOTORWAY",
1447 ParkingGarage = "PARKING_GARAGE",
1449 ParkingLot = "PARKING_LOT",
1451 OnDriveway = "ON_DRIVEWAY",
1453 OnStreet = "ON_STREET",
1455 UndergroundGarage = "UNDERGROUND_GARAGE",
1457 }
1458}
1459
1460ocpi_enum! {
1461 pub enum PowerType {
1465 Ac1Phase = "AC_1_PHASE",
1467 Ac2Phase = "AC_2_PHASE",
1469 Ac2PhaseSplit = "AC_2_PHASE_SPLIT",
1471 Ac3Phase = "AC_3_PHASE",
1473 Dc = "DC",
1475 }
1476}
1477
1478ocpi_enum! {
1479 pub enum Status {
1485 Available = "AVAILABLE",
1487 Blocked = "BLOCKED",
1489 Charging = "CHARGING",
1491 Inoperative = "INOPERATIVE",
1493 OutOfOrder = "OUTOFORDER",
1495 Planned = "PLANNED",
1497 Removed = "REMOVED",
1499 Reserved = "RESERVED",
1501 Unknown = "UNKNOWN",
1503 }
1504}
1505
1506ocpi_open_enum! {
1507 pub enum VehicleType {
1511 Motorcycle = "MOTORCYCLE",
1513 PersonalVehicle = "PERSONAL_VEHICLE",
1515 PersonalVehicleWithTrailer = "PERSONAL_VEHICLE_WITH_TRAILER",
1517 Van = "VAN",
1519 SemiTractor = "SEMI_TRACTOR",
1521 Rigid = "RIGID",
1523 TruckWithTrailer = "TRUCK_WITH_TRAILER",
1525 Bus = "BUS",
1527 Disabled = "DISABLED",
1529 }
1530}
1531
1532#[cfg(test)]
1533mod tests {
1534 use super::*;
1535
1536 fn geo() -> GeoLocation {
1537 GeoLocation::new("50.770774", "-126.104965").unwrap()
1538 }
1539
1540 #[test]
1541 fn coordinate_format_is_checked_against_the_spec_regex() {
1542 assert!(geo().validate().is_ok());
1543 for (lat, lon) in [
1544 ("50.77", "-126.104965"), ("50", "-126.104965"), ("50.7707745678", "-126.1"), ("50.770774", "-1261.104965"), ] {
1549 let g = GeoLocation {
1550 latitude: OcpiString::new_lenient(lat),
1551 longitude: OcpiString::new_lenient(lon),
1552 extensions: Extensions::new(),
1553 };
1554 assert!(g.validate().is_err(), "{lat}/{lon} should be reported");
1555 }
1556 }
1557
1558 #[test]
1559 fn publish_allowed_to_requires_publish_false() {
1560 let mut loc = Location::builder()
1561 .country_code("NL")
1562 .party_id("TNM")
1563 .id("LOC1")
1564 .publish(true)
1565 .address("Street 1")
1566 .city("Amsterdam")
1567 .country("NLD")
1568 .coordinates(geo())
1569 .time_zone("Europe/Amsterdam")
1570 .last_updated("2024-01-01T00:00:00Z".parse::<DateTime>().unwrap())
1571 .build();
1572 assert!(loc.validate().is_ok());
1573
1574 loc.publish_allowed_to =
1575 vec![PublishTokenType { group_id: Some(CiString::new("G1").unwrap()), ..Default::default() }];
1576 let err = loc.validate().unwrap_err();
1577 assert_eq!(err.as_slice()[0].pointer, "/publish_allowed_to");
1578 assert_eq!(err.as_slice()[0].code, ViolationCode::Inconsistent);
1579 }
1580
1581 #[test]
1582 fn publish_token_matching_requires_all_set_fields_to_agree() {
1583 let allowed = PublishTokenType {
1584 visual_number: Some(OcpiString::new("12345").unwrap()),
1585 issuer: Some(OcpiString::new("TheNewMotion").unwrap()),
1586 ..Default::default()
1587 };
1588 let same = allowed.clone();
1589 let wrong_issuer =
1590 PublishTokenType { issuer: Some(OcpiString::new("Other").unwrap()), ..allowed.clone() };
1591 let extra_fields = PublishTokenType {
1592 uid: Some(CiString::new("ABC").unwrap()),
1593 token_type: Some(TokenType::Rfid),
1594 ..allowed.clone()
1595 };
1596 assert!(allowed.matches(&same));
1597 assert!(!allowed.matches(&wrong_issuer));
1598 assert!(allowed.matches(&extra_fields), "extra information on the candidate is fine");
1599 }
1600
1601 #[test]
1602 fn publish_token_conditional_requirements_are_reported() {
1603 let only_uid = PublishTokenType { uid: Some(CiString::new("ABC").unwrap()), ..Default::default() };
1604 let err = only_uid.validate().unwrap_err();
1605 assert_eq!(err.as_slice()[0].pointer, "/type");
1606 assert!(PublishTokenType::default().validate().is_err(), "one of three must be set");
1607 }
1608
1609 #[test]
1610 fn regular_hours_must_be_a_forward_interval_on_a_real_weekday() {
1611 let ok = RegularHours {
1612 weekday: 1,
1613 period_begin: "08:00".parse().unwrap(),
1614 period_end: "20:00".parse().unwrap(),
1615 extensions: Extensions::new(),
1616 };
1617 assert!(ok.validate().is_ok());
1618 let backwards = RegularHours { period_end: "07:00".parse().unwrap(), ..ok.clone() };
1619 assert!(backwards.validate().is_err());
1620 let no_such_day = RegularHours { weekday: 8, ..ok };
1621 assert!(no_such_day.validate().is_err());
1622 }
1623
1624 #[test]
1625 fn hours_apply_the_precedence_the_spec_gives() {
1626 let dt = |s: &str| s.parse::<DateTime>().unwrap();
1627 let hours = Hours {
1628 twentyfourseven: true,
1629 regular_hours: vec![],
1630 exceptional_openings: vec![],
1631 exceptional_closings: vec![ExceptionalPeriod {
1632 period_begin: dt("2018-12-25T03:00:00Z"),
1633 period_end: dt("2018-12-25T05:00:00Z"),
1634 extensions: Extensions::new(),
1635 }],
1636 extensions: Extensions::new(),
1637 };
1638 assert!(hours.is_open_at(dt("2018-12-25T02:59:59Z"), 0));
1639 assert!(!hours.is_open_at(dt("2018-12-25T04:00:00Z"), 0), "closing beats 24/7");
1640 }
1641
1642 #[test]
1643 fn twentyfourseven_false_needs_regular_hours() {
1644 let empty = Hours {
1645 twentyfourseven: false,
1646 regular_hours: vec![],
1647 exceptional_openings: vec![],
1648 exceptional_closings: vec![],
1649 extensions: Extensions::new(),
1650 };
1651 assert_eq!(empty.validate().unwrap_err().as_slice()[0].code, ViolationCode::MissingConditional);
1652 }
1653
1654 #[test]
1655 fn evse_parking_must_point_at_a_parking_place_of_the_same_location() {
1656 let evse = Evse::builder()
1657 .uid("E1")
1658 .status(Status::Available)
1659 .connectors(vec![])
1660 .parking(vec![EvseParking {
1661 parking_id: CiString::new("P9").unwrap(),
1662 evse_position: None,
1663 extensions: Extensions::new(),
1664 }])
1665 .last_updated("2024-01-01T00:00:00Z".parse::<DateTime>().unwrap())
1666 .build();
1667 let loc = Location::builder()
1668 .country_code("NL")
1669 .party_id("TNM")
1670 .id("LOC1")
1671 .publish(true)
1672 .address("Street 1")
1673 .city("Amsterdam")
1674 .country("NLD")
1675 .coordinates(geo())
1676 .time_zone("Europe/Amsterdam")
1677 .evses(vec![evse])
1678 .last_updated("2024-01-01T00:00:00Z".parse::<DateTime>().unwrap())
1679 .build();
1680 let err = loc.validate().unwrap_err();
1681 assert!(err.as_slice().iter().any(|x| x.pointer == "/evses/0/parking/0/parking_id"), "{err}");
1682 assert!(err.as_slice().iter().any(|x| x.code == ViolationCode::EmptyRequiredList));
1684 }
1685
1686 #[test]
1687 fn unknown_connector_types_survive_a_round_trip() {
1688 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"}"#;
1689 let c: Connector = serde_json::from_str(json).unwrap();
1690 assert!(!c.standard.is_known());
1691 assert_eq!(serde_json::to_string(&c).unwrap(), json);
1692 }
1693}