codetics-ocpp-service 0.1.0

Codetics OCPP: Charging Station Management System (CSMS) service for OCPP 1.6J, 2.0.1 and 2.1, with TLS security profiles, smart charging, reservations and tariffs
Documentation
// SPDX-License-Identifier: Apache-2.0
// Copyright 2026 Kevin Vavelin, Codetics (https://codetics.fr)

//! Sending commands to charging stations.
//!
//! - `POST /stations/{identity}/ocpp/{Action}`: any CSMS-initiated action of the
//!   version negotiated on the station's connection; the body is the OCPP payload.
//! - `POST /stations/{identity}/commands/{command}`: version-independent
//!   commands translated to the right message for 1.6, 2.0.1 or 2.1.

use axum::Json;
use axum::extract::{Path, State};
use ocpp_core::OcppVersion;
use serde::Deserialize;
use serde_json::{Value, json};

use crate::error::AppError;
#[cfg(test)]
use crate::ocpp::{v16, v21, v201};
use crate::state::App;

async fn execute(
    app: &App,
    identity: &str,
    version: OcppVersion,
    action: &str,
    payload: Value,
) -> Result<Json<Value>, AppError> {
    let response = crate::calls::call(app, identity, version, action, payload).await?;
    Ok(Json(
        json!({ "ocpp_version": version.to_string(), "action": action, "response": response }),
    ))
}

pub async fn raw(
    State(app): State<App>,
    Path((identity, action)): Path<(String, String)>,
    Json(payload): Json<Value>,
) -> Result<Json<Value>, AppError> {
    let version = crate::calls::connected_version(&app, &identity).await?;
    execute(&app, &identity, version, &action, payload).await
}

#[derive(Debug, Deserialize, Default)]
#[serde(default)]
pub struct GenericCommand {
    /// reset: `Hard`/`Immediate` or `Soft`/`OnIdle`.
    pub r#type: Option<String>,
    pub evse_id: Option<i64>,
    pub connector_id: Option<i64>,
    /// remote-start: token to authorize the session with.
    pub id_token: Option<String>,
    /// remote-start (2.x): token type, default `Central`.
    pub id_token_type: Option<String>,
    /// remote-start (2.x): id echoed in TransactionEvent; generated if absent.
    pub remote_start_id: Option<i64>,
    /// remote-stop: transaction id (1.6 integer as string, or 2.x transaction id).
    pub transaction_id: Option<String>,
    /// change-availability
    pub operative: Option<bool>,
    /// trigger-message: e.g. `BootNotification`, `Heartbeat`, `StatusNotification`, `MeterValues`.
    pub message: Option<String>,
    /// remote-start: TxProfile (without transactionId) in the station's version format
    /// (1.6 ยง5.16.2, 2.x K05).
    pub charging_profile: Option<Value>,
}

fn required<T>(v: Option<T>, name: &str) -> Result<T, AppError> {
    v.ok_or_else(|| AppError::BadRequest(format!("'{name}' is required")))
}

/// Translates a version-independent command into (action, payload).
fn translate(
    command: &str,
    version: OcppVersion,
    c: GenericCommand,
) -> Result<(&'static str, Value), AppError> {
    let v16 = version == OcppVersion::V16;
    Ok(match command {
        "reset" => {
            let hard = match c.r#type.as_deref().unwrap_or("Soft") {
                "Hard" | "Immediate" => true,
                "Soft" | "OnIdle" => false,
                other => {
                    return Err(AppError::BadRequest(format!(
                        "unknown reset type '{other}'"
                    )));
                }
            };
            if v16 {
                (
                    "Reset",
                    json!({ "type": if hard { "Hard" } else { "Soft" } }),
                )
            } else {
                let mut p = json!({ "type": if hard { "Immediate" } else { "OnIdle" } });
                if let Some(e) = c.evse_id {
                    p["evseId"] = json!(e);
                }
                ("Reset", p)
            }
        }
        "remote-start" => {
            let token = required(c.id_token, "id_token")?;
            // 1.6 ยง5.16.2 / 2.x K05: a TxProfile without transactionId (assigned when the transaction starts).
            if let Some(cp) = &c.charging_profile
                && (cp.get("chargingProfilePurpose").and_then(Value::as_str) != Some("TxProfile")
                    || cp.get("transactionId").is_some())
            {
                return Err(AppError::BadRequest(
                    "charging_profile of a remote start must be a TxProfile without transactionId"
                        .into(),
                ));
            }
            if v16 {
                let mut p = json!({ "idTag": token });
                if let Some(e) = c.evse_id {
                    p["connectorId"] = json!(e);
                }
                if let Some(cp) = c.charging_profile {
                    p["chargingProfile"] = cp;
                }
                ("RemoteStartTransaction", p)
            } else {
                let remote_start_id = c.remote_start_id.unwrap_or_else(|| {
                    (ocpp_core::DateTime::now().unix_micros() % i32::MAX as i64).abs()
                });
                let mut p = json!({
                    "idToken": { "idToken": token, "type": c.id_token_type.unwrap_or_else(|| "Central".into()) },
                    "remoteStartId": remote_start_id,
                });
                if let Some(e) = c.evse_id {
                    p["evseId"] = json!(e);
                }
                if let Some(cp) = c.charging_profile {
                    p["chargingProfile"] = cp;
                }
                ("RequestStartTransaction", p)
            }
        }
        "remote-stop" => {
            let tx = required(c.transaction_id, "transaction_id")?;
            if v16 {
                let id: i64 = tx.parse().map_err(|_| {
                    AppError::BadRequest("OCPP 1.6 transaction_id must be an integer".into())
                })?;
                ("RemoteStopTransaction", json!({ "transactionId": id }))
            } else {
                ("RequestStopTransaction", json!({ "transactionId": tx }))
            }
        }
        "unlock-connector" => {
            let evse = required(c.evse_id, "evse_id")?;
            if v16 {
                ("UnlockConnector", json!({ "connectorId": evse }))
            } else {
                (
                    "UnlockConnector",
                    json!({ "evseId": evse, "connectorId": c.connector_id.unwrap_or(1) }),
                )
            }
        }
        "change-availability" => {
            let operative = required(c.operative, "operative")?;
            let status = if operative {
                "Operative"
            } else {
                "Inoperative"
            };
            if v16 {
                (
                    "ChangeAvailability",
                    json!({ "connectorId": c.evse_id.unwrap_or(0), "type": status }),
                )
            } else {
                let mut p = json!({ "operationalStatus": status });
                if let Some(e) = c.evse_id {
                    p["evse"] = json!({ "id": e });
                    if let Some(conn) = c.connector_id {
                        p["evse"]["connectorId"] = json!(conn);
                    }
                }
                ("ChangeAvailability", p)
            }
        }
        "trigger-message" => {
            let msg = required(c.message, "message")?;
            let mut p = json!({ "requestedMessage": msg });
            if let Some(e) = c.evse_id {
                if v16 {
                    p["connectorId"] = json!(e);
                } else {
                    p["evse"] = json!({ "id": e });
                }
            }
            ("TriggerMessage", p)
        }
        other => return Err(AppError::NotFound(format!("unknown command '{other}'"))),
    })
}

pub async fn generic(
    State(app): State<App>,
    Path((identity, command)): Path<(String, String)>,
    body: Option<Json<GenericCommand>>,
) -> Result<Json<Value>, AppError> {
    let version = crate::calls::connected_version(&app, &identity).await?;
    let (action, payload) = translate(&command, version, body.map(|b| b.0).unwrap_or_default())?;
    execute(&app, &identity, version, action, payload).await
}

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

    #[test]
    fn translations_validate_for_each_version() {
        let cases: Vec<(&str, GenericCommand)> = vec![
            (
                "reset",
                GenericCommand {
                    r#type: Some("Hard".into()),
                    ..Default::default()
                },
            ),
            (
                "remote-start",
                GenericCommand {
                    id_token: Some("TAG1".into()),
                    evse_id: Some(1),
                    ..Default::default()
                },
            ),
            (
                "remote-stop",
                GenericCommand {
                    transaction_id: Some("42".into()),
                    ..Default::default()
                },
            ),
            (
                "unlock-connector",
                GenericCommand {
                    evse_id: Some(1),
                    ..Default::default()
                },
            ),
            (
                "change-availability",
                GenericCommand {
                    operative: Some(false),
                    evse_id: Some(2),
                    ..Default::default()
                },
            ),
            (
                "trigger-message",
                GenericCommand {
                    message: Some("StatusNotification".into()),
                    evse_id: Some(1),
                    ..Default::default()
                },
            ),
        ];
        for version in OcppVersion::ALL {
            for (cmd, c) in &cases {
                let c = GenericCommand { ..serde_json::from_value(serde_json::to_value(json!({
                    "type": c.r#type, "evse_id": c.evse_id, "id_token": c.id_token, "transaction_id": c.transaction_id,
                    "operative": c.operative, "message": c.message,
                })).unwrap()).unwrap() };
                let (action, payload) = translate(cmd, version, c).unwrap();
                let ok = match version {
                    OcppVersion::V16 => v16::validate_command(action, payload.clone()),
                    OcppVersion::V201 => v201::validate_command(action, payload.clone()),
                    OcppVersion::V21 => v21::validate_command(action, payload.clone()),
                };
                assert!(ok.is_ok(), "{cmd} {version}: {payload} -> {ok:?}");
            }
        }
    }
}