Skip to main content

ocpi_kit/v2_3_0/
locations.rs

1//! The *Locations* module of OCPI 2.3.0: where the EVSEs are and what they can do.
2//!
3//! *Module Identifier: `locations`* — Data owner: CPO.
4//!
5//! Spec: 2.3.0 §mod_locations_locations_module
6
7use 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/// Where a group of EVSEs that belong together is installed.
21///
22/// > *Typically, the Location object is the exact location of the group of EVSEs, but it can
23/// > also be the entrance of a parking garage which contains these EVSEs.*
24///
25/// Spec: 2.3.0 §mod_locations_location_object
26#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, Builder)]
27#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
28#[builder(on(_, into))]
29pub struct Location {
30    /// ISO-3166 alpha-2 country code of the CPO that 'owns' this Location.
31    pub country_code: CountryCode,
32    /// ID of the CPO that 'owns' this Location.
33    pub party_id: PartyId,
34    /// Uniquely identifies the location within the CPO's platform. Never changed or renamed.
35    pub id: CiString<36>,
36    /// Whether the Location may be published on a website or app.
37    ///
38    /// > *When this is set to `false`, only tokens identified in the field `publish_allowed_to`
39    /// > are allowed to be shown this Location.*
40    pub publish: bool,
41    /// Tokens allowed to be shown this Location when [`publish`](Self::publish) is `false`.
42    #[serde(default, skip_serializing_if = "Vec::is_empty")]
43    #[builder(default)]
44    pub publish_allowed_to: Vec<PublishTokenType>,
45    /// Display name of the location.
46    #[serde(default, skip_serializing_if = "Option::is_none")]
47    pub name: Option<OcpiString<255>>,
48    /// Street/block name and house number if available.
49    ///
50    /// NOTE: earlier releases of the OCPI 2.3.0 documentation mistakenly gave a maximum of 45.
51    pub address: OcpiString<255>,
52    /// City or town.
53    pub city: OcpiString<45>,
54    /// Postal code, omitted only where the location genuinely has none.
55    #[serde(default, skip_serializing_if = "Option::is_none")]
56    pub postal_code: Option<OcpiString<10>>,
57    /// State or province, only where relevant.
58    ///
59    /// NOTE: earlier releases of the OCPI 2.3.0 documentation mistakenly gave a maximum of 20.
60    #[serde(default, skip_serializing_if = "Option::is_none")]
61    pub state: Option<OcpiString<45>>,
62    /// ISO 3166-1 alpha-3 code for the country of this location.
63    pub country: OcpiString<3>,
64    /// Coordinates of the location.
65    pub coordinates: GeoLocation,
66    /// Geographical locations of related points relevant to the user.
67    #[serde(default, skip_serializing_if = "Vec::is_empty")]
68    #[builder(default)]
69    pub related_locations: Vec<AdditionalGeoLocation>,
70    /// The general type of parking at the charge point location.
71    #[serde(default, skip_serializing_if = "Option::is_none")]
72    pub parking_type: Option<ParkingType>,
73    /// The EVSEs that belong to this Location.
74    #[serde(default, skip_serializing_if = "Vec::is_empty")]
75    #[builder(default)]
76    pub evses: Vec<Evse>,
77    /// Parking places usable by vehicles charging at this Location.
78    #[serde(default, skip_serializing_if = "Vec::is_empty")]
79    #[builder(default)]
80    pub parking_places: Vec<Parking>,
81    /// Human-readable directions on how to reach the location.
82    #[serde(default, skip_serializing_if = "Vec::is_empty")]
83    #[builder(default)]
84    pub directions: Vec<DisplayText>,
85    /// Information of the operator, when it differs from the party in the Credentials module.
86    #[serde(default, skip_serializing_if = "Option::is_none")]
87    pub operator: Option<BusinessDetails>,
88    /// Information of the suboperator if available.
89    #[serde(default, skip_serializing_if = "Option::is_none")]
90    pub suboperator: Option<BusinessDetails>,
91    /// Information of the owner if available.
92    #[serde(default, skip_serializing_if = "Option::is_none")]
93    pub owner: Option<BusinessDetails>,
94    /// Facilities this charging location directly belongs to.
95    #[serde(default, skip_serializing_if = "Vec::is_empty")]
96    #[builder(default)]
97    pub facilities: Vec<Facility>,
98    /// One of IANA tzdata's TZ values, e.g. `Europe/Oslo`.
99    ///
100    /// This is the time zone that [`LocalTime`] and [`crate::types::LocalDate`] values elsewhere
101    /// in the protocol — opening hours, tariff restrictions — are expressed in.
102    pub time_zone: OcpiString<255>,
103    /// When the EVSEs at the location can be accessed for charging.
104    #[serde(default, skip_serializing_if = "Option::is_none")]
105    pub opening_times: Option<Hours>,
106    /// Whether the EVSEs still charge outside the opening hours. Default: `true`.
107    #[serde(default, skip_serializing_if = "Option::is_none")]
108    pub charging_when_closed: Option<bool>,
109    /// Links to images related to the location such as photos or logos.
110    #[serde(default, skip_serializing_if = "Vec::is_empty")]
111    #[builder(default)]
112    pub images: Vec<Image>,
113    /// Details on the energy supplied at this location.
114    #[serde(default, skip_serializing_if = "Option::is_none")]
115    pub energy_mix: Option<EnergyMix>,
116    /// A telephone number a Driver may call for assistance. New in OCPI 2.3.0.
117    #[serde(default, skip_serializing_if = "Option::is_none")]
118    pub help_phone: Option<CiString<25>>,
119    /// Timestamp when this Location or one of its EVSEs or Connectors was last updated.
120    pub last_updated: DateTime,
121    /// Undocumented JSON fields, preserved verbatim.
122    #[serde(flatten, default, skip_serializing_if = "Extensions::is_empty")]
123    #[builder(default)]
124    pub extensions: Extensions,
125}
126
127impl Location {
128    /// The party that owns this Location.
129    #[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    /// Whether the EVSEs keep charging outside opening hours, applying the spec's default.
135    ///
136    /// > *`charging_when_closed` … Default: **true***
137    #[must_use]
138    pub fn charging_when_closed_or_default(&self) -> bool {
139        self.charging_when_closed.unwrap_or(true)
140    }
141
142    /// Finds an EVSE by its `uid`, comparing case-insensitively as `CiString` requires.
143    #[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    /// Whether this Location may be shown to the holder of the given token.
149    ///
150    /// > *Locations that have this flag set to `false` SHALL not be shown in an app or on a
151    /// > website etc. unless it is to the owner of a Token in the `publish_allowed_to` list. …
152    /// > If the user … has provided information about his/her Token, and that information
153    /// > matches **all the fields** of one of the PublishToken tokens in the list, then they are
154    /// > allowed to show this location to their user.*
155    ///
156    /// Passing `None` asks whether the Location may be shown to the general public.
157    ///
158    /// Spec: 2.3.0 §mod_locations_location_object
159    #[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        // `EVSEParking.parking_id` "refers to a Parking object from the containing Location's
208        // parking_places field by its id field".
209        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/// The part that controls the power supply to a single EV in a single session.
235///
236/// > *An EVSE object has a list of Connectors which can not be used simultaneously: only one
237/// > connector per EVSE can be used at the time.*
238///
239/// Spec: 2.3.0 §mod_locations_evse_object
240#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, Builder)]
241#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
242#[builder(on(_, into))]
243pub struct Evse {
244    /// Uniquely identifies the EVSE within the CPO's platform. Never changed or renamed.
245    ///
246    /// > *Note that in order to fulfill both the requirement that an EVSE's `uid` be unique
247    /// > within a CPO's platform and the requirement that EVSEs are never deleted, a CPO will
248    /// > typically want to avoid using identifiers of the physical hardware for this `uid`.*
249    pub uid: CiString<36>,
250    /// The human-readable EVSE ID in the eMI3/IDACS format.
251    ///
252    /// Optional because *"if an `evse_id` is to be re-used in the real world, the `evse_id` can
253    /// be removed from an EVSE object if the `status` is set to `REMOVED`"*.
254    #[serde(default, skip_serializing_if = "Option::is_none")]
255    pub evse_id: Option<EvseId>,
256    /// The current status of the EVSE.
257    pub status: Status,
258    /// Planned status updates of the EVSE.
259    #[serde(default, skip_serializing_if = "Vec::is_empty")]
260    #[builder(default)]
261    pub status_schedule: Vec<StatusSchedule>,
262    /// Functionalities that the EVSE is capable of.
263    #[serde(default, skip_serializing_if = "Vec::is_empty")]
264    #[builder(default)]
265    pub capabilities: Vec<Capability>,
266    /// Available connectors on the EVSE. Cardinality `+`: at least one.
267    pub connectors: Vec<Connector>,
268    /// Level on which the Charge Point is located, in the locally displayed numbering scheme.
269    #[serde(default, skip_serializing_if = "Option::is_none")]
270    pub floor_level: Option<OcpiString<4>>,
271    /// Coordinates of the EVSE.
272    #[serde(default, skip_serializing_if = "Option::is_none")]
273    pub coordinates: Option<GeoLocation>,
274    /// A number/string printed on the outside of the EVSE for visual identification.
275    #[serde(default, skip_serializing_if = "Option::is_none")]
276    pub physical_reference: Option<OcpiString<16>>,
277    /// Directions on how to reach the EVSE from the Location.
278    #[serde(default, skip_serializing_if = "Vec::is_empty")]
279    #[builder(default)]
280    pub directions: Vec<DisplayText>,
281    /// Restrictions on who can charge at the EVSE, apart from those related to the vehicle type.
282    #[serde(default, skip_serializing_if = "Vec::is_empty")]
283    #[builder(default)]
284    pub parking_restrictions: Vec<ParkingRestriction>,
285    /// References to the parking spaces usable when charging at this EVSE. New in OCPI 2.3.0.
286    #[serde(default, skip_serializing_if = "Vec::is_empty")]
287    #[builder(default)]
288    pub parking: Vec<EvseParking>,
289    /// Links to images related to the EVSE.
290    #[serde(default, skip_serializing_if = "Vec::is_empty")]
291    #[builder(default)]
292    pub images: Vec<Image>,
293    /// Names of the eMSPs whose contract-based payment options are accepted at this EVSE.
294    ///
295    /// > *Note that this field is added specifically to allow European CPOs to comply with a
296    /// > regulatory requirement to provide this data to National Access Points (NAPs).*
297    ///
298    /// New in OCPI 2.3.0.
299    #[serde(default, skip_serializing_if = "Vec::is_empty")]
300    #[builder(default)]
301    pub accepted_service_providers: Vec<OcpiString<50>>,
302    /// Timestamp when this EVSE or one of its Connectors was last updated.
303    pub last_updated: DateTime,
304    /// Undocumented JSON fields, preserved verbatim.
305    #[serde(flatten, default, skip_serializing_if = "Extensions::is_empty")]
306    #[builder(default)]
307    pub extensions: Extensions,
308}
309
310impl Evse {
311    /// Whether a `StartSession` for this EVSE must carry a `connector_id`.
312    ///
313    /// > *`START_SESSION_CONNECTOR_REQUIRED`: When a StartSession is sent to this EVSE, the MSP
314    /// > is required to add the optional `connector_id` field in the StartSession object.*
315    ///
316    /// Spec: 2.3.0 §mod_locations_capability_enum
317    #[must_use]
318    pub fn requires_connector_id_on_start(&self) -> bool {
319        self.capabilities.contains(&Capability::StartSessionConnectorRequired)
320    }
321
322    /// Whether this EVSE has the given capability.
323    #[must_use]
324    pub fn has(&self, capability: &Capability) -> bool {
325        self.capabilities.contains(capability)
326    }
327
328    /// Finds a Connector by its `id`, comparing case-insensitively.
329    #[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/// The socket, or cable and plug, available for the EV to use.
366///
367/// Spec: 2.3.0 §mod_locations_connector_object
368#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, Builder)]
369#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
370#[builder(on(_, into))]
371pub struct Connector {
372    /// Identifier of the Connector within the EVSE.
373    ///
374    /// > *Two Connectors may have the same id as long as they do not belong to the same EVSE.*
375    pub id: CiString<36>,
376    /// The standard of the installed connector.
377    pub standard: ConnectorType,
378    /// The format (socket/cable) of the installed connector.
379    pub format: ConnectorFormat,
380    /// Whether the connector supplies AC or DC, and on how many phases.
381    pub power_type: PowerType,
382    /// Maximum voltage of the connector (line to neutral for `AC_3_PHASE`), in volt.
383    pub max_voltage: i32,
384    /// Maximum amperage of the connector, in ampere.
385    pub max_amperage: i32,
386    /// Maximum electric power this connector can deliver, in watt.
387    ///
388    /// > *When the maximum electric power is lower than the calculated value from `voltage` and
389    /// > `amperage`, this value should be set.*
390    #[serde(default, skip_serializing_if = "Option::is_none")]
391    pub max_electric_power: Option<i32>,
392    /// Identifiers of the currently valid charging tariffs.
393    ///
394    /// > *Multiple tariffs are possible, but only one of each `Tariff.type` can be active at the
395    /// > same time. … For a "free of charge" tariff, this field should be set and point to a
396    /// > defined "free of charge" tariff.*
397    #[serde(default, skip_serializing_if = "Vec::is_empty")]
398    #[builder(default)]
399    pub tariff_ids: Vec<CiString<36>>,
400    /// URL to the operator's terms and conditions.
401    #[serde(default, skip_serializing_if = "Option::is_none")]
402    pub terms_and_conditions: Option<Url>,
403    /// Functionalities the connector is capable of. New in OCPI 2.3.0.
404    #[serde(default, skip_serializing_if = "Vec::is_empty")]
405    #[builder(default)]
406    pub capabilities: Vec<ConnectorCapability>,
407    /// Timestamp when this Connector was last updated.
408    pub last_updated: DateTime,
409    /// Undocumented JSON fields, preserved verbatim.
410    #[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/// A parking space a vehicle can be parked in while charging.
444///
445/// > *Parking objects were newly added in OCPI 2.3.0 … The purpose of Parking objects is to
446/// > allow CPOs in the EU to comply with requirements in the EU's Alternative Fuel Infrastructure
447/// > Regulation (AFIR). … All Locations receivers who are not NAPs are free to ignore Parking
448/// > objects in the Location data that they receive.*
449///
450/// Spec: 2.3.0 §mod_locations_parking_object
451#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, Builder)]
452#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
453#[builder(on(_, into))]
454pub struct Parking {
455    /// Identifier for this parking space, unique among the Parking objects of one Location.
456    pub id: CiString<36>,
457    /// A short identifier physically visible on-site, e.g. painted on the surface.
458    #[serde(default, skip_serializing_if = "Option::is_none")]
459    pub physical_reference: Option<OcpiString<12>>,
460    /// The vehicle types the parking is designed to accommodate. Cardinality `+`.
461    pub vehicle_types: Vec<VehicleType>,
462    /// Maximum vehicle weight that can park at the EVSE, in kilograms.
463    #[serde(default, skip_serializing_if = "Option::is_none")]
464    pub max_vehicle_weight: Option<Number>,
465    /// Maximum vehicle height that can park at the EVSE, in centimetres.
466    #[serde(default, skip_serializing_if = "Option::is_none")]
467    pub max_vehicle_height: Option<Number>,
468    /// Maximum vehicle length that can park at the EVSE, in centimetres.
469    #[serde(default, skip_serializing_if = "Option::is_none")]
470    pub max_vehicle_length: Option<Number>,
471    /// Maximum vehicle width that can park at the EVSE, in centimetres.
472    #[serde(default, skip_serializing_if = "Option::is_none")]
473    pub max_vehicle_width: Option<Number>,
474    /// The length of the parking space, in centimetres.
475    #[serde(default, skip_serializing_if = "Option::is_none")]
476    pub parking_space_length: Option<Number>,
477    /// The width of the parking space, in centimetres.
478    #[serde(default, skip_serializing_if = "Option::is_none")]
479    pub parking_space_width: Option<Number>,
480    /// Whether vehicles loaded with dangerous substances may park at the EVSE.
481    #[serde(default, skip_serializing_if = "Option::is_none")]
482    pub dangerous_goods_allowed: Option<bool>,
483    /// The direction in which the vehicle is to be parked next to the EVSE.
484    #[serde(default, skip_serializing_if = "Option::is_none")]
485    pub direction: Option<ParkingDirection>,
486    /// Whether a vehicle can stop, charge and proceed without reversing.
487    #[serde(default, skip_serializing_if = "Option::is_none")]
488    pub drive_through: Option<bool>,
489    /// Whether vehicles of a type not listed in `vehicle_types` are forbidden to park here.
490    pub restricted_to_type: bool,
491    /// Whether a reservation is required for parking at the EVSE.
492    pub reservation_required: bool,
493    /// A parking time limit, in minutes.
494    #[serde(default, skip_serializing_if = "Option::is_none")]
495    pub time_limit: Option<Number>,
496    /// Whether the vehicle will be parked under a roof while charging.
497    #[serde(default, skip_serializing_if = "Option::is_none")]
498    pub roofed: Option<bool>,
499    /// Photos of the parking space.
500    ///
501    /// > *At least one photograph should be provided if the value of `vehicle_types` includes
502    /// > the `DISABLED` vehicle type.*
503    #[serde(default, skip_serializing_if = "Vec::is_empty")]
504    #[builder(default)]
505    pub images: Vec<Image>,
506    /// Whether the parking space is lit by artificial lighting.
507    #[serde(default, skip_serializing_if = "Option::is_none")]
508    pub lighting: Option<bool>,
509    /// Whether a power outlet is available for a transport truck's load refrigeration.
510    #[serde(default, skip_serializing_if = "Option::is_none")]
511    pub refrigeration_outlet: Option<bool>,
512    /// Standards the parking space conforms to, e.g. PAS 1899 for accessible parking.
513    #[serde(default, skip_serializing_if = "Vec::is_empty")]
514    #[builder(default)]
515    pub standards: Vec<CiString<36>>,
516    /// Reference to an Alliance for Parking Data Standards (APDS) element describing this
517    /// parking.
518    #[serde(default, skip_serializing_if = "Option::is_none")]
519    pub apds_reference: Option<CiText>,
520    /// Undocumented JSON fields, preserved verbatim.
521    #[serde(flatten, default, skip_serializing_if = "Extensions::is_empty")]
522    #[builder(default)]
523    pub extensions: Extensions,
524}
525
526impl Parking {
527    /// Whether the spec expects the vehicle dimension fields to be filled.
528    ///
529    /// > *A value for this field should be provided unless the value of the `vehicle_types` field
530    /// > contains no values other than `PERSONAL_VEHICLE` or `MOTORCYCLE`.*
531    #[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// ---------------------------------------------------------------------------------------------
577// Data types
578// ---------------------------------------------------------------------------------------------
579
580/// A geo location relevant to the Charge Point, with a name.
581///
582/// The geodetic system is WGS 84.
583///
584/// Spec: 2.3.0 §mod_locations_additionalgeolocation_class
585#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
586#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
587pub struct AdditionalGeoLocation {
588    /// Latitude of the point in decimal degrees.
589    pub latitude: OcpiString<10>,
590    /// Longitude of the point in decimal degrees.
591    pub longitude: OcpiString<11>,
592    /// Name of the point in the local language or as written at the location.
593    #[serde(default, skip_serializing_if = "Option::is_none")]
594    pub name: Option<DisplayText>,
595    /// Undocumented JSON fields, preserved verbatim.
596    #[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/// The geo location of a Charge Point. The geodetic system is WGS 84.
609///
610/// > *Five decimal places is seen as a minimum for GPS coordinates of the Charge Point as this
611/// > gives approximately 1 meter precision. More is always better.*
612///
613/// The spec types both fields as strings with a regex, not as numbers, so this crate keeps them
614/// as strings: re-serialising must not turn `"50.770774"` into `50.770774`. Use
615/// [`GeoLocation::latitude_decimal`] to compute with them.
616///
617/// Spec: 2.3.0 §mod_locations_geolocation_class
618#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
619#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
620pub struct GeoLocation {
621    /// Latitude in decimal degrees; regex `-?[0-9]{1,2}\.[0-9]{5,7}`.
622    pub latitude: OcpiString<10>,
623    /// Longitude in decimal degrees; regex `-?[0-9]{1,3}\.[0-9]{5,7}`.
624    pub longitude: OcpiString<11>,
625    /// Undocumented JSON fields, preserved verbatim.
626    #[serde(flatten, default, skip_serializing_if = "Extensions::is_empty")]
627    pub extensions: Extensions,
628}
629
630impl GeoLocation {
631    /// Creates a geo location from two coordinate strings.
632    ///
633    /// # Errors
634    ///
635    /// Returns [`crate::types::InvalidString`] if either value is too long or not printable.
636    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    /// The latitude as an exact decimal, if it parses.
648    #[must_use]
649    pub fn latitude_decimal(&self) -> Option<Number> {
650        self.latitude.as_str().parse().ok()
651    }
652
653    /// The longitude as an exact decimal, if it parses.
654    #[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
668/// Checks the coordinate regex the spec gives: `-?[0-9]{1,N}\.[0-9]{5,7}`.
669fn 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/// Details of a business: an operator, suboperator or owner.
694///
695/// Spec: 2.3.0 §mod_locations_businessdetails_class
696#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, Builder)]
697#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
698#[builder(on(_, into))]
699pub struct BusinessDetails {
700    /// Name of the operator.
701    pub name: OcpiString<100>,
702    /// Link to the operator's website.
703    #[serde(default, skip_serializing_if = "Option::is_none")]
704    pub website: Option<Url>,
705    /// Image link to the operator's logo.
706    #[serde(default, skip_serializing_if = "Option::is_none")]
707    pub logo: Option<Image>,
708    /// Undocumented JSON fields, preserved verbatim.
709    #[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/// The energy mix and environmental impact of the energy supplied at a location or in a tariff.
721///
722/// Spec: 2.3.0 §mod_locations_energymix_class
723#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, Builder)]
724#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
725#[builder(on(_, into))]
726pub struct EnergyMix {
727    /// True if 100% from regenerative sources: CO2 and nuclear waste are zero.
728    pub is_green_energy: bool,
729    /// Energy sources of this location's tariff, as category and percentage.
730    #[serde(default, skip_serializing_if = "Vec::is_empty")]
731    #[builder(default)]
732    pub energy_sources: Vec<EnergySource>,
733    /// Nuclear waste and CO2 exhaust of this location's tariff.
734    #[serde(default, skip_serializing_if = "Vec::is_empty")]
735    #[builder(default)]
736    pub environ_impact: Vec<EnvironmentalImpact>,
737    /// Name of the energy supplier delivering the energy for this location or tariff.
738    #[serde(default, skip_serializing_if = "Option::is_none")]
739    pub supplier_name: Option<OcpiString<64>>,
740    /// Name of the energy supplier's product/tariff plan used at this location.
741    #[serde(default, skip_serializing_if = "Option::is_none")]
742    pub energy_product_name: Option<OcpiString<64>>,
743    /// Undocumented JSON fields, preserved verbatim.
744    #[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        // "All given values of all categories should add up to 100 percent."
753        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/// One energy source and its share of the mix.
767///
768/// Spec: 2.3.0 §mod_locations_energysource_class
769#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
770#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
771pub struct EnergySource {
772    /// The type of energy source.
773    pub source: EnergySourceCategory,
774    /// Percentage of this source (0–100) in the mix.
775    pub percentage: Number,
776    /// Undocumented JSON fields, preserved verbatim.
777    #[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/// Waste produced or emitted per kWh.
791///
792/// Spec: 2.3.0 §mod_locations_environmentalimpact_class
793#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
794#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
795pub struct EnvironmentalImpact {
796    /// The environmental impact category of this value.
797    pub category: EnvironmentalImpactCategory,
798    /// Amount of this portion in g/kWh.
799    pub amount: Number,
800    /// Undocumented JSON fields, preserved verbatim.
801    #[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/// A link between an EVSE and a [`Parking`] object. New in OCPI 2.3.0.
812///
813/// Spec: 2.3.0 §mod_locations_evseparking_class
814#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
815#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
816pub struct EvseParking {
817    /// The `id` of a [`Parking`] in the containing Location's `parking_places`.
818    pub parking_id: CiString<36>,
819    /// The position of the EVSE relative to the parking space.
820    #[serde(default, skip_serializing_if = "Option::is_none")]
821    pub evse_position: Option<EvsePosition>,
822    /// Undocumented JSON fields, preserved verbatim.
823    #[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/// One exceptional period for opening or access hours.
834///
835/// Spec: 2.3.0 §mod_locations_exceptionalperiod_class
836#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
837#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
838pub struct ExceptionalPeriod {
839    /// Begin of the exception, in UTC.
840    pub period_begin: DateTime,
841    /// End of the exception, in UTC.
842    pub period_end: DateTime,
843    /// Undocumented JSON fields, preserved verbatim.
844    #[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/// Opening and access hours of a location.
862///
863/// Spec: 2.3.0 §mod_locations_hours_class
864#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, Builder)]
865#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
866#[builder(on(_, into))]
867pub struct Hours {
868    /// True to represent 24 hours a day and 7 days a week, except the given exceptions.
869    pub twentyfourseven: bool,
870    /// Regular weekday-based hours. Required to be non-empty when `twentyfourseven` is false.
871    #[serde(default, skip_serializing_if = "Vec::is_empty")]
872    #[builder(default)]
873    pub regular_hours: Vec<RegularHours>,
874    /// Periods the station is operating/accessible, additional to `regular_hours`.
875    #[serde(default, skip_serializing_if = "Vec::is_empty")]
876    #[builder(default)]
877    pub exceptional_openings: Vec<ExceptionalPeriod>,
878    /// Periods the station is not operating/accessible, overriding everything else.
879    #[serde(default, skip_serializing_if = "Vec::is_empty")]
880    #[builder(default)]
881    pub exceptional_closings: Vec<ExceptionalPeriod>,
882    /// Undocumented JSON fields, preserved verbatim.
883    #[serde(flatten, default, skip_serializing_if = "Extensions::is_empty")]
884    #[builder(default)]
885    pub extensions: Extensions,
886}
887
888impl Hours {
889    /// Whether the location is open at `instant`, given the location's UTC offset in seconds.
890    ///
891    /// Applies the spec's precedence: an exceptional closing beats an exceptional opening, which
892    /// beats the regular hours.
893    ///
894    /// > *`exceptional_closings`: … Overwriting `regular_hours` and `exceptional_openings`.*
895    #[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/// Regular recurring operation or access hours.
928///
929/// Spec: 2.3.0 §mod_locations_regularhours_class
930#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
931#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
932pub struct RegularHours {
933    /// Number of the day in the week, from Monday (1) till Sunday (7).
934    pub weekday: u8,
935    /// Begin of the regular period, in local time.
936    pub period_begin: LocalTime,
937    /// End of the regular period, in local time. Must be later than `period_begin`.
938    pub period_end: LocalTime,
939    /// Undocumented JSON fields, preserved verbatim.
940    #[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        // "Must be later than `period_begin`." Unlike TariffRestrictions, RegularHours does not
955        // define a wrap-around, so this is a genuine constraint.
956        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/// An image related to an EVSE, in terms of a file name or URL.
967///
968/// Spec: 2.3.0 §mod_locations_image_class
969#[derive(Clone, Debug, PartialEq, Serialize, Deserialize, Builder)]
970#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
971#[builder(on(_, into))]
972pub struct Image {
973    /// URL from where the image data can be fetched through a web browser.
974    pub url: Url,
975    /// URL from where a thumbnail of the image can be fetched.
976    #[serde(default, skip_serializing_if = "Option::is_none")]
977    pub thumbnail: Option<Url>,
978    /// Describes what the image is used for.
979    pub category: ImageCategory,
980    /// Image type, e.g. `gif`, `jpeg`, `png`, `svg`.
981    #[serde(rename = "type")]
982    pub image_type: CiString<4>,
983    /// Width of the full scale image.
984    #[serde(default, skip_serializing_if = "Option::is_none")]
985    pub width: Option<u32>,
986    /// Height of the full scale image.
987    #[serde(default, skip_serializing_if = "Option::is_none")]
988    pub height: Option<u32>,
989    /// Undocumented JSON fields, preserved verbatim.
990    #[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        // int(5) in the property table.
999        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/// The set of values that identify a token to which a Location might be published.
1008///
1009/// > *At least one of the following fields SHALL be set: `uid`, `visual_number`, or `group_id`.
1010/// > When `uid` is set, `type` SHALL also be set. When `visual_number` is set, `issuer` SHALL
1011/// > also be set.*
1012///
1013/// Spec: 2.3.0 §mod_locations_publish_token_class
1014#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize, Builder)]
1015#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
1016#[builder(on(_, into))]
1017pub struct PublishTokenType {
1018    /// Unique ID by which this Token can be identified.
1019    #[serde(default, skip_serializing_if = "Option::is_none")]
1020    pub uid: Option<CiString<36>>,
1021    /// Type of the token.
1022    #[serde(rename = "type", default, skip_serializing_if = "Option::is_none")]
1023    pub token_type: Option<TokenType>,
1024    /// Visual readable number/identification as printed on the Token.
1025    #[serde(default, skip_serializing_if = "Option::is_none")]
1026    pub visual_number: Option<OcpiString<64>>,
1027    /// Issuing company, most of the time the name printed on the token.
1028    #[serde(default, skip_serializing_if = "Option::is_none")]
1029    pub issuer: Option<OcpiString<64>>,
1030    /// Groups a couple of tokens so that two or more tokens work as one.
1031    #[serde(default, skip_serializing_if = "Option::is_none")]
1032    pub group_id: Option<CiString<36>>,
1033    /// Undocumented JSON fields, preserved verbatim.
1034    #[serde(flatten, default, skip_serializing_if = "Extensions::is_empty")]
1035    #[builder(default)]
1036    pub extensions: Extensions,
1037}
1038
1039impl PublishTokenType {
1040    /// Whether `candidate` matches **all the set fields** of this publish token.
1041    ///
1042    /// > *If the user of their app/website has provided information about his/her Token, and that
1043    /// > information matches all the fields of one of the PublishToken tokens in the list, then
1044    /// > they are allowed to show this location to their user.*
1045    ///
1046    /// A field this publish token leaves unset places no requirement on `candidate`.
1047    #[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/// A scheduled status period in the future.
1083///
1084/// > *The scheduled status is purely informational. When the status actually changes, the CPO
1085/// > must push an update to the EVSEs `status` field itself.*
1086///
1087/// Spec: 2.3.0 §mod_locations_statusschedule_class
1088#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
1089#[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
1090pub struct StatusSchedule {
1091    /// Begin of the scheduled period.
1092    pub period_begin: DateTime,
1093    /// End of the scheduled period, if known. A period MAY have no end.
1094    #[serde(default, skip_serializing_if = "Option::is_none")]
1095    pub period_end: Option<DateTime>,
1096    /// Status value during the scheduled period.
1097    pub status: Status,
1098    /// Undocumented JSON fields, preserved verbatim.
1099    #[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
1116// ---------------------------------------------------------------------------------------------
1117// Enumerations
1118// ---------------------------------------------------------------------------------------------
1119
1120ocpi_open_enum! {
1121    /// The capabilities of an EVSE.
1122    ///
1123    /// Spec: 2.3.0 §mod_locations_capability_enum
1124    pub enum Capability {
1125        /// The EVSE supports charging profiles.
1126        ChargingProfileCapable = "CHARGING_PROFILE_CAPABLE",
1127        /// The EVSE supports charging preferences.
1128        ChargingPreferencesCapable = "CHARGING_PREFERENCES_CAPABLE",
1129        /// EVSE has a payment terminal that supports chip cards.
1130        ChipCardSupport = "CHIP_CARD_SUPPORT",
1131        /// EVSE has a payment terminal that supports contactless cards.
1132        ContactlessCardSupport = "CONTACTLESS_CARD_SUPPORT",
1133        /// EVSE has a payment terminal that accepts credit cards.
1134        CreditCardPayable = "CREDIT_CARD_PAYABLE",
1135        /// EVSE has a payment terminal that accepts debit cards.
1136        DebitCardPayable = "DEBIT_CARD_PAYABLE",
1137        /// EVSE has a payment terminal with a pin-code entry device.
1138        PedTerminal = "PED_TERMINAL",
1139        /// The EVSE can remotely be started/stopped.
1140        RemoteStartStopCapable = "REMOTE_START_STOP_CAPABLE",
1141        /// The EVSE can be reserved.
1142        Reservable = "RESERVABLE",
1143        /// Charging at this EVSE can be authorized with an RFID token.
1144        RfidReader = "RFID_READER",
1145        /// A `StartSession` for this EVSE must carry the optional `connector_id` field.
1146        StartSessionConnectorRequired = "START_SESSION_CONNECTOR_REQUIRED",
1147        /// This EVSE supports token groups: two or more tokens work as one.
1148        TokenGroupCapable = "TOKEN_GROUP_CAPABLE",
1149        /// Connectors have a mechanical lock the eMSP can request to be unlocked.
1150        UnlockCapable = "UNLOCK_CAPABLE",
1151    }
1152}
1153
1154ocpi_open_enum! {
1155    /// Functionalities that a Connector may or may not support. New in OCPI 2.3.0.
1156    ///
1157    /// > *NOTE: these capabilities are meant to signal to eMSPs and their Drivers that a Driver
1158    /// > can indeed use these functionalities at a Connector. Mere support for a standard by the
1159    /// > charging hardware is not enough to warrant the presence of these capabilities.*
1160    ///
1161    /// Spec: 2.3.0 §mod_locations_connectorcapability_enum
1162    pub enum ConnectorCapability {
1163        /// Driver authentication with a contract certificate per ISO 15118-2.
1164        Iso151182PlugAndCharge = "ISO_15118_2_PLUG_AND_CHARGE",
1165        /// Driver authentication with a contract certificate per ISO 15118-20.
1166        Iso1511820PlugAndCharge = "ISO_15118_20_PLUG_AND_CHARGE",
1167    }
1168}
1169
1170ocpi_enum! {
1171    /// The format of the connector: whether it is a socket or an attached cable.
1172    ///
1173    /// Spec: 2.3.0 §mod_locations_connectorformat_enum
1174    pub enum ConnectorFormat {
1175        /// The connector is a socket; the EV user needs to bring a fitting plug.
1176        Socket = "SOCKET",
1177        /// The connector is an attached cable; the EV user's car needs a fitting inlet.
1178        Cable = "CABLE",
1179    }
1180}
1181
1182ocpi_open_enum! {
1183    /// The socket or plug standard of the charging point.
1184    ///
1185    /// This became an `OpenEnum` in OCPI 2.3.0 — in 2.2.1 it was a closed enum — which is the
1186    /// single most important reason not to reject unknown enum values: new plug standards appear
1187    /// faster than OCPI releases.
1188    ///
1189    /// Spec: 2.3.0 §mod_locations_connectortype_enum
1190    pub enum ConnectorType {
1191        /// CHAdeMO, DC.
1192        Chademo = "CHADEMO",
1193        /// The ChaoJi connector, harmonized between CHAdeMO and GB/T. DC.
1194        ChaoJi = "CHAOJI",
1195        /// Standard/Domestic household, type "A", NEMA 1-15, 2 pins.
1196        DomesticA = "DOMESTIC_A",
1197        /// Standard/Domestic household, type "B", NEMA 5-15, 3 pins.
1198        DomesticB = "DOMESTIC_B",
1199        /// Standard/Domestic household, type "C", CEE 7/17, 2 pins.
1200        DomesticC = "DOMESTIC_C",
1201        /// Standard/Domestic household, type "D", 3 pin.
1202        DomesticD = "DOMESTIC_D",
1203        /// Standard/Domestic household, type "E", CEE 7/5, 3 pins.
1204        DomesticE = "DOMESTIC_E",
1205        /// Standard/Domestic household, type "F", CEE 7/4, Schuko, 3 pins.
1206        DomesticF = "DOMESTIC_F",
1207        /// Standard/Domestic household, type "G", BS 1363, Commonwealth, 3 pins.
1208        DomesticG = "DOMESTIC_G",
1209        /// Standard/Domestic household, type "H", SI-32, 3 pins.
1210        DomesticH = "DOMESTIC_H",
1211        /// Standard/Domestic household, type "I", AS 3112, 3 pins.
1212        DomesticI = "DOMESTIC_I",
1213        /// Standard/Domestic household, type "J", SEV 1011, 3 pins.
1214        DomesticJ = "DOMESTIC_J",
1215        /// Standard/Domestic household, type "K", DS 60884-2-D1, 3 pins.
1216        DomesticK = "DOMESTIC_K",
1217        /// Standard/Domestic household, type "L", CEI 23-16-VII, 3 pins.
1218        DomesticL = "DOMESTIC_L",
1219        /// Standard/Domestic household, type "M", BS 546, 3 pins.
1220        DomesticM = "DOMESTIC_M",
1221        /// Standard/Domestic household, type "N", NBR 14136, 3 pins.
1222        DomesticN = "DOMESTIC_N",
1223        /// Standard/Domestic household, type "O", TIS 166-2549, 3 pins.
1224        DomesticO = "DOMESTIC_O",
1225        /// Guobiao GB/T 20234.2 AC socket/connector.
1226        GbtAc = "GBT_AC",
1227        /// Guobiao GB/T 20234.3 DC connector.
1228        GbtDc = "GBT_DC",
1229        /// IEC 60309-2 Industrial Connector single phase 16 A (usually blue).
1230        Iec603092Single16 = "IEC_60309_2_single_16",
1231        /// IEC 60309-2 Industrial Connector three phases 16 A (usually red).
1232        Iec603092Three16 = "IEC_60309_2_three_16",
1233        /// IEC 60309-2 Industrial Connector three phases 32 A (usually red).
1234        Iec603092Three32 = "IEC_60309_2_three_32",
1235        /// IEC 60309-2 Industrial Connector three phases 64 A (usually red).
1236        Iec603092Three64 = "IEC_60309_2_three_64",
1237        /// IEC 62196 Type 1 "SAE J1772".
1238        Iec62196T1 = "IEC_62196_T1",
1239        /// Combo Type 1 based, DC.
1240        Iec62196T1Combo = "IEC_62196_T1_COMBO",
1241        /// IEC 62196 Type 2 "Mennekes".
1242        Iec62196T2 = "IEC_62196_T2",
1243        /// Combo Type 2 based, DC.
1244        Iec62196T2Combo = "IEC_62196_T2_COMBO",
1245        /// IEC 62196 Type 3A.
1246        Iec62196T3A = "IEC_62196_T3A",
1247        /// IEC 62196 Type 3C "Scame".
1248        Iec62196T3C = "IEC_62196_T3C",
1249        /// The MegaWatt Charging System (MCS) connector developed by CharIN. New in 2.3.0.
1250        Mcs = "MCS",
1251        /// NEMA 5-20, 3 pins.
1252        Nema520 = "NEMA_5_20",
1253        /// NEMA 6-30, 3 pins.
1254        Nema630 = "NEMA_6_30",
1255        /// NEMA 6-50, 3 pins.
1256        Nema650 = "NEMA_6_50",
1257        /// NEMA 10-30, 3 pins.
1258        Nema1030 = "NEMA_10_30",
1259        /// NEMA 10-50, 3 pins.
1260        Nema1050 = "NEMA_10_50",
1261        /// NEMA 14-30, 3 pins, rating of 30 A.
1262        Nema1430 = "NEMA_14_30",
1263        /// NEMA 14-50, 3 pins, rating of 50 A.
1264        Nema1450 = "NEMA_14_50",
1265        /// On-board bottom-up pantograph, typically for bus charging.
1266        PantographBottomUp = "PANTOGRAPH_BOTTOM_UP",
1267        /// Off-board top-down pantograph, typically for bus charging.
1268        PantographTopDown = "PANTOGRAPH_TOP_DOWN",
1269        /// SAE J3400, also known as the North American Charging Standard (NACS).
1270        SaeJ3400 = "SAE_J3400",
1271        /// Tesla Connector "Roadster"-type (round, 4 pin).
1272        TeslaR = "TESLA_R",
1273        /// Tesla Connector "Model-S"-type (oval, 5 pin), mechanically compatible with SAE J3400.
1274        TeslaS = "TESLA_S",
1275    }
1276}
1277
1278ocpi_enum! {
1279    /// Categories of energy sources.
1280    ///
1281    /// Spec: 2.3.0 §mod_locations_energysourcecategory_enum
1282    pub enum EnergySourceCategory {
1283        /// Nuclear power sources.
1284        Nuclear = "NUCLEAR",
1285        /// All kinds of fossil power sources.
1286        GeneralFossil = "GENERAL_FOSSIL",
1287        /// Fossil power from coal.
1288        Coal = "COAL",
1289        /// Fossil power from gas.
1290        Gas = "GAS",
1291        /// All kinds of regenerative power sources.
1292        GeneralGreen = "GENERAL_GREEN",
1293        /// Regenerative power from PV.
1294        Solar = "SOLAR",
1295        /// Regenerative power from wind turbines.
1296        Wind = "WIND",
1297        /// Regenerative power from water turbines.
1298        Water = "WATER",
1299    }
1300}
1301
1302ocpi_open_enum! {
1303    /// Categories of environmental impact values.
1304    ///
1305    /// Spec: 2.3.0 §mod_locations_environmentalimpactcategory_enum
1306    pub enum EnvironmentalImpactCategory {
1307        /// Produced nuclear waste in grams per kilowatt-hour.
1308        NuclearWaste = "NUCLEAR_WASTE",
1309        /// Exhausted carbon dioxide in grams per kilowatt-hour.
1310        CarbonDioxide = "CARBON_DIOXIDE",
1311    }
1312}
1313
1314ocpi_enum! {
1315    /// The position of an EVSE relative to the EVSE's parking space. New in OCPI 2.3.0.
1316    ///
1317    /// Spec: 2.3.0 §mod_locations_evseposition_enum
1318    pub enum EvsePosition {
1319        /// The EVSE is to the left of the vehicle.
1320        Left = "LEFT",
1321        /// The EVSE is to the right of the vehicle when parked.
1322        Right = "RIGHT",
1323        /// The EVSE is at the center of the impassable narrow end of a parking space.
1324        Center = "CENTER",
1325    }
1326}
1327
1328ocpi_open_enum! {
1329    /// Facilities a charging location directly belongs to.
1330    ///
1331    /// Spec: 2.3.0 §mod_locations_facility_enum
1332    pub enum Facility {
1333        /// A hotel.
1334        Hotel = "HOTEL",
1335        /// A restaurant.
1336        Restaurant = "RESTAURANT",
1337        /// A cafe.
1338        Cafe = "CAFE",
1339        /// A mall or shopping center.
1340        Mall = "MALL",
1341        /// A supermarket.
1342        Supermarket = "SUPERMARKET",
1343        /// Sport facilities: gym, field etc.
1344        Sport = "SPORT",
1345        /// A recreation area.
1346        RecreationArea = "RECREATION_AREA",
1347        /// Located in, or close to, a park or nature reserve.
1348        Nature = "NATURE",
1349        /// A museum.
1350        Museum = "MUSEUM",
1351        /// A bike/e-bike/e-scooter sharing location.
1352        BikeSharing = "BIKE_SHARING",
1353        /// A bus stop.
1354        BusStop = "BUS_STOP",
1355        /// A taxi stand.
1356        TaxiStand = "TAXI_STAND",
1357        /// A tram stop/station.
1358        TramStop = "TRAM_STOP",
1359        /// A metro station.
1360        MetroStation = "METRO_STATION",
1361        /// A train station.
1362        TrainStation = "TRAIN_STATION",
1363        /// An airport.
1364        Airport = "AIRPORT",
1365        /// A parking lot.
1366        ParkingLot = "PARKING_LOT",
1367        /// A carpool parking.
1368        CarpoolParking = "CARPOOL_PARKING",
1369        /// A fuel station.
1370        FuelStation = "FUEL_STATION",
1371        /// Wifi or other type of internet available.
1372        Wifi = "WIFI",
1373    }
1374}
1375
1376ocpi_open_enum! {
1377    /// The category of an image, so it can be used correctly in a presentation.
1378    ///
1379    /// Spec: 2.3.0 §mod_locations_imagecategory_enum
1380    pub enum ImageCategory {
1381        /// Photo of the physical device that contains one or more EVSEs.
1382        Charger = "CHARGER",
1383        /// Location entrance photo, showing the car entrance from the street side.
1384        Entrance = "ENTRANCE",
1385        /// Location overview photo.
1386        Location = "LOCATION",
1387        /// Logo of an associated roaming network.
1388        Network = "NETWORK",
1389        /// Logo of the charge point operator.
1390        Operator = "OPERATOR",
1391        /// Other.
1392        Other = "OTHER",
1393        /// Logo of the charge point owner, for example a local store.
1394        Owner = "OWNER",
1395    }
1396}
1397
1398ocpi_enum! {
1399    /// The direction in which parking occurs relative to the approach roadway. New in 2.3.0.
1400    ///
1401    /// Spec: 2.3.0 §mod_locations_parkingdirection_enum
1402    pub enum ParkingDirection {
1403        /// Parking happens parallel to the roadway.
1404        Parallel = "PARALLEL",
1405        /// Parking happens perpendicular to the roadway.
1406        Perpendicular = "PERPENDICULAR",
1407        /// Parking happens at an angle to the roadway (echelon parking).
1408        Angle = "ANGLE",
1409    }
1410}
1411
1412ocpi_open_enum! {
1413    /// Restrictions on the parking spot for different purposes.
1414    ///
1415    /// `EMPLOYEES`, `TAXIS` and `TENANTS` are new in OCPI 2.3.0.
1416    ///
1417    /// Spec: 2.3.0 §mod_locations_parkingrestriction_enum
1418    pub enum ParkingRestriction {
1419        /// Parking spot for customers or guests only.
1420        Customers = "CUSTOMERS",
1421        /// Reserved parking spot for disabled people with a valid ID.
1422        Disabled = "DISABLED",
1423        /// Parking only for people who work at the site the Location belongs to.
1424        Employees = "EMPLOYEES",
1425        /// Reserved parking spot for electric vehicles.
1426        EvOnly = "EV_ONLY",
1427        /// Parking spot only suitable for (electric) motorcycles or scooters.
1428        Motorcycles = "MOTORCYCLES",
1429        /// Parking is only allowed while plugged in (charging).
1430        Plugged = "PLUGGED",
1431        /// Parking only for taxi vehicles.
1432        Taxis = "TAXIS",
1433        /// Parking only for people who live in a complex the Location belongs to.
1434        Tenants = "TENANTS",
1435    }
1436}
1437
1438ocpi_open_enum! {
1439    /// The general type of the charge point's location.
1440    ///
1441    /// Spec: 2.3.0 §mod_locations_parkingtype_enum
1442    pub enum ParkingType {
1443        /// A parking facility or rest area along a motorway, freeway, interstate or highway.
1444        AlongMotorway = "ALONG_MOTORWAY",
1445        /// Multistorey car park.
1446        ParkingGarage = "PARKING_GARAGE",
1447        /// A cleared area intended for parking vehicles, e.g. at supermarkets or bars.
1448        ParkingLot = "PARKING_LOT",
1449        /// Location is on the driveway of a house or building.
1450        OnDriveway = "ON_DRIVEWAY",
1451        /// Parking in public space along a street.
1452        OnStreet = "ON_STREET",
1453        /// Multistorey car park, mainly underground.
1454        UndergroundGarage = "UNDERGROUND_GARAGE",
1455    }
1456}
1457
1458ocpi_enum! {
1459    /// Whether a connector supplies AC or DC, and on how many phases.
1460    ///
1461    /// Spec: 2.3.0 §mod_locations_powertype_enum
1462    pub enum PowerType {
1463        /// AC single phase.
1464        Ac1Phase = "AC_1_PHASE",
1465        /// AC two phases, only two of the three available phases connected.
1466        Ac2Phase = "AC_2_PHASE",
1467        /// AC two phases using a split phase system.
1468        Ac2PhaseSplit = "AC_2_PHASE_SPLIT",
1469        /// AC three phases.
1470        Ac3Phase = "AC_3_PHASE",
1471        /// Direct current.
1472        Dc = "DC",
1473    }
1474}
1475
1476ocpi_enum! {
1477    /// The status of an EVSE.
1478    ///
1479    /// > *An EVSE is never deleted; a removed EVSE gets `status` `REMOVED`.*
1480    ///
1481    /// Spec: 2.3.0 §mod_locations_status_enum
1482    pub enum Status {
1483        /// The EVSE/Connector is able to start a new charging session.
1484        Available = "AVAILABLE",
1485        /// Not accessible because of a physical barrier, e.g. a car.
1486        Blocked = "BLOCKED",
1487        /// The EVSE/Connector is in use.
1488        Charging = "CHARGING",
1489        /// Not yet active, or temporarily unavailable, but not broken.
1490        Inoperative = "INOPERATIVE",
1491        /// Currently out of order; some parts may be broken or defective.
1492        OutOfOrder = "OUTOFORDER",
1493        /// Planned, will be operating soon.
1494        Planned = "PLANNED",
1495        /// Discontinued or removed.
1496        Removed = "REMOVED",
1497        /// Reserved for a particular EV driver and unavailable for other drivers.
1498        Reserved = "RESERVED",
1499        /// No status information available; also used when offline.
1500        Unknown = "UNKNOWN",
1501    }
1502}
1503
1504ocpi_open_enum! {
1505    /// Which type of vehicles can use a certain EVSE. New in OCPI 2.3.0.
1506    ///
1507    /// Spec: 2.3.0 §mod_locations_vehicletype_enum
1508    pub enum VehicleType {
1509        /// A motorcycle. Approximate UNECE code: L.
1510        Motorcycle = "MOTORCYCLE",
1511        /// A personal vehicle, a passenger car. UNECE: M1.
1512        PersonalVehicle = "PERSONAL_VEHICLE",
1513        /// A personal vehicle with a trailer attached. UNECE: M1 + O.
1514        PersonalVehicleWithTrailer = "PERSONAL_VEHICLE_WITH_TRAILER",
1515        /// A light-duty van with a height smaller than 275 cm. UNECE: N1.
1516        Van = "VAN",
1517        /// A heavy-duty tractor unit without a trailer. UNECE: T.
1518        SemiTractor = "SEMI_TRACTOR",
1519        /// A heavy-duty truck without an articulation point. UNECE: N2/N3.
1520        Rigid = "RIGID",
1521        /// A heavy-duty truck with a trailer attached. UNECE: N2/N3 + O.
1522        TruckWithTrailer = "TRUCK_WITH_TRAILER",
1523        /// A bus or a motor coach. UNECE: M2/M3.
1524        Bus = "BUS",
1525        /// A vehicle with a permit for parking spaces for people with disabilities.
1526        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"),      // too few decimals
1543            ("50", "-126.104965"),         // no decimal point
1544            ("50.7707745678", "-126.1"),   // too many decimals, and too few
1545            ("50.770774", "-1261.104965"), // too many integer digits (also over string(11))
1546        ] {
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        // The empty connector list is the other violation the same object has.
1681        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}