codetics-ocpp-core 0.1.0

Codetics OCPP: version-independent OCPP-J building blocks (RPC framing, error codes, RFC 3339 date-time, validation, subprotocol negotiation, message ids)
Documentation
// SPDX-License-Identifier: Apache-2.0
// Copyright 2026 Kevin Vavelin, Codetics (https://codetics.fr)

//! OCPP-J RPC framing (OCPP-J 1.6 §4, OCPP 2.0.1/2.1 Part 4 §4).
//!
//! ```text
//! CALL            [2, "<MessageId>", "<Action>", {<Payload>}]
//! CALLRESULT      [3, "<MessageId>", {<Payload>}]
//! CALLERROR       [4, "<MessageId>", "<errorCode>", "<errorDescription>", {<errorDetails>}]
//! CALLRESULTERROR [5, "<MessageId>", "<errorCode>", "<errorDescription>", {<errorDetails>}]   (2.1)
//! SEND            [6, "<MessageId>", "<Action>", {<Payload>}]                                 (2.1)
//! ```

use serde::Serialize;
use serde_json::value::RawValue;

use crate::{ErrorCode, ErrorDialect, RpcError};

/// Maximum message id length, to allow for UUIDs/GUIDs.
pub const MAX_MESSAGE_ID_LEN: usize = 36;
/// Maximum CALLERROR description length (2.0.1 Part 4 §4.2.3).
pub const MAX_ERROR_DESCRIPTION_LEN: usize = 255;
/// Message id to use in a CALLERROR when the id of the offending message cannot be read.
pub const UNKNOWN_MESSAGE_ID: &str = "-1";

#[derive(Debug, Clone, Copy, PartialEq, Eq)]
#[repr(u8)]
pub enum MessageTypeId {
    Call = 2,
    CallResult = 3,
    CallError = 4,
    CallResultError = 5,
    Send = 6,
}

#[derive(Debug)]
pub enum Frame {
    Call {
        id: String,
        action: String,
        payload: Box<RawValue>,
    },
    CallResult {
        id: String,
        payload: Box<RawValue>,
    },
    CallError {
        id: String,
        code: String,
        description: String,
        details: Box<RawValue>,
    },
    CallResultError {
        id: String,
        code: String,
        description: String,
        details: Box<RawValue>,
    },
    Send {
        id: String,
        action: String,
        payload: Box<RawValue>,
    },
}

/// A frame that could not be parsed.
#[derive(Debug, Clone, PartialEq)]
pub enum FrameError {
    /// The text is not valid JSON, not an array, or the message id is unreadable.
    Malformed { id: Option<String>, reason: String },
    /// Valid envelope but a message type number this implementation does not know.
    UnknownMessageType { id: Option<String>, type_id: u64 },
}

impl FrameError {
    /// Message id to echo in the CALLERROR: the original id if readable, otherwise `"-1"`.
    pub fn reply_id(&self) -> &str {
        match self {
            FrameError::Malformed { id, .. } | FrameError::UnknownMessageType { id, .. } => {
                id.as_deref().unwrap_or(UNKNOWN_MESSAGE_ID)
            }
        }
    }
}

impl std::fmt::Display for FrameError {
    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
        match self {
            FrameError::Malformed { reason, .. } => write!(f, "malformed OCPP-J frame: {reason}"),
            FrameError::UnknownMessageType { type_id, .. } => {
                write!(f, "unknown message type id {type_id}")
            }
        }
    }
}

impl std::error::Error for FrameError {}

fn empty_object() -> Box<RawValue> {
    RawValue::from_string("{}".to_owned()).expect("valid json")
}

/// `null` payloads are tolerated and normalized to `{}` (OCPP-J 1.6 §4.2.1).
fn payload(raw: &RawValue) -> Box<RawValue> {
    if raw.get() == "null" {
        empty_object()
    } else {
        raw.to_owned()
    }
}

fn string(raw: &RawValue) -> Option<String> {
    serde_json::from_str::<String>(raw.get()).ok()
}

impl Frame {
    pub fn parse(text: &str) -> Result<Frame, FrameError> {
        let parts: Vec<&RawValue> =
            serde_json::from_str(text).map_err(|e| FrameError::Malformed {
                id: None,
                reason: format!("not a JSON array: {e}"),
            })?;
        let type_id: u64 = parts
            .first()
            .and_then(|r| serde_json::from_str(r.get()).ok())
            .ok_or_else(|| FrameError::Malformed {
                id: None,
                reason: "missing or invalid MessageTypeId".into(),
            })?;
        let id = parts.get(1).and_then(|r| string(r));
        let Some(id_str) = id.clone() else {
            return Err(FrameError::Malformed {
                id: None,
                reason: "missing or invalid MessageId".into(),
            });
        };
        let bad = |reason: &str| FrameError::Malformed {
            id: id.clone(),
            reason: reason.into(),
        };
        let check_object = |r: &RawValue| -> Result<Box<RawValue>, FrameError> {
            let p = payload(r);
            if p.get().trim_start().starts_with('{') {
                Ok(p)
            } else {
                Err(bad("payload is not a JSON object"))
            }
        };
        match type_id {
            2 | 6 => {
                if parts.len() != 4 {
                    return Err(bad("CALL/SEND must have 4 elements"));
                }
                let action = string(parts[2]).ok_or_else(|| bad("Action is not a string"))?;
                let payload = check_object(parts[3])?;
                Ok(if type_id == 2 {
                    Frame::Call {
                        id: id_str,
                        action,
                        payload,
                    }
                } else {
                    Frame::Send {
                        id: id_str,
                        action,
                        payload,
                    }
                })
            }
            3 => {
                if parts.len() != 3 {
                    return Err(bad("CALLRESULT must have 3 elements"));
                }
                Ok(Frame::CallResult {
                    id: id_str,
                    payload: check_object(parts[2])?,
                })
            }
            4 | 5 => {
                // Be lenient with peers that omit errorDetails.
                if !(4..=5).contains(&parts.len()) {
                    return Err(bad("CALLERROR must have 5 elements"));
                }
                let code = string(parts[2]).ok_or_else(|| bad("errorCode is not a string"))?;
                let description = string(parts[3]).unwrap_or_default();
                let details = parts
                    .get(4)
                    .map(|r| payload(r))
                    .unwrap_or_else(empty_object);
                Ok(if type_id == 4 {
                    Frame::CallError {
                        id: id_str,
                        code,
                        description,
                        details,
                    }
                } else {
                    Frame::CallResultError {
                        id: id_str,
                        code,
                        description,
                        details,
                    }
                })
            }
            other => Err(FrameError::UnknownMessageType { id, type_id: other }),
        }
    }

    pub fn id(&self) -> &str {
        match self {
            Frame::Call { id, .. }
            | Frame::CallResult { id, .. }
            | Frame::CallError { id, .. }
            | Frame::CallResultError { id, .. }
            | Frame::Send { id, .. } => id,
        }
    }
}

/// Serializes `[2, id, action, payload]`.
pub fn encode_call<P: Serialize + ?Sized>(
    id: &str,
    action: &str,
    payload: &P,
) -> Result<String, serde_json::Error> {
    serde_json::to_string(&(MessageTypeId::Call as u8, id, action, payload))
}

/// Serializes `[6, id, action, payload]` (2.1 SEND).
pub fn encode_send<P: Serialize + ?Sized>(
    id: &str,
    action: &str,
    payload: &P,
) -> Result<String, serde_json::Error> {
    serde_json::to_string(&(MessageTypeId::Send as u8, id, action, payload))
}

/// Serializes `[3, id, payload]`.
pub fn encode_result<P: Serialize + ?Sized>(
    id: &str,
    payload: &P,
) -> Result<String, serde_json::Error> {
    serde_json::to_string(&(MessageTypeId::CallResult as u8, id, payload))
}

/// Serializes a CALLERROR (`[4, …]`) or, with `result_error`, a CALLRESULTERROR (`[5, …]`).
pub fn encode_error(id: &str, err: &RpcError, dialect: ErrorDialect, result_error: bool) -> String {
    let type_id = if result_error {
        MessageTypeId::CallResultError
    } else {
        MessageTypeId::CallError
    } as u8;
    let mut description = err.description.as_str();
    if description.chars().count() > MAX_ERROR_DESCRIPTION_LEN {
        let cut = description
            .char_indices()
            .nth(MAX_ERROR_DESCRIPTION_LEN)
            .map(|(i, _)| i)
            .unwrap_or(description.len());
        description = &description[..cut];
    }
    // errorDetails MUST be an object; `{}` when there are none.
    let empty = serde_json::Value::Object(Default::default());
    let details = if err.details.is_object() {
        &err.details
    } else {
        &empty
    };
    serde_json::to_string(&(type_id, id, err.code.as_wire(dialect), description, details))
        .expect("error frame serializes")
}

/// Converts a received CALLERROR into an [`RpcError`].
pub fn error_from_frame(code: &str, description: String, details: &RawValue) -> RpcError {
    RpcError {
        code: ErrorCode::from_wire(code),
        description,
        details: serde_json::from_str(details.get()).unwrap_or_default(),
    }
}

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

    #[test]
    fn parse_call() {
        let f = Frame::parse(r#"[2, "19223201", "BootNotification", {"chargePointVendor": "VendorX", "chargePointModel": "X"}]"#).unwrap();
        match f {
            Frame::Call {
                id,
                action,
                payload,
            } => {
                assert_eq!(id, "19223201");
                assert_eq!(action, "BootNotification");
                assert!(payload.get().contains("VendorX"));
            }
            _ => panic!(),
        }
        let f = Frame::parse(r#"[2,"1","Heartbeat",null]"#).unwrap();
        assert!(matches!(f, Frame::Call { ref payload, .. } if payload.get() == "{}"));
    }

    #[test]
    fn parse_result_and_error() {
        assert!(matches!(
            Frame::parse(r#"[3,"a",{"status":"Accepted"}]"#).unwrap(),
            Frame::CallResult { .. }
        ));
        match Frame::parse(
            r#"[4,"162376037","NotSupported","SetDisplayMessageRequest not supported",{}]"#,
        )
        .unwrap()
        {
            Frame::CallError {
                code, description, ..
            } => {
                assert_eq!(code, "NotSupported");
                assert_eq!(description, "SetDisplayMessageRequest not supported");
            }
            _ => panic!(),
        }
        assert!(matches!(
            Frame::parse(r#"[5,"a","FormatViolation","",{}]"#).unwrap(),
            Frame::CallResultError { .. }
        ));
        assert!(matches!(
            Frame::parse(r#"[6,"a","NotifyPeriodicEventStream",{}]"#).unwrap(),
            Frame::Send { .. }
        ));
    }

    #[test]
    fn parse_errors() {
        assert_eq!(Frame::parse("garbage").unwrap_err().reply_id(), "-1");
        assert_eq!(Frame::parse("[2]").unwrap_err().reply_id(), "-1");
        assert_eq!(
            Frame::parse(r#"[2,"abc","X"]"#).unwrap_err().reply_id(),
            "abc"
        );
        assert_eq!(
            Frame::parse(r#"[2,"abc","X",[]]"#).unwrap_err().reply_id(),
            "abc"
        );
        assert_eq!(
            Frame::parse(r#"[9,"abc"]"#).unwrap_err(),
            FrameError::UnknownMessageType {
                id: Some("abc".into()),
                type_id: 9
            }
        );
    }

    #[test]
    fn encode() {
        assert_eq!(
            encode_call("1", "Heartbeat", &serde_json::json!({})).unwrap(),
            r#"[2,"1","Heartbeat",{}]"#
        );
        assert_eq!(
            encode_result("1", &serde_json::json!({"a":1})).unwrap(),
            r#"[3,"1",{"a":1}]"#
        );
        let e = RpcError::new(ErrorCode::FormatViolation, "bad");
        assert_eq!(
            encode_error("1", &e, ErrorDialect::Ocpp16, false),
            r#"[4,"1","FormationViolation","bad",{}]"#
        );
        assert_eq!(
            encode_error("1", &e, ErrorDialect::Ocpp2, true),
            r#"[5,"1","FormatViolation","bad",{}]"#
        );
        let long = RpcError::new(ErrorCode::GenericError, "x".repeat(400));
        let s = encode_error("1", &long, ErrorDialect::Ocpp2, false);
        assert!(s.len() < 300);
    }
}