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)

//! OCPP 2.0.1 charging profiles (functional block K).

use ocpp201::{
    ChargingProfile, ChargingProfileKindEnum, ChargingRateUnitEnum, ChargingSchedule,
    RecurrencyKindEnum,
};
use serde_json::{Value, json};

use super::model::{
    self, Kind, Period, Profile, Purpose, Recurrency, RuleViolation, Schedule, Unit,
};

pub fn schedule(s: &ChargingSchedule) -> Schedule {
    Schedule {
        start: s.start_schedule,
        duration: s.duration,
        unit: match s.charging_rate_unit {
            ChargingRateUnitEnum::A => Unit::A,
            ChargingRateUnitEnum::W => Unit::W,
        },
        periods: s
            .charging_schedule_period
            .iter()
            .map(|p| Period {
                start: p.start_period,
                limit: Some(p.limit),
                phases: p.number_phases,
            })
            .collect(),
    }
}

pub fn normalize(evse_id: i64, p: &ChargingProfile) -> Profile {
    Profile {
        id: p.id,
        evse: evse_id,
        purpose: Purpose::parse(p.charging_profile_purpose.as_str())
            .expect("2.0.1 purposes are known"),
        stack_level: p.stack_level,
        kind: match p.charging_profile_kind {
            ChargingProfileKindEnum::Absolute => Kind::Absolute,
            ChargingProfileKindEnum::Recurring => Kind::Recurring,
            ChargingProfileKindEnum::Relative => Kind::Relative,
        },
        recurrency: p.recurrency_kind.map(|r| match r {
            RecurrencyKindEnum::Daily => Recurrency::Daily,
            RecurrencyKindEnum::Weekly => Recurrency::Weekly,
        }),
        valid_from: p.valid_from,
        valid_to: p.valid_to,
        transaction: p.transaction_id.clone(),
        schedule: schedule(&p.charging_schedule[0]),
        dyn_updated_at: None,
    }
}

pub fn decode(v: &Value) -> Result<ChargingProfile, String> {
    ocpp_core::decode_value(v.clone()).map_err(|e| e.to_string())
}

pub fn prepare(
    evse_id: i64,
    mut p: ChargingProfile,
    active_tx: Option<&str>,
) -> Result<(Profile, Value), RuleViolation> {
    let purpose = p.charging_profile_purpose.as_str();
    if purpose == "ChargingStationExternalConstraints" {
        return Err(RuleViolation::new(
            "K01.FR.22",
            "the CSMS must not set ChargingStationExternalConstraints profiles",
        ));
    }
    if purpose == "TxProfile" {
        let Some(active) = active_tx else {
            return Err(RuleViolation::new(
                "K01.FR.03",
                "a TxProfile requires an active transaction on the EVSE",
            ));
        };
        match &p.transaction_id {
            None => p.transaction_id = Some(active.to_owned()),
            Some(t) if t != active => {
                return Err(RuleViolation::new(
                    "K01.FR.03",
                    format!("transactionId {t} is not the active transaction {active} of the EVSE"),
                ));
            }
            Some(_) => {}
        }
    }
    for s in &p.charging_schedule {
        if s.charging_schedule_period
            .iter()
            .any(|per| per.phase_to_use.is_some() && per.number_phases != Some(1))
        {
            return Err(RuleViolation::new(
                "K01.FR.19",
                "phaseToUse may only be set when numberPhases is 1",
            ));
        }
    }
    let profile = normalize(evse_id, &p);
    model::check_structure(&profile)?;
    let payload = json!({ "evseId": evse_id, "chargingProfile": p });
    Ok((profile, payload))
}