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 version negotiation through the WebSocket subprotocol.
//!
//! The `Sec-WebSocket-Protocol` header is the single source of truth for the
//! OCPP version of a connection (OCPP-J 1.6 §3.1.2; 2.0.1/2.1 Part 4 §3.1.2:
//! "If the OCPP version is part of the OCPP-J endpoint URL it SHALL NOT
//! determine the OCPP version to use"). The client lists versions in order of
//! preference; the server answers with exactly one, or with none and then
//! closes the connection.

use std::fmt;
use std::str::FromStr;

#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord)]
pub enum OcppVersion {
    V16,
    V201,
    V21,
}

impl OcppVersion {
    pub const ALL: [OcppVersion; 3] = [OcppVersion::V16, OcppVersion::V201, OcppVersion::V21];

    /// IANA-registered WebSocket subprotocol name.
    pub const fn subprotocol(self) -> &'static str {
        match self {
            OcppVersion::V16 => "ocpp1.6",
            OcppVersion::V201 => "ocpp2.0.1",
            OcppVersion::V21 => "ocpp2.1",
        }
    }

    pub fn from_subprotocol(s: &str) -> Option<OcppVersion> {
        // Subprotocol tokens are case-sensitive per RFC 6455, but some
        // stations send e.g. "OCPP1.6"; accepting them is harmless.
        match s.trim().to_ascii_lowercase().as_str() {
            "ocpp1.6" => Some(OcppVersion::V16),
            "ocpp2.0.1" => Some(OcppVersion::V201),
            "ocpp2.1" => Some(OcppVersion::V21),
            _ => None,
        }
    }

    pub const fn dialect(self) -> crate::ErrorDialect {
        match self {
            OcppVersion::V16 => crate::ErrorDialect::Ocpp16,
            _ => crate::ErrorDialect::Ocpp2,
        }
    }

    /// Short identifier used in URLs, configuration and the database.
    pub const fn as_str(self) -> &'static str {
        match self {
            OcppVersion::V16 => "1.6",
            OcppVersion::V201 => "2.0.1",
            OcppVersion::V21 => "2.1",
        }
    }
}

impl fmt::Display for OcppVersion {
    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
        f.write_str(self.as_str())
    }
}

impl FromStr for OcppVersion {
    type Err = String;
    fn from_str(s: &str) -> Result<Self, Self::Err> {
        match s.trim() {
            "1.6" | "16" | "ocpp1.6" => Ok(OcppVersion::V16),
            "2.0.1" | "201" | "ocpp2.0.1" => Ok(OcppVersion::V201),
            "2.1" | "21" | "ocpp2.1" => Ok(OcppVersion::V21),
            other => Err(format!("unknown OCPP version '{other}'")),
        }
    }
}

/// Picks the first version offered by the client (in its preference order)
/// that is also acceptable to the server. Returns the version together with
/// the exact token the client sent, which must be echoed back verbatim.
pub fn negotiate<'a, I>(offered: I, acceptable: &[OcppVersion]) -> Option<(OcppVersion, &'a str)>
where
    I: IntoIterator<Item = &'a str>,
{
    offered
        .into_iter()
        .flat_map(|h| h.split(','))
        .map(str::trim)
        .filter(|t| !t.is_empty())
        .find_map(|t| {
            OcppVersion::from_subprotocol(t)
                .filter(|v| acceptable.contains(v))
                .map(|v| (v, t))
        })
}

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

    #[test]
    fn negotiation_respects_client_preference() {
        let all = OcppVersion::ALL;
        assert_eq!(
            negotiate(["ocpp2.1, ocpp2.0.1, ocpp1.6"], &all),
            Some((OcppVersion::V21, "ocpp2.1"))
        );
        assert_eq!(
            negotiate(["ocpp1.6, ocpp2.0.1"], &all),
            Some((OcppVersion::V16, "ocpp1.6"))
        );
        assert_eq!(
            negotiate(["ocpp2.1", "ocpp1.6"], &[OcppVersion::V16]),
            Some((OcppVersion::V16, "ocpp1.6"))
        );
        assert_eq!(negotiate(["ocpp1.5, ocpp1.8"], &all), None);
        assert_eq!(negotiate([], &all), None);
        assert_eq!(
            negotiate(["OCPP1.6"], &all),
            Some((OcppVersion::V16, "OCPP1.6"))
        );
    }
}