codetics-ocpp16 0.1.0

Codetics OCPP: OCPP 1.6 (OCPP-J) message types with validation and typed dispatch
Documentation
// SPDX-License-Identifier: Apache-2.0
// Copyright 2026 Kevin Vavelin, Codetics (https://codetics.fr)

//! OCPP 1.6 (OCPP-J) protocol messages.
//!
//! Pure Rust definitions of every OCPP 1.6 message, following the
//! specification (chapter 7 type names) and the 1.6-J errata 2025-04
//! (both `Celcius` and `Celsius` are accepted, `StopTransaction.reason` is optional).
//!
//! - [`enums`], [`datatypes`], [`messages`]: payload types with serde mappings
//!   and [`ocpp_core::Validate`] impls for length / range / cardinality constraints.
//! - [`actions`]: [`Action`] with message direction, and the typed
//!   [`StationCall`] / [`CsmsCall`] dispatch enums.

pub mod actions;
pub mod datatypes;
pub mod enums;
pub mod messages;

pub use actions::*;
pub use datatypes::*;
pub use enums::*;
pub use messages::*;

/// WebSocket subprotocol of this version.
pub const SUBPROTOCOL: &str = "ocpp1.6";

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn decode_spec_boot_notification() {
        let c = StationCall::decode(
            "BootNotification",
            r#"{"chargePointVendor": "VendorX", "chargePointModel": "SingleSocketCharger"}"#,
        )
        .unwrap();
        match c {
            StationCall::BootNotification(b) => {
                assert_eq!(b.charge_point_vendor, "VendorX");
                assert_eq!(b.charge_point_model, "SingleSocketCharger");
                assert!(b.firmware_version.is_none());
            }
            other => panic!("{other:?}"),
        }
    }

    #[test]
    fn violations_map_to_error_codes() {
        use ocpp_core::ViolationKind::*;
        let kind = |a: &str, p: &str| match StationCall::decode(a, p) {
            Err(DecodeError::Invalid(v)) => v.kind,
            other => panic!("{other:?}"),
        };
        // missing required field
        assert_eq!(
            kind("BootNotification", r#"{"chargePointVendor":"V"}"#),
            Occurrence
        );
        // maxLength 20 exceeded
        assert_eq!(
            kind(
                "BootNotification",
                r#"{"chargePointVendor":"V","chargePointModel":"123456789012345678901"}"#
            ),
            Property
        );
        // wrong type
        assert_eq!(kind("Heartbeat", r#"{"x":1}"#), Format);
        assert_eq!(
            kind(
                "StatusNotification",
                r#"{"connectorId":"1","errorCode":"NoError","status":"Available"}"#
            ),
            Type
        );
        // unknown enum value
        assert_eq!(
            kind(
                "StatusNotification",
                r#"{"connectorId":1,"errorCode":"NoError","status":"Broken"}"#
            ),
            Property
        );
    }

    #[test]
    fn direction_is_enforced() {
        assert!(matches!(
            StationCall::decode("Reset", r#"{"type":"Hard"}"#),
            Err(DecodeError::WrongDirection(Action::Reset))
        ));
        assert!(matches!(
            StationCall::decode("Nope", "{}"),
            Err(DecodeError::UnknownAction(_))
        ));
        assert!(matches!(
            CsmsCall::decode("Reset", r#"{"type":"Hard"}"#),
            Ok(CsmsCall::Reset(_))
        ));
        assert!(StationCall::decode("DataTransfer", r#"{"vendorId":"x"}"#).is_ok());
        assert!(CsmsCall::decode("DataTransfer", r#"{"vendorId":"x"}"#).is_ok());
    }

    #[test]
    fn errata_celsius_and_optional_reason() {
        let p = r#"{"transactionId":1,"timestamp":"2024-01-01T00:00:00Z","meterStop":10,
            "transactionData":[{"timestamp":"2024-01-01T00:00:00Z","sampledValue":[{"value":"20","unit":"Celsius"},{"value":"20","unit":"Celcius"}]}]}"#;
        assert!(StationCall::decode("StopTransaction", p).is_ok());
    }

    #[test]
    fn multiple_of_constraint() {
        let mk = |limit: &str| {
            format!(
                r#"{{"connectorId":1,"csChargingProfiles":{{"chargingProfileId":1,"stackLevel":0,"chargingProfilePurpose":"TxDefaultProfile","chargingProfileKind":"Absolute",
                "chargingSchedule":{{"chargingRateUnit":"A","chargingSchedulePeriod":[{{"startPeriod":0,"limit":{limit}}}]}}}}}}"#
            )
        };
        assert!(CsmsCall::decode("SetChargingProfile", &mk("8.1")).is_ok());
        assert!(CsmsCall::decode("SetChargingProfile", &mk("8.15")).is_err());
    }

    #[test]
    fn response_roundtrip() {
        let r = BootNotificationResponse {
            status: RegistrationStatus::Accepted,
            current_time: ocpp_core::DateTime::parse("2013-02-01T20:53:32.486Z").unwrap(),
            interval: 300,
        };
        let s = serde_json::to_string(&r).unwrap();
        assert_eq!(
            s,
            r#"{"status":"Accepted","currentTime":"2013-02-01T20:53:32.486Z","interval":300}"#
        );
        assert!(validate_response(Action::BootNotification, &s).is_ok());
    }
}

/// Compiles and runs the examples of the README.
#[cfg(doctest)]
#[doc = include_str!("../README.md")]
pub struct ReadmeDoctests;