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.1 charging profiles (functional block K, including dynamic and
//! priority charging profiles).

use ocpp21::{
    ChargingProfile, ChargingProfileKindEnum, ChargingRateUnitEnum, ChargingSchedule,
    ChargingScheduleUpdate, 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: p.limit,
                phases: p.number_phases,
            })
            .collect(),
    }
}

/// `update`: latest dynamic schedule update (K28), applied to the single period.
pub fn normalize(
    evse_id: i64,
    p: &ChargingProfile,
    update: Option<(&ChargingScheduleUpdate, ocpp_core::DateTime)>,
) -> Profile {
    let mut schedule = schedule(&p.charging_schedule[0]);
    let mut dyn_updated_at = p.dyn_update_time;
    if let Some((u, at)) = update
        && let Some(first) = schedule.periods.first_mut()
    {
        if u.limit.is_some() {
            first.limit = u.limit;
        }
        dyn_updated_at = Some(at);
    }
    Profile {
        id: p.id,
        evse: evse_id,
        purpose: Purpose::parse(p.charging_profile_purpose.as_str())
            .expect("2.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,
            ChargingProfileKindEnum::Dynamic => Kind::Dynamic,
        },
        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,
        dyn_updated_at,
    }
}

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(_) => {}
        }
    }
    let dynamic = p.charging_profile_kind == ChargingProfileKindEnum::Dynamic;
    if dynamic
        && (p.charging_schedule.len() != 1
            || p.charging_schedule[0].charging_schedule_period.len() != 1)
    {
        return Err(RuleViolation::new(
            "K28.FR.01",
            "a Dynamic profile has exactly one chargingSchedule with one chargingSchedulePeriod",
        ));
    }
    if !dynamic && p.dyn_update_interval.is_some() {
        return Err(RuleViolation::new(
            "K28.FR.04",
            "dynUpdateInterval only applies to Dynamic profiles",
        ));
    }
    for s in &p.charging_schedule {
        if purpose == "PriorityCharging" && s.duration.is_some() {
            return Err(RuleViolation::new(
                "K01.FR.73",
                "a PriorityCharging schedule must not have a duration",
            ));
        }
        for per in &s.charging_schedule_period {
            if 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 has_l23 = per.limit_l2.is_some() || per.limit_l3.is_some();
            if has_l23 && purpose != "TxProfile" {
                return Err(RuleViolation::new(
                    "K01.FR.143",
                    "limit_L2/limit_L3 are only allowed in a TxProfile",
                ));
            }
            if has_l23 && per.limit.is_none() {
                return Err(RuleViolation::new(
                    "K01.FR.145",
                    "limit_L2/limit_L3 require limit (phase L1)",
                ));
            }
        }
    }
    let profile = normalize(evse_id, &p, None);
    model::check_structure(&profile)?;
    let payload = json!({ "evseId": evse_id, "chargingProfile": p });
    Ok((profile, payload))
}