Skip to main content

codetics_ocpp16/
lib.rs

1// SPDX-License-Identifier: Apache-2.0
2// Copyright 2026 Kevin Vavelin, Codetics (https://codetics.fr)
3
4//! OCPP 1.6 (OCPP-J) protocol messages.
5//!
6//! Pure Rust definitions of every OCPP 1.6 message, following the
7//! specification (chapter 7 type names) and the 1.6-J errata 2025-04
8//! (both `Celcius` and `Celsius` are accepted, `StopTransaction.reason` is optional).
9//!
10//! - [`enums`], [`datatypes`], [`messages`]: payload types with serde mappings
11//!   and [`ocpp_core::Validate`] impls for length / range / cardinality constraints.
12//! - [`actions`]: [`Action`] with message direction, and the typed
13//!   [`StationCall`] / [`CsmsCall`] dispatch enums.
14
15pub mod actions;
16pub mod datatypes;
17pub mod enums;
18pub mod messages;
19
20pub use actions::*;
21pub use datatypes::*;
22pub use enums::*;
23pub use messages::*;
24
25/// WebSocket subprotocol of this version.
26pub const SUBPROTOCOL: &str = "ocpp1.6";
27
28#[cfg(test)]
29mod tests {
30    use super::*;
31
32    #[test]
33    fn decode_spec_boot_notification() {
34        let c = StationCall::decode(
35            "BootNotification",
36            r#"{"chargePointVendor": "VendorX", "chargePointModel": "SingleSocketCharger"}"#,
37        )
38        .unwrap();
39        match c {
40            StationCall::BootNotification(b) => {
41                assert_eq!(b.charge_point_vendor, "VendorX");
42                assert_eq!(b.charge_point_model, "SingleSocketCharger");
43                assert!(b.firmware_version.is_none());
44            }
45            other => panic!("{other:?}"),
46        }
47    }
48
49    #[test]
50    fn violations_map_to_error_codes() {
51        use ocpp_core::ViolationKind::*;
52        let kind = |a: &str, p: &str| match StationCall::decode(a, p) {
53            Err(DecodeError::Invalid(v)) => v.kind,
54            other => panic!("{other:?}"),
55        };
56        // missing required field
57        assert_eq!(
58            kind("BootNotification", r#"{"chargePointVendor":"V"}"#),
59            Occurrence
60        );
61        // maxLength 20 exceeded
62        assert_eq!(
63            kind(
64                "BootNotification",
65                r#"{"chargePointVendor":"V","chargePointModel":"123456789012345678901"}"#
66            ),
67            Property
68        );
69        // wrong type
70        assert_eq!(kind("Heartbeat", r#"{"x":1}"#), Format);
71        assert_eq!(
72            kind(
73                "StatusNotification",
74                r#"{"connectorId":"1","errorCode":"NoError","status":"Available"}"#
75            ),
76            Type
77        );
78        // unknown enum value
79        assert_eq!(
80            kind(
81                "StatusNotification",
82                r#"{"connectorId":1,"errorCode":"NoError","status":"Broken"}"#
83            ),
84            Property
85        );
86    }
87
88    #[test]
89    fn direction_is_enforced() {
90        assert!(matches!(
91            StationCall::decode("Reset", r#"{"type":"Hard"}"#),
92            Err(DecodeError::WrongDirection(Action::Reset))
93        ));
94        assert!(matches!(
95            StationCall::decode("Nope", "{}"),
96            Err(DecodeError::UnknownAction(_))
97        ));
98        assert!(matches!(
99            CsmsCall::decode("Reset", r#"{"type":"Hard"}"#),
100            Ok(CsmsCall::Reset(_))
101        ));
102        assert!(StationCall::decode("DataTransfer", r#"{"vendorId":"x"}"#).is_ok());
103        assert!(CsmsCall::decode("DataTransfer", r#"{"vendorId":"x"}"#).is_ok());
104    }
105
106    #[test]
107    fn errata_celsius_and_optional_reason() {
108        let p = r#"{"transactionId":1,"timestamp":"2024-01-01T00:00:00Z","meterStop":10,
109            "transactionData":[{"timestamp":"2024-01-01T00:00:00Z","sampledValue":[{"value":"20","unit":"Celsius"},{"value":"20","unit":"Celcius"}]}]}"#;
110        assert!(StationCall::decode("StopTransaction", p).is_ok());
111    }
112
113    #[test]
114    fn multiple_of_constraint() {
115        let mk = |limit: &str| {
116            format!(
117                r#"{{"connectorId":1,"csChargingProfiles":{{"chargingProfileId":1,"stackLevel":0,"chargingProfilePurpose":"TxDefaultProfile","chargingProfileKind":"Absolute",
118                "chargingSchedule":{{"chargingRateUnit":"A","chargingSchedulePeriod":[{{"startPeriod":0,"limit":{limit}}}]}}}}}}"#
119            )
120        };
121        assert!(CsmsCall::decode("SetChargingProfile", &mk("8.1")).is_ok());
122        assert!(CsmsCall::decode("SetChargingProfile", &mk("8.15")).is_err());
123    }
124
125    #[test]
126    fn response_roundtrip() {
127        let r = BootNotificationResponse {
128            status: RegistrationStatus::Accepted,
129            current_time: ocpp_core::DateTime::parse("2013-02-01T20:53:32.486Z").unwrap(),
130            interval: 300,
131        };
132        let s = serde_json::to_string(&r).unwrap();
133        assert_eq!(
134            s,
135            r#"{"status":"Accepted","currentTime":"2013-02-01T20:53:32.486Z","interval":300}"#
136        );
137        assert!(validate_response(Action::BootNotification, &s).is_ok());
138    }
139}
140
141/// Compiles and runs the examples of the README.
142#[cfg(doctest)]
143#[doc = include_str!("../README.md")]
144pub struct ReadmeDoctests;