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//! # Fields the spec leaves untyped
113//!
114//! 2.0.1 and 2.1's `DataTransfer` carries a `data` field the specification
115//! deliberately gives no type at all -- "open to implementation", agreed
116//! between the two parties. There's no single Rust type for arbitrary JSON
117//! without an allocator, so the payload type is the caller's to pick, as a
118//! type parameter defaulting to `()` (i.e. "this deployment sends no
119//! data"):
120//!
121//! ```
122//! use ocpp_types::v201::DataTransferRequest;
123//!
124//! // Whatever this vendor agreed on; add `serde::Serialize`/`Deserialize`
125//! // to send it on the wire.
126//! #[derive(Debug, Clone, PartialEq)]
127//! struct VendorPayload {
128//! session_id: u32,
129//! }
130//!
131//! let request: DataTransferRequest<VendorPayload> = DataTransferRequest {
132//! custom_data: None,
133//! data: Some(VendorPayload { session_id: 42 }),
134//! message_id: None,
135//! vendor_id: heapless::String::try_from("com.example").unwrap(),
136//! };
137//!
138//! // Or, sending no vendor payload at all, the default:
139//! let plain: DataTransferRequest = DataTransferRequest {
140//! custom_data: None,
141//! data: None,
142//! message_id: None,
143//! vendor_id: heapless::String::try_from("com.example").unwrap(),
144//! };
145//! ```
146//!
147//! 1.6J's `DataTransfer.data` is a plain string in that version's schema,
148//! so it stays `Option<heapless::String<N>>` and needs no parameter.
149//!
150//! # Example
151//!
152//! ```
153//! use ocpp_types::Action;
154//! use ocpp_types::v16::{AuthorizeRequest, IdTag};
155//!
156//! let request = AuthorizeRequest {
157//! id_tag: IdTag::try_from("ABC123").unwrap(),
158//! };
159//!
160//! assert_eq!(AuthorizeRequest::ACTION, "Authorize");
161//! ```
162//!
163//! # Serialization
164//!
165//! With the `serde` feature enabled, every message implements
166//! `serde::Serialize`/`serde::Deserialize`, and [`Action`] gains
167//! zero-allocation JSON helpers backed by
168//! [`serde-json-core`](https://docs.rs/serde-json-core) -- the caller owns
169//! the buffer, nothing is heap-allocated:
170//!
171//! ```ignore
172//! use ocpp_types::Action;
173//!
174//! let mut buf = [0u8; 256];
175//! let json: &str = request.to_json_str(&mut buf)?;
176//! let parsed = AuthorizeRequest::from_json_str(json)?;
177//! ```
178//!
179//! # RPC errors
180//!
181//! Each version also exposes an `RpcErrorCode` enum covering the
182//! `CALLERROR` codes defined by that version's OCPP-J specification (e.g.
183//! [`v16::RpcErrorCode`]), implementing [`core::error::Error`].
184//!
185//! # WebSocket envelopes
186//!
187//! With `serde`, `Call`/`CallResult`/`CallError` model the OCPP-J
188//! array-based envelope every message travels in (`[2, messageId, action,
189//! payload]`, etc.) -- generic over the payload type, so no per-version
190//! duplication is needed. `CallResultError`/`SendMessage` cover 2.1's
191//! additional `CALLRESULTERROR`/`SEND` message types (the shapes work for
192//! any version; whether a given deployment actually uses them is a
193//! protocol-level concern, not something the types enforce). See
194//! `examples/envelope.rs`.
195#![no_std]
196
197#[cfg(feature = "alloc")]
198extern crate alloc;
199
200mod action;
201mod custom_data;
202mod envelope;
203mod timestamp;
204
205#[cfg(test)]
206mod generics_test;
207
208#[cfg(test)]
209mod size_test;
210
211#[cfg(test)]
212mod standard_test;
213
214#[cfg(test)]
215mod untyped_data_test;
216
217#[cfg(test)]
218mod v16_security_test;
219
220pub mod v16;
221pub mod v201;
222pub mod v21;
223
224pub use action::Action;
225#[cfg(feature = "serde")]
226pub use envelope::{Call, CallError, CallResult, CallResultError, EmptyPayload, SendMessage};
227pub use custom_data::NoCustomData;
228pub use envelope::MessageId;
229pub use timestamp::{MAX_RFC3339_LEN, OcppDate, OcppTimeOfDay, OcppTimestamp, TimestampError};
230
231#[cfg(test)]
232mod tests {
233 use super::*;
234
235 #[test]
236 fn id_tag_round_trips_a_short_string() {
237 let tag = v16::IdTag::try_from("ABC123").unwrap();
238 assert_eq!(tag.as_str(), "ABC123");
239 }
240
241 #[test]
242 fn id_tag_rejects_strings_over_20_bytes() {
243 let too_long = "A".repeat(21);
244 assert!(v16::IdTag::try_from(too_long.as_str()).is_err());
245 }
246
247 /// See `envelope::tests::message_id_error_type_stays_unit_...`: the
248 /// wrapper's `Error` is this crate's public API and must not track
249 /// whatever `heapless::String::try_from` happens to return.
250 #[test]
251 fn id_tag_error_type_stays_unit_regardless_of_the_heapless_version() {
252 fn assert_unit_error<T>()
253 where
254 for<'a> T: TryFrom<&'a str, Error = ()>,
255 {
256 }
257
258 assert_unit_error::<v16::IdTag>();
259 }
260
261 struct DummyRequest;
262
263 impl Action for DummyRequest {
264 const ACTION: &'static str = "Dummy";
265 }
266
267 #[test]
268 fn action_trait_exposes_action_name() {
269 assert_eq!(DummyRequest::ACTION, "Dummy");
270 }
271}