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)

//! Smart charging endpoints.

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

use super::stations::load;
use crate::calls;
use crate::error::AppError;
use crate::smart::{self, model::Unit};
use crate::state::App;

#[derive(Debug, Deserialize)]
pub struct SetProfile {
    /// 2.x evseId / 1.6 connectorId; 0 = whole station.
    pub evse_id: i64,
    /// ChargingProfile in the format of the station's OCPP version.
    pub charging_profile: Value,
}

pub async fn set(
    State(app): State<App>,
    Path(identity): Path<String>,
    Json(body): Json<SetProfile>,
) -> Result<Json<Value>, AppError> {
    let version = calls::connected_version(&app, &identity).await?;
    let station = load(&app, &identity).await?;
    let outcome =
        smart::install(&app, &station, version, body.evse_id, body.charging_profile).await?;
    Ok(Json(json!(outcome)))
}

#[derive(Debug, Deserialize)]
pub struct ListQuery {
    /// Pending, Installed, Rejected or Cleared (default: all).
    pub status: Option<String>,
}

pub async fn list(
    State(app): State<App>,
    Path(identity): Path<String>,
    Query(q): Query<ListQuery>,
) -> Result<Json<Value>, AppError> {
    let station = load(&app, &identity).await?;
    Ok(Json(json!(
        crate::db::charging::list(&app.db, station.id, q.status.as_deref()).await?
    )))
}

pub async fn clear(
    State(app): State<App>,
    Path(identity): Path<String>,
    Json(req): Json<smart::ClearRequest>,
) -> Result<Json<Value>, AppError> {
    let version = calls::connected_version(&app, &identity).await?;
    let station = load(&app, &identity).await?;
    Ok(Json(
        json!({ "response": smart::clear(&app, &station, version, &req).await? }),
    ))
}

#[derive(Debug, Deserialize, Default)]
#[serde(default)]
pub struct SyncRequest {
    pub evse_id: Option<i64>,
    pub purpose: Option<String>,
    pub stack_level: Option<i64>,
    /// ChargingLimitSource values; default: all sources.
    pub sources: Option<Vec<String>>,
}

/// OCPP 2.x K09: asks the station to report its charging profiles; the
/// ReportChargingProfiles messages update the registry.
pub async fn sync(
    State(app): State<App>,
    Path(identity): Path<String>,
    body: Option<Json<SyncRequest>>,
) -> Result<Json<Value>, AppError> {
    let version = calls::connected_version(&app, &identity).await?;
    if version == OcppVersion::V16 {
        return Err(AppError::BadRequest(
            "OCPP 1.6 has no GetChargingProfiles message".into(),
        ));
    }
    let r = body.map(|b| b.0).unwrap_or_default();
    let request_id = (DateTime::now().unix_micros() / 1000 % i32::MAX as i64) as i32;
    let mut criterion = json!({ "chargingProfilePurpose": r.purpose, "stackLevel": r.stack_level, "chargingLimitSource": r.sources });
    if criterion
        .as_object()
        .is_some_and(|o| o.values().all(Value::is_null))
    {
        // K09.FR.03 requires at least one criterion: all sources.
        criterion = json!({ "chargingLimitSource": ["EMS", "Other", "SO", "CSO"] });
    }
    let payload = smart::strip_nulls(
        json!({ "requestId": request_id, "evseId": r.evse_id, "chargingProfile": criterion }),
    );
    let response = calls::call(&app, &identity, version, "GetChargingProfiles", payload).await?;
    Ok(Json(
        json!({ "request_id": request_id, "response": response }),
    ))
}

#[derive(Debug, Deserialize)]
pub struct CompositeQuery {
    pub evse_id: i64,
    /// Seconds, default 86400.
    pub duration: Option<i64>,
    /// `A` or `W` (default W).
    pub unit: Option<String>,
    /// `csms` (computed from the registry, default) or `station` (GetCompositeSchedule).
    pub source: Option<String>,
}

pub async fn composite(
    State(app): State<App>,
    Path(identity): Path<String>,
    Query(q): Query<CompositeQuery>,
) -> Result<Json<Value>, AppError> {
    let duration = q.duration.unwrap_or(86_400).clamp(1, 7 * 86_400);
    let unit = match q.unit.as_deref().unwrap_or("W") {
        "A" => Unit::A,
        "W" => Unit::W,
        other => {
            return Err(AppError::BadRequest(format!(
                "unit must be A or W, not {other}"
            )));
        }
    };
    let unit_str = if unit == Unit::A { "A" } else { "W" };
    if q.source.as_deref() == Some("station") {
        let version = calls::connected_version(&app, &identity).await?;
        let payload = match version {
            OcppVersion::V16 => {
                json!({ "connectorId": q.evse_id, "duration": duration, "chargingRateUnit": unit_str })
            }
            _ => json!({ "evseId": q.evse_id, "duration": duration, "chargingRateUnit": unit_str }),
        };
        return Ok(Json(
            json!({ "source": "station", "response": calls::call(&app, &identity, version, "GetCompositeSchedule", payload).await? }),
        ));
    }
    let station = load(&app, &identity).await?;
    let start = DateTime::now();
    let periods = smart::composite_for(&app, &station, q.evse_id, start, duration, unit).await?;
    Ok(Json(json!({
        "source": "csms",
        "evse_id": q.evse_id,
        "schedule_start": start,
        "duration": duration,
        "charging_rate_unit": unit_str,
        "charging_schedule_period": periods,
    })))
}

#[derive(Debug, Deserialize)]
pub struct DynamicUpdate {
    /// ChargingScheduleUpdateType (OCPP 2.1).
    pub schedule_update: Value,
    /// Send UpdateDynamicSchedule now (default). With `false` the values are only
    /// stored and returned when the station pulls (PullDynamicScheduleUpdate).
    #[serde(default = "yes")]
    pub push: bool,
}

fn yes() -> bool {
    true
}

pub async fn dynamic_update(
    State(app): State<App>,
    Path((identity, profile_id)): Path<(String, i64)>,
    Json(body): Json<DynamicUpdate>,
) -> Result<Json<Value>, AppError> {
    let station = load(&app, &identity).await?;
    let version = if body.push {
        calls::connected_version(&app, &identity).await?
    } else {
        station
            .ocpp_version
            .as_deref()
            .unwrap_or("2.1")
            .parse()
            .map_err(AppError::BadRequest)?
    };
    Ok(Json(
        smart::dynamic_update(
            &app,
            &station,
            version,
            profile_id,
            body.schedule_update,
            body.push,
        )
        .await?,
    ))
}

pub async fn limits(
    State(app): State<App>,
    Path(identity): Path<String>,
) -> Result<Json<Value>, AppError> {
    let station = load(&app, &identity).await?;
    Ok(Json(json!(
        crate::db::charging::external_limits(&app.db, station.id).await?
    )))
}