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.local_parts(utc_offset_seconds);
908 self.regular_hours
909 .iter()
910 .any(|r| r.weekday == local.iso_weekday && local.time.is_within(r.period_begin, r.period_end))
911 }
912}
913
914impl Validate for Hours {
915 fn validate_in(&self, v: &mut Validator) {
916 validate_fields!(self, v, regular_hours, exceptional_openings, exceptional_closings);
917 if !self.twentyfourseven && self.regular_hours.is_empty() {
918 v.report_at(
919 "regular_hours",
920 ViolationCode::MissingConditional,
921 "when `twentyfourseven` is false this field must contain at least one entry",
922 );
923 }
924 }
925}
926
927#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
931#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
932pub struct RegularHours {
933 pub weekday: u8,
935 pub period_begin: LocalTime,
937 pub period_end: LocalTime,
939 #[serde(flatten, default, skip_serializing_if = "Extensions::is_empty")]
941 pub extensions: Extensions,
942}
943
944impl Validate for RegularHours {
945 fn validate_in(&self, v: &mut Validator) {
946 validate_fields!(self, v, period_begin, period_end);
947 if !(1..=7).contains(&self.weekday) {
948 v.report_at(
949 "weekday",
950 ViolationCode::OutOfRange,
951 format!("{} is not a day of the week: Monday (1) till Sunday (7)", self.weekday),
952 );
953 }
954 if self.period_end <= self.period_begin {
957 v.report_at(
958 "period_end",
959 ViolationCode::Inconsistent,
960 format!("{} must be later than period_begin {}", self.period_end, self.period_begin),
961 );
962 }
963 }
964}
965
966#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, Builder)]
970#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
971#[builder(on(_, into))]
972pub struct Image {
973 pub url: Url,
975 #[serde(default, skip_serializing_if = "Option::is_none")]
977 pub thumbnail: Option<Url>,
978 pub category: ImageCategory,
980 #[serde(rename = "type")]
982 pub image_type: CiString<4>,
983 #[serde(default, skip_serializing_if = "Option::is_none")]
985 pub width: Option<u32>,
986 #[serde(default, skip_serializing_if = "Option::is_none")]
988 pub height: Option<u32>,
989 #[serde(flatten, default, skip_serializing_if = "Extensions::is_empty")]
991 #[builder(default)]
992 pub extensions: Extensions,
993}
994
995impl Validate for Image {
996 fn validate_in(&self, v: &mut Validator) {
997 validate_fields!(self, v, url, thumbnail, category, image_type as "type");
998 for (name, value) in [("width", self.width), ("height", self.height)] {
1000 if value.is_some_and(|x| x > 99_999) {
1001 v.report_at(name, ViolationCode::OutOfRange, "int(5): at most five digits");
1002 }
1003 }
1004 }
1005}
1006
1007#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize, Builder)]
1015#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
1016#[builder(on(_, into))]
1017pub struct PublishTokenType {
1018 #[serde(default, skip_serializing_if = "Option::is_none")]
1020 pub uid: Option<CiString<36>>,
1021 #[serde(rename = "type", default, skip_serializing_if = "Option::is_none")]
1023 pub token_type: Option<TokenType>,
1024 #[serde(default, skip_serializing_if = "Option::is_none")]
1026 pub visual_number: Option<OcpiString<64>>,
1027 #[serde(default, skip_serializing_if = "Option::is_none")]
1029 pub issuer: Option<OcpiString<64>>,
1030 #[serde(default, skip_serializing_if = "Option::is_none")]
1032 pub group_id: Option<CiString<36>>,
1033 #[serde(flatten, default, skip_serializing_if = "Extensions::is_empty")]
1035 #[builder(default)]
1036 pub extensions: Extensions,
1037}
1038
1039impl PublishTokenType {
1040 #[must_use]
1048 pub fn matches(&self, candidate: &Self) -> bool {
1049 fn agree<T: PartialEq>(required: Option<&T>, given: Option<&T>) -> bool {
1050 required.is_none_or(|r| given == Some(r))
1051 }
1052 agree(self.uid.as_ref(), candidate.uid.as_ref())
1053 && agree(self.token_type.as_ref(), candidate.token_type.as_ref())
1054 && agree(self.visual_number.as_ref(), candidate.visual_number.as_ref())
1055 && agree(self.issuer.as_ref(), candidate.issuer.as_ref())
1056 && agree(self.group_id.as_ref(), candidate.group_id.as_ref())
1057 }
1058}
1059
1060impl Validate for PublishTokenType {
1061 fn validate_in(&self, v: &mut Validator) {
1062 validate_fields!(self, v, uid, token_type as "type", visual_number, issuer, group_id);
1063 if self.uid.is_none() && self.visual_number.is_none() && self.group_id.is_none() {
1064 v.report(
1065 ViolationCode::MissingConditional,
1066 "at least one of `uid`, `visual_number` or `group_id` SHALL be set",
1067 );
1068 }
1069 if self.uid.is_some() && self.token_type.is_none() {
1070 v.report_at("type", ViolationCode::MissingConditional, "SHALL be set when `uid` is set");
1071 }
1072 if self.visual_number.is_some() && self.issuer.is_none() {
1073 v.report_at(
1074 "issuer",
1075 ViolationCode::MissingConditional,
1076 "SHALL be set when `visual_number` is set",
1077 );
1078 }
1079 }
1080}
1081
1082#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
1089#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
1090pub struct StatusSchedule {
1091 pub period_begin: DateTime,
1093 #[serde(default, skip_serializing_if = "Option::is_none")]
1095 pub period_end: Option<DateTime>,
1096 pub status: Status,
1098 #[serde(flatten, default, skip_serializing_if = "Extensions::is_empty")]
1100 pub extensions: Extensions,
1101}
1102
1103impl Validate for StatusSchedule {
1104 fn validate_in(&self, v: &mut Validator) {
1105 validate_fields!(self, v, period_begin, period_end, status);
1106 if self.period_end.is_some_and(|end| end < self.period_begin) {
1107 v.report_at(
1108 "period_end",
1109 ViolationCode::Inconsistent,
1110 "the end of a scheduled period cannot precede its beginning",
1111 );
1112 }
1113 }
1114}
1115
1116ocpi_open_enum! {
1121 pub enum Capability {
1125 ChargingProfileCapable = "CHARGING_PROFILE_CAPABLE",
1127 ChargingPreferencesCapable = "CHARGING_PREFERENCES_CAPABLE",
1129 ChipCardSupport = "CHIP_CARD_SUPPORT",
1131 ContactlessCardSupport = "CONTACTLESS_CARD_SUPPORT",
1133 CreditCardPayable = "CREDIT_CARD_PAYABLE",
1135 DebitCardPayable = "DEBIT_CARD_PAYABLE",
1137 PedTerminal = "PED_TERMINAL",
1139 RemoteStartStopCapable = "REMOTE_START_STOP_CAPABLE",
1141 Reservable = "RESERVABLE",
1143 RfidReader = "RFID_READER",
1145 StartSessionConnectorRequired = "START_SESSION_CONNECTOR_REQUIRED",
1147 TokenGroupCapable = "TOKEN_GROUP_CAPABLE",
1149 UnlockCapable = "UNLOCK_CAPABLE",
1151 }
1152}
1153
1154ocpi_open_enum! {
1155 pub enum ConnectorCapability {
1163 Iso151182PlugAndCharge = "ISO_15118_2_PLUG_AND_CHARGE",
1165 Iso1511820PlugAndCharge = "ISO_15118_20_PLUG_AND_CHARGE",
1167 }
1168}
1169
1170ocpi_enum! {
1171 pub enum ConnectorFormat {
1175 Socket = "SOCKET",
1177 Cable = "CABLE",
1179 }
1180}
1181
1182ocpi_open_enum! {
1183 pub enum ConnectorType {
1191 Chademo = "CHADEMO",
1193 ChaoJi = "CHAOJI",
1195 DomesticA = "DOMESTIC_A",
1197 DomesticB = "DOMESTIC_B",
1199 DomesticC = "DOMESTIC_C",
1201 DomesticD = "DOMESTIC_D",
1203 DomesticE = "DOMESTIC_E",
1205 DomesticF = "DOMESTIC_F",
1207 DomesticG = "DOMESTIC_G",
1209 DomesticH = "DOMESTIC_H",
1211 DomesticI = "DOMESTIC_I",
1213 DomesticJ = "DOMESTIC_J",
1215 DomesticK = "DOMESTIC_K",
1217 DomesticL = "DOMESTIC_L",
1219 DomesticM = "DOMESTIC_M",
1221 DomesticN = "DOMESTIC_N",
1223 DomesticO = "DOMESTIC_O",
1225 GbtAc = "GBT_AC",
1227 GbtDc = "GBT_DC",
1229 Iec603092Single16 = "IEC_60309_2_single_16",
1231 Iec603092Three16 = "IEC_60309_2_three_16",
1233 Iec603092Three32 = "IEC_60309_2_three_32",
1235 Iec603092Three64 = "IEC_60309_2_three_64",
1237 Iec62196T1 = "IEC_62196_T1",
1239 Iec62196T1Combo = "IEC_62196_T1_COMBO",
1241 Iec62196T2 = "IEC_62196_T2",
1243 Iec62196T2Combo = "IEC_62196_T2_COMBO",
1245 Iec62196T3A = "IEC_62196_T3A",
1247 Iec62196T3C = "IEC_62196_T3C",
1249 Mcs = "MCS",
1251 Nema520 = "NEMA_5_20",
1253 Nema630 = "NEMA_6_30",
1255 Nema650 = "NEMA_6_50",
1257 Nema1030 = "NEMA_10_30",
1259 Nema1050 = "NEMA_10_50",
1261 Nema1430 = "NEMA_14_30",
1263 Nema1450 = "NEMA_14_50",
1265 PantographBottomUp = "PANTOGRAPH_BOTTOM_UP",
1267 PantographTopDown = "PANTOGRAPH_TOP_DOWN",
1269 SaeJ3400 = "SAE_J3400",
1271 TeslaR = "TESLA_R",
1273 TeslaS = "TESLA_S",
1275 }
1276}
1277
1278ocpi_enum! {
1279 pub enum EnergySourceCategory {
1283 Nuclear = "NUCLEAR",
1285 GeneralFossil = "GENERAL_FOSSIL",
1287 Coal = "COAL",
1289 Gas = "GAS",
1291 GeneralGreen = "GENERAL_GREEN",
1293 Solar = "SOLAR",
1295 Wind = "WIND",
1297 Water = "WATER",
1299 }
1300}
1301
1302ocpi_open_enum! {
1303 pub enum EnvironmentalImpactCategory {
1307 NuclearWaste = "NUCLEAR_WASTE",
1309 CarbonDioxide = "CARBON_DIOXIDE",
1311 }
1312}
1313
1314ocpi_enum! {
1315 pub enum EvsePosition {
1319 Left = "LEFT",
1321 Right = "RIGHT",
1323 Center = "CENTER",
1325 }
1326}
1327
1328ocpi_open_enum! {
1329 pub enum Facility {
1333 Hotel = "HOTEL",
1335 Restaurant = "RESTAURANT",
1337 Cafe = "CAFE",
1339 Mall = "MALL",
1341 Supermarket = "SUPERMARKET",
1343 Sport = "SPORT",
1345 RecreationArea = "RECREATION_AREA",
1347 Nature = "NATURE",
1349 Museum = "MUSEUM",
1351 BikeSharing = "BIKE_SHARING",
1353 BusStop = "BUS_STOP",
1355 TaxiStand = "TAXI_STAND",
1357 TramStop = "TRAM_STOP",
1359 MetroStation = "METRO_STATION",
1361 TrainStation = "TRAIN_STATION",
1363 Airport = "AIRPORT",
1365 ParkingLot = "PARKING_LOT",
1367 CarpoolParking = "CARPOOL_PARKING",
1369 FuelStation = "FUEL_STATION",
1371 Wifi = "WIFI",
1373 }
1374}
1375
1376ocpi_open_enum! {
1377 pub enum ImageCategory {
1381 Charger = "CHARGER",
1383 Entrance = "ENTRANCE",
1385 Location = "LOCATION",
1387 Network = "NETWORK",
1389 Operator = "OPERATOR",
1391 Other = "OTHER",
1393 Owner = "OWNER",
1395 }
1396}
1397
1398ocpi_enum! {
1399 pub enum ParkingDirection {
1403 Parallel = "PARALLEL",
1405 Perpendicular = "PERPENDICULAR",
1407 Angle = "ANGLE",
1409 }
1410}
1411
1412ocpi_open_enum! {
1413 pub enum ParkingRestriction {
1419 Customers = "CUSTOMERS",
1421 Disabled = "DISABLED",
1423 Employees = "EMPLOYEES",
1425 EvOnly = "EV_ONLY",
1427 Motorcycles = "MOTORCYCLES",
1429 Plugged = "PLUGGED",
1431 Taxis = "TAXIS",
1433 Tenants = "TENANTS",
1435 }
1436}
1437
1438ocpi_open_enum! {
1439 pub enum ParkingType {
1443 AlongMotorway = "ALONG_MOTORWAY",
1445 ParkingGarage = "PARKING_GARAGE",
1447 ParkingLot = "PARKING_LOT",
1449 OnDriveway = "ON_DRIVEWAY",
1451 OnStreet = "ON_STREET",
1453 UndergroundGarage = "UNDERGROUND_GARAGE",
1455 }
1456}
1457
1458ocpi_enum! {
1459 pub enum PowerType {
1463 Ac1Phase = "AC_1_PHASE",
1465 Ac2Phase = "AC_2_PHASE",
1467 Ac2PhaseSplit = "AC_2_PHASE_SPLIT",
1469 Ac3Phase = "AC_3_PHASE",
1471 Dc = "DC",
1473 }
1474}
1475
1476ocpi_enum! {
1477 pub enum Status {
1483 Available = "AVAILABLE",
1485 Blocked = "BLOCKED",
1487 Charging = "CHARGING",
1489 Inoperative = "INOPERATIVE",
1491 OutOfOrder = "OUTOFORDER",
1493 Planned = "PLANNED",
1495 Removed = "REMOVED",
1497 Reserved = "RESERVED",
1499 Unknown = "UNKNOWN",
1501 }
1502}
1503
1504ocpi_open_enum! {
1505 pub enum VehicleType {
1509 Motorcycle = "MOTORCYCLE",
1511 PersonalVehicle = "PERSONAL_VEHICLE",
1513 PersonalVehicleWithTrailer = "PERSONAL_VEHICLE_WITH_TRAILER",
1515 Van = "VAN",
1517 SemiTractor = "SEMI_TRACTOR",
1519 Rigid = "RIGID",
1521 TruckWithTrailer = "TRUCK_WITH_TRAILER",
1523 Bus = "BUS",
1525 Disabled = "DISABLED",
1527 }
1528}
1529
1530#[cfg(test)]
1531mod tests {
1532 use super::*;
1533
1534 fn geo() -> GeoLocation {
1535 GeoLocation::new("50.770774", "-126.104965").unwrap()
1536 }
1537
1538 #[test]
1539 fn coordinate_format_is_checked_against_the_spec_regex() {
1540 assert!(geo().validate().is_ok());
1541 for (lat, lon) in [
1542 ("50.77", "-126.104965"), ("50", "-126.104965"), ("50.7707745678", "-126.1"), ("50.770774", "-1261.104965"), ] {
1547 let g = GeoLocation {
1548 latitude: OcpiString::new_lenient(lat),
1549 longitude: OcpiString::new_lenient(lon),
1550 extensions: Extensions::new(),
1551 };
1552 assert!(g.validate().is_err(), "{lat}/{lon} should be reported");
1553 }
1554 }
1555
1556 #[test]
1557 fn publish_allowed_to_requires_publish_false() {
1558 let mut loc = Location::builder()
1559 .country_code("NL")
1560 .party_id("TNM")
1561 .id("LOC1")
1562 .publish(true)
1563 .address("Street 1")
1564 .city("Amsterdam")
1565 .country("NLD")
1566 .coordinates(geo())
1567 .time_zone("Europe/Amsterdam")
1568 .last_updated("2024-01-01T00:00:00Z".parse::<DateTime>().unwrap())
1569 .build();
1570 assert!(loc.validate().is_ok());
1571
1572 loc.publish_allowed_to =
1573 vec![PublishTokenType { group_id: Some(CiString::new("G1").unwrap()), ..Default::default() }];
1574 let err = loc.validate().unwrap_err();
1575 assert_eq!(err.as_slice()[0].pointer, "/publish_allowed_to");
1576 assert_eq!(err.as_slice()[0].code, ViolationCode::Inconsistent);
1577 }
1578
1579 #[test]
1580 fn publish_token_matching_requires_all_set_fields_to_agree() {
1581 let allowed = PublishTokenType {
1582 visual_number: Some(OcpiString::new("12345").unwrap()),
1583 issuer: Some(OcpiString::new("TheNewMotion").unwrap()),
1584 ..Default::default()
1585 };
1586 let same = allowed.clone();
1587 let wrong_issuer =
1588 PublishTokenType { issuer: Some(OcpiString::new("Other").unwrap()), ..allowed.clone() };
1589 let extra_fields = PublishTokenType {
1590 uid: Some(CiString::new("ABC").unwrap()),
1591 token_type: Some(TokenType::Rfid),
1592 ..allowed.clone()
1593 };
1594 assert!(allowed.matches(&same));
1595 assert!(!allowed.matches(&wrong_issuer));
1596 assert!(allowed.matches(&extra_fields), "extra information on the candidate is fine");
1597 }
1598
1599 #[test]
1600 fn publish_token_conditional_requirements_are_reported() {
1601 let only_uid = PublishTokenType { uid: Some(CiString::new("ABC").unwrap()), ..Default::default() };
1602 let err = only_uid.validate().unwrap_err();
1603 assert_eq!(err.as_slice()[0].pointer, "/type");
1604 assert!(PublishTokenType::default().validate().is_err(), "one of three must be set");
1605 }
1606
1607 #[test]
1608 fn regular_hours_must_be_a_forward_interval_on_a_real_weekday() {
1609 let ok = RegularHours {
1610 weekday: 1,
1611 period_begin: "08:00".parse().unwrap(),
1612 period_end: "20:00".parse().unwrap(),
1613 extensions: Extensions::new(),
1614 };
1615 assert!(ok.validate().is_ok());
1616 let backwards = RegularHours { period_end: "07:00".parse().unwrap(), ..ok.clone() };
1617 assert!(backwards.validate().is_err());
1618 let no_such_day = RegularHours { weekday: 8, ..ok };
1619 assert!(no_such_day.validate().is_err());
1620 }
1621
1622 #[test]
1623 fn hours_apply_the_precedence_the_spec_gives() {
1624 let dt = |s: &str| s.parse::<DateTime>().unwrap();
1625 let hours = Hours {
1626 twentyfourseven: true,
1627 regular_hours: vec![],
1628 exceptional_openings: vec![],
1629 exceptional_closings: vec![ExceptionalPeriod {
1630 period_begin: dt("2018-12-25T03:00:00Z"),
1631 period_end: dt("2018-12-25T05:00:00Z"),
1632 extensions: Extensions::new(),
1633 }],
1634 extensions: Extensions::new(),
1635 };
1636 assert!(hours.is_open_at(dt("2018-12-25T02:59:59Z"), 0));
1637 assert!(!hours.is_open_at(dt("2018-12-25T04:00:00Z"), 0), "closing beats 24/7");
1638 }
1639
1640 #[test]
1641 fn twentyfourseven_false_needs_regular_hours() {
1642 let empty = Hours {
1643 twentyfourseven: false,
1644 regular_hours: vec![],
1645 exceptional_openings: vec![],
1646 exceptional_closings: vec![],
1647 extensions: Extensions::new(),
1648 };
1649 assert_eq!(empty.validate().unwrap_err().as_slice()[0].code, ViolationCode::MissingConditional);
1650 }
1651
1652 #[test]
1653 fn evse_parking_must_point_at_a_parking_place_of_the_same_location() {
1654 let evse = Evse::builder()
1655 .uid("E1")
1656 .status(Status::Available)
1657 .connectors(vec![])
1658 .parking(vec![EvseParking {
1659 parking_id: CiString::new("P9").unwrap(),
1660 evse_position: None,
1661 extensions: Extensions::new(),
1662 }])
1663 .last_updated("2024-01-01T00:00:00Z".parse::<DateTime>().unwrap())
1664 .build();
1665 let loc = Location::builder()
1666 .country_code("NL")
1667 .party_id("TNM")
1668 .id("LOC1")
1669 .publish(true)
1670 .address("Street 1")
1671 .city("Amsterdam")
1672 .country("NLD")
1673 .coordinates(geo())
1674 .time_zone("Europe/Amsterdam")
1675 .evses(vec![evse])
1676 .last_updated("2024-01-01T00:00:00Z".parse::<DateTime>().unwrap())
1677 .build();
1678 let err = loc.validate().unwrap_err();
1679 assert!(err.as_slice().iter().any(|x| x.pointer == "/evses/0/parking/0/parking_id"), "{err}");
1680 assert!(err.as_slice().iter().any(|x| x.code == ViolationCode::EmptyRequiredList));
1682 }
1683
1684 #[test]
1685 fn unknown_connector_types_survive_a_round_trip() {
1686 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"}"#;
1687 let c: Connector = serde_json::from_str(json).unwrap();
1688 assert!(!c.standard.is_known());
1689 assert_eq!(serde_json::to_string(&c).unwrap(), json);
1690 }
1691}