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)

//! Version-neutral view of a charging profile, used for the CSMS-side rules
//! and the composite schedule calculation. Each OCPP version converts its own
//! `ChargingProfile` type into [`Profile`] (see `smart::v16`, `v201`, `v21`).

use ocpp_core::DateTime;
use serde::Serialize;

#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize)]
pub enum Purpose {
    /// 1.6 `ChargePointMaxProfile`, 2.x `ChargingStationMaxProfile`.
    StationMax,
    TxDefault,
    Tx,
    /// 2.x `ChargingStationExternalConstraints` (set by an external system, never by the CSMS).
    ExternalConstraints,
    /// 2.1
    PriorityCharging,
    /// 2.1
    LocalGeneration,
}

impl Purpose {
    pub fn parse(s: &str) -> Option<Purpose> {
        Some(match s {
            "ChargePointMaxProfile" | "ChargingStationMaxProfile" => Purpose::StationMax,
            "TxDefaultProfile" => Purpose::TxDefault,
            "TxProfile" => Purpose::Tx,
            "ChargingStationExternalConstraints" => Purpose::ExternalConstraints,
            "PriorityCharging" => Purpose::PriorityCharging,
            "LocalGeneration" => Purpose::LocalGeneration,
            _ => return None,
        })
    }
}

#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
pub enum Kind {
    Absolute,
    Recurring,
    Relative,
    /// 2.1 (K28)
    Dynamic,
}

#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
pub enum Recurrency {
    Daily,
    Weekly,
}

impl Recurrency {
    pub fn seconds(self) -> i64 {
        match self {
            Recurrency::Daily => 86_400,
            Recurrency::Weekly => 7 * 86_400,
        }
    }
}

#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
pub enum Unit {
    A,
    W,
}

#[derive(Debug, Clone, PartialEq, Serialize)]
pub struct Period {
    /// Seconds from the start of the schedule.
    pub start: i64,
    /// `None` for 2.1 periods without a limit (e.g. setpoint-only).
    pub limit: Option<f64>,
    pub phases: Option<i64>,
}

#[derive(Debug, Clone, PartialEq, Serialize)]
pub struct Schedule {
    pub start: Option<DateTime>,
    pub duration: Option<i64>,
    pub unit: Unit,
    pub periods: Vec<Period>,
}

#[derive(Debug, Clone, PartialEq, Serialize)]
pub struct Profile {
    pub id: i64,
    /// 0 = whole station.
    pub evse: i64,
    pub purpose: Purpose,
    pub stack_level: i64,
    pub kind: Kind,
    pub recurrency: Option<Recurrency>,
    pub valid_from: Option<DateTime>,
    pub valid_to: Option<DateTime>,
    pub transaction: Option<String>,
    /// The schedule in effect. 2.x profiles may carry up to three alternatives
    /// for ISO 15118; the first one applies unless the EV selected another.
    pub schedule: Schedule,
    /// 2.1 dynamic profiles: when the last update was applied (K28).
    pub dyn_updated_at: Option<DateTime>,
}

impl Profile {
    pub fn valid_at(&self, t: DateTime) -> bool {
        self.valid_from.is_none_or(|f| f <= t) && self.valid_to.is_none_or(|to| t < to)
    }

    /// Validity windows overlap (`None` bounds are open).
    pub fn validity_overlaps(&self, other: &Profile) -> bool {
        let starts_before_other_ends = match (self.valid_from, other.valid_to) {
            (Some(a), Some(b)) => a < b,
            _ => true,
        };
        let other_starts_before_self_ends = match (other.valid_from, self.valid_to) {
            (Some(a), Some(b)) => a < b,
            _ => true,
        };
        starts_before_other_ends && other_starts_before_self_ends
    }
}

/// A rule violation detected before sending a profile to the station.
#[derive(Debug, Clone, PartialEq, Serialize)]
pub struct RuleViolation {
    /// Requirement identifier from the specification.
    pub rule: &'static str,
    pub message: String,
}

impl RuleViolation {
    pub fn new(rule: &'static str, message: impl Into<String>) -> Self {
        RuleViolation {
            rule,
            message: message.into(),
        }
    }
}

/// Structural checks shared by all versions.
pub fn check_structure(p: &Profile) -> Result<(), RuleViolation> {
    let periods = &p.schedule.periods;
    if periods.first().is_none_or(|f| f.start != 0) {
        // 1.6 ยง5.16 note; 2.x ChargingSchedulePeriodType.startPeriod.
        return Err(RuleViolation::new(
            "SchedulePeriodStart",
            "the first chargingSchedulePeriod must have startPeriod 0",
        ));
    }
    if periods.windows(2).any(|w| w[1].start <= w[0].start) {
        return Err(RuleViolation::new(
            "SchedulePeriodOrder",
            "chargingSchedulePeriod startPeriod values must be strictly increasing",
        ));
    }
    match p.kind {
        Kind::Absolute if p.schedule.start.is_none() => Err(RuleViolation::new(
            "ProfileKind",
            "an Absolute profile requires startSchedule",
        )),
        Kind::Recurring if p.recurrency.is_none() => Err(RuleViolation::new(
            "ProfileKind",
            "a Recurring profile requires recurrencyKind",
        )),
        Kind::Recurring if p.schedule.start.is_none() => Err(RuleViolation::new(
            "ProfileKind",
            "a Recurring profile requires startSchedule",
        )),
        Kind::Relative if p.schedule.start.is_some() => Err(RuleViolation::new(
            "ProfileKind",
            "a Relative profile must not have startSchedule",
        )),
        _ => Ok(()),
    }
    .and_then(|()| {
        if let (Some(f), Some(t)) = (p.valid_from, p.valid_to)
            && f >= t
        {
            return Err(RuleViolation::new(
                "Validity",
                "validFrom must be before validTo",
            ));
        }
        Ok(())
    })
    .and_then(|()| match p.purpose {
        Purpose::StationMax if p.evse != 0 => Err(RuleViolation::new(
            "StationMaxEvse",
            "a ChargingStationMaxProfile/ChargePointMaxProfile can only be set on EVSE/connector 0",
        )),
        Purpose::Tx if p.evse <= 0 => Err(RuleViolation::new(
            "TxProfileEvse",
            "a TxProfile can only be set on an EVSE/connector > 0",
        )),
        _ => Ok(()),
    })
}