ocpp_types/lib.rs
1//! Strongly typed OCPP message types for 1.6J, 2.0.1, and 2.1.
2//!
3//! `ocpp-types` provides the request/response payload types for the Open
4//! Charge Point Protocol, generated from the official JSON schemas. It is
5//! `no_std` and allocation-free: field sizes are bounded at the type level
6//! with [`heapless`] collections, sized to the limits stated in each
7//! version's specification.
8//!
9//! Each protocol version lives in its own module -- [`v16`], [`v201`],
10//! [`v21`] -- since the same message name can differ in shape across
11//! versions.
12//!
13//! # Fields with no spec-given bound
14//!
15//! A handful of fields (free-text strings, a few arrays) have no
16//! `maxLength`/`maxItems` in the spec, so there's no size to give a
17//! `heapless` collection without guessing one. These become a const
18//! generic the caller picks (with a default, so most code never needs to
19//! think about it):
20//!
21//! ```
22//! # #[cfg(not(feature = "alloc"))]
23//! # {
24//! use ocpp_types::v16::DataTransferResponse;
25//! use ocpp_types::v16::common::DataTransferResponseStatus;
26//!
27//! // Uses the default capacity (1024):
28//! let response: DataTransferResponse = DataTransferResponse {
29//! data: Some(heapless::String::try_from("vendor payload").unwrap()),
30//! status: DataTransferResponseStatus::Accepted,
31//! };
32//!
33//! // Or pick a smaller one explicitly:
34//! let response: DataTransferResponse<64> = DataTransferResponse {
35//! data: Some(heapless::String::try_from("vendor payload").unwrap()),
36//! status: DataTransferResponseStatus::Accepted,
37//! };
38//! # }
39//! ```
40//!
41//! With the `alloc` feature enabled, these fields become plain
42//! `alloc::string::String`/`alloc::vec::Vec<T>` instead, and the const
43//! generic disappears entirely -- useful on targets with a real allocator
44//! (a CSMS backend, a simulator) that would rather not pick a bound at all.
45//!
46//! # Timestamps
47//!
48//! Every version types its `dateTime` fields as `{"type": "string",
49//! "format": "date-time"}` without a `maxLength`, so they would fall under
50//! the rule above and reserve 1024 bytes each. They are [`OcppTimestamp`]
51//! instead: 16 bytes, no const generic, no allocator, and comparable --
52//! which a string is not.
53//!
54//! ```
55//! use ocpp_types::{OcppTimestamp, v16::HeartbeatResponse};
56//!
57//! let response = HeartbeatResponse {
58//! current_time: OcppTimestamp::parse_rfc3339("2024-01-01T00:00:00Z").unwrap(),
59//! };
60//!
61//! assert_eq!(response.current_time.unix_seconds(), 1_704_067_200);
62//! ```
63//!
64//! Enable the `chrono` feature for `From`/`Into` conversions with
65//! `chrono::DateTime`. That is interop only -- chrono is never on the wire
66//! path, since its own serde support formats through an allocating
67//! `to_rfc3339`, which the no-`alloc` build cannot use.
68//!
69//! # Sizing for allocation-free targets
70//!
71//! With `default-features = false` every field is stored inline at its
72//! declared capacity, so a message's `size_of` is the sum of what it *could*
73//! hold, not what it does. Most of the protocol is small under that rule --
74//! the median message is a few hundred bytes and around 85% are under 4 KB --
75//! but a few families reserve far more, and those need their capacities
76//! named rather than defaulted.
77//!
78//! Every capacity below is a const generic with a default, so nothing has to
79//! be specified to compile; specifying them is how a station trades unused
80//! headroom for stack space.
81//!
82//! | If your station does | Set | Because the default is |
83//! | --- | --- | --- |
84//! | Smart charging | `chargingSchedulePeriod`, `chargingProfile` caps | 8 each, and they nest three deep |
85//! | V2X / bidirectional | `v2xFreqWattCurve`, `v2xSignalWattCurve` | 8 points each, on every schedule period |
86//! | ISO 15118-20 pricing | `priceRuleStacks`, `priceLevelScheduleEntries`, `salesTariffEntry` | 8 each; set to 0 if unused |
87//! | Tariffs (2.1) | `energyPrices`, `timePrices`, `fixedPrices` | 8 each, per tariff kind |
88//! | Plug and Charge | `certificate`, `certificateChain`, `csr`, `signingCertificate` | 1024 -- *too small for a real PEM chain* |
89//! | Signed metering | `signedMeterData` | 1024; the spec allows 32768 |
90//! | Local auth lists | `localAuthorizationList` | 8 entries |
91//! | Metering | `meterValue`, `sampledValue` | 8 each, and they multiply |
92//!
93//! Two of these are worth calling out for opposite reasons. The Plug and
94//! Charge fields are the only ones whose default is deliberately *too small*:
95//! a PEM chain will not fit in 1024 bytes, so a deployment that uses
96//! certificates must raise them and will discover this immediately in
97//! testing. Erring the other way would have cost every deployment that never
98//! sees a certificate. Conversely, capacities you set to `0` cost nothing at
99//! all, which is the cheapest way to exclude a feature you do not implement.
100//!
101//! As a worked example, `ReportChargingProfilesRequest` defaults to 530 KB.
102//! A station that advertises `PeriodsPerSchedule = 8`, reports one profile at
103//! a time, and implements neither V2X nor ISO 15118-20 pricing compiles the
104//! same message at 18 KB by naming those capacities.
105//!
106//! The specification expects this: `SmartChargingCtrlr.PeriodsPerSchedule` is
107//! a required 2.x variable and 1.6 has `ChargingScheduleMaxPeriods`, so every
108//! station already declares its own limits. Compiling in the capacity you
109//! advertise is conformant; reserving the protocol ceiling you will never
110//! accept is merely large.
111//!
112//! # Checking a payload against the spec
113//!
114//! Most spec limits are in the types, so a violation cannot be built: a
115//! property bounded at `maxLength: 20` is a `heapless::String<20>`. What is
116//! left over is what the `validate` feature covers — the bounds too large
117//! to store inline (see the sizing table above: those fields are a growable
118//! `String`/`Vec` under `alloc`, and a caller-chosen capacity without it),
119//! plus every `minItems`, `minimum`, `maximum` and `multipleOf` in the
120//! schemas, which no collection type can express at all.
121//!
122//! ```
123//! # #[cfg(all(feature = "validate", feature = "alloc"))] {
124//! use ocpp_types::v21::CancelReservationRequest;
125//! use ocpp_types::validate::{Validate, ValidationErrorKind};
126//!
127//! let request: CancelReservationRequest = CancelReservationRequest {
128//! custom_data: None,
129//! reservation_id: -1, // the schema states `minimum: 0`
130//! };
131//!
132//! let error = request.validate().unwrap_err();
133//! assert_eq!(
134//! error.kind(),
135//! ValidationErrorKind::BelowMinimum { value: -1.0, min: 0.0 },
136//! );
137//! # }
138//! ```
139//!
140//! This matters most on the sending side, and most of all for a CSMS: an
141//! over-long field that the type accepted comes back from the peer as a
142//! `CALLERROR` with no indication of which field caused it, while
143//! [`validate::ValidationError`] names the path to it. Nothing calls it for
144//! you — see the [`validate`] module.
145//!
146//! # Fields the spec leaves untyped
147//!
148//! 2.0.1 and 2.1's `DataTransfer` carries a `data` field the specification
149//! deliberately gives no type at all -- "open to implementation", agreed
150//! between the two parties. There's no single Rust type for arbitrary JSON
151//! without an allocator, so the payload type is the caller's to pick, as a
152//! type parameter defaulting to `()` (i.e. "this deployment sends no
153//! data"):
154//!
155//! ```
156//! use ocpp_types::v201::DataTransferRequest;
157//!
158//! // Whatever this vendor agreed on; add `serde::Serialize`/`Deserialize`
159//! // to send it on the wire.
160//! #[derive(Debug, Clone, PartialEq)]
161//! struct VendorPayload {
162//! session_id: u32,
163//! }
164//!
165//! let request: DataTransferRequest<VendorPayload> = DataTransferRequest {
166//! custom_data: None,
167//! data: Some(VendorPayload { session_id: 42 }),
168//! message_id: None,
169//! vendor_id: heapless::String::try_from("com.example").unwrap(),
170//! };
171//!
172//! // Or, sending no vendor payload at all, the default:
173//! let plain: DataTransferRequest = DataTransferRequest {
174//! custom_data: None,
175//! data: None,
176//! message_id: None,
177//! vendor_id: heapless::String::try_from("com.example").unwrap(),
178//! };
179//! ```
180//!
181//! 1.6J's `DataTransfer.data` is a plain string in that version's schema,
182//! so it stays `Option<heapless::String<N>>` and needs no parameter.
183//!
184//! # Example
185//!
186//! ```
187//! use ocpp_types::Action;
188//! use ocpp_types::v16::{AuthorizeRequest, IdTag};
189//!
190//! let request = AuthorizeRequest {
191//! id_tag: IdTag::try_from("ABC123").unwrap(),
192//! };
193//!
194//! assert_eq!(AuthorizeRequest::ACTION, "Authorize");
195//! ```
196//!
197//! # Serialization
198//!
199//! With the `serde` feature enabled, every message implements
200//! `serde::Serialize`/`serde::Deserialize`, and [`Action`] gains
201//! zero-allocation JSON helpers backed by
202//! [`serde-json-core`](https://docs.rs/serde-json-core) -- the caller owns
203//! the buffer, nothing is heap-allocated:
204//!
205//! ```ignore
206//! use ocpp_types::Action;
207//!
208//! let mut buf = [0u8; 256];
209//! let json: &str = request.to_json_str(&mut buf)?;
210//! let parsed = AuthorizeRequest::from_json_str(json)?;
211//! ```
212//!
213//! # RPC errors
214//!
215//! Each version also exposes an `RpcErrorCode` enum covering the
216//! `CALLERROR` codes defined by that version's OCPP-J specification (e.g.
217//! [`v16::RpcErrorCode`]), implementing [`core::error::Error`].
218//!
219//! # WebSocket envelopes
220//!
221//! With `serde`, `Call`/`CallResult`/`CallError` model the OCPP-J
222//! array-based envelope every message travels in (`[2, messageId, action,
223//! payload]`, etc.) -- generic over the payload type, so no per-version
224//! duplication is needed. `CallResultError`/`SendMessage` cover 2.1's
225//! additional `CALLRESULTERROR`/`SEND` message types (the shapes work for
226//! any version; whether a given deployment actually uses them is a
227//! protocol-level concern, not something the types enforce). See
228//! `examples/envelope.rs`.
229#![no_std]
230
231#[cfg(feature = "alloc")]
232extern crate alloc;
233
234mod action;
235mod custom_data;
236mod envelope;
237mod timestamp;
238// `ValidationError` carries the JSON path to the failing value inline --
239// there is no allocator to box it into, and a path that names the field is
240// the whole point of the error. That makes it larger than clippy's
241// threshold, deliberately; see `validate::MAX_PATH_DEPTH`, and the size
242// test that pins it.
243#[cfg_attr(feature = "validate", allow(clippy::result_large_err))]
244#[cfg(feature = "validate")]
245pub mod validate;
246
247#[cfg(test)]
248mod generics_test;
249
250#[cfg(test)]
251mod size_test;
252
253#[cfg(test)]
254mod standard_test;
255
256#[cfg(test)]
257mod untyped_data_test;
258
259#[cfg(all(test, feature = "validate"))]
260mod validate_test;
261
262#[cfg(test)]
263mod v16_security_test;
264
265pub mod v16;
266pub mod v201;
267pub mod v21;
268
269pub use action::Action;
270#[cfg(feature = "serde")]
271pub use envelope::{Call, CallError, CallResult, CallResultError, EmptyPayload, SendMessage};
272pub use custom_data::NoCustomData;
273pub use envelope::MessageId;
274pub use timestamp::{MAX_RFC3339_LEN, OcppDate, OcppTimeOfDay, OcppTimestamp, TimestampError};
275
276#[cfg(test)]
277mod tests {
278 use super::*;
279
280 #[test]
281 fn id_tag_round_trips_a_short_string() {
282 let tag = v16::IdTag::try_from("ABC123").unwrap();
283 assert_eq!(tag.as_str(), "ABC123");
284 }
285
286 #[test]
287 fn id_tag_rejects_strings_over_20_bytes() {
288 let too_long = "A".repeat(21);
289 assert!(v16::IdTag::try_from(too_long.as_str()).is_err());
290 }
291
292 /// See `envelope::tests::message_id_error_type_stays_unit_...`: the
293 /// wrapper's `Error` is this crate's public API and must not track
294 /// whatever `heapless::String::try_from` happens to return.
295 #[test]
296 fn id_tag_error_type_stays_unit_regardless_of_the_heapless_version() {
297 fn assert_unit_error<T>()
298 where
299 for<'a> T: TryFrom<&'a str, Error = ()>,
300 {
301 }
302
303 assert_unit_error::<v16::IdTag>();
304 }
305
306 struct DummyRequest;
307
308 impl Action for DummyRequest {
309 const ACTION: &'static str = "Dummy";
310 }
311
312 #[test]
313 fn action_trait_exposes_action_name() {
314 assert_eq!(DummyRequest::ACTION, "Dummy");
315 }
316}