Skip to main content

ocpp_service/smart/
model.rs

1// SPDX-License-Identifier: Apache-2.0
2// Copyright 2026 Kevin Vavelin, Codetics (https://codetics.fr)
3
4//! Version-neutral view of a charging profile, used for the CSMS-side rules
5//! and the composite schedule calculation. Each OCPP version converts its own
6//! `ChargingProfile` type into [`Profile`] (see `smart::v16`, `v201`, `v21`).
7
8use ocpp_core::DateTime;
9use serde::Serialize;
10
11#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize)]
12pub enum Purpose {
13    /// 1.6 `ChargePointMaxProfile`, 2.x `ChargingStationMaxProfile`.
14    StationMax,
15    TxDefault,
16    Tx,
17    /// 2.x `ChargingStationExternalConstraints` (set by an external system, never by the CSMS).
18    ExternalConstraints,
19    /// 2.1
20    PriorityCharging,
21    /// 2.1
22    LocalGeneration,
23}
24
25impl Purpose {
26    pub fn parse(s: &str) -> Option<Purpose> {
27        Some(match s {
28            "ChargePointMaxProfile" | "ChargingStationMaxProfile" => Purpose::StationMax,
29            "TxDefaultProfile" => Purpose::TxDefault,
30            "TxProfile" => Purpose::Tx,
31            "ChargingStationExternalConstraints" => Purpose::ExternalConstraints,
32            "PriorityCharging" => Purpose::PriorityCharging,
33            "LocalGeneration" => Purpose::LocalGeneration,
34            _ => return None,
35        })
36    }
37}
38
39#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
40pub enum Kind {
41    Absolute,
42    Recurring,
43    Relative,
44    /// 2.1 (K28)
45    Dynamic,
46}
47
48#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
49pub enum Recurrency {
50    Daily,
51    Weekly,
52}
53
54impl Recurrency {
55    pub fn seconds(self) -> i64 {
56        match self {
57            Recurrency::Daily => 86_400,
58            Recurrency::Weekly => 7 * 86_400,
59        }
60    }
61}
62
63#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
64pub enum Unit {
65    A,
66    W,
67}
68
69#[derive(Debug, Clone, PartialEq, Serialize)]
70pub struct Period {
71    /// Seconds from the start of the schedule.
72    pub start: i64,
73    /// `None` for 2.1 periods without a limit (e.g. setpoint-only).
74    pub limit: Option<f64>,
75    pub phases: Option<i64>,
76}
77
78#[derive(Debug, Clone, PartialEq, Serialize)]
79pub struct Schedule {
80    pub start: Option<DateTime>,
81    pub duration: Option<i64>,
82    pub unit: Unit,
83    pub periods: Vec<Period>,
84}
85
86#[derive(Debug, Clone, PartialEq, Serialize)]
87pub struct Profile {
88    pub id: i64,
89    /// 0 = whole station.
90    pub evse: i64,
91    pub purpose: Purpose,
92    pub stack_level: i64,
93    pub kind: Kind,
94    pub recurrency: Option<Recurrency>,
95    pub valid_from: Option<DateTime>,
96    pub valid_to: Option<DateTime>,
97    pub transaction: Option<String>,
98    /// The schedule in effect. 2.x profiles may carry up to three alternatives
99    /// for ISO 15118; the first one applies unless the EV selected another.
100    pub schedule: Schedule,
101    /// 2.1 dynamic profiles: when the last update was applied (K28).
102    pub dyn_updated_at: Option<DateTime>,
103}
104
105impl Profile {
106    pub fn valid_at(&self, t: DateTime) -> bool {
107        self.valid_from.is_none_or(|f| f <= t) && self.valid_to.is_none_or(|to| t < to)
108    }
109
110    /// Validity windows overlap (`None` bounds are open).
111    pub fn validity_overlaps(&self, other: &Profile) -> bool {
112        let starts_before_other_ends = match (self.valid_from, other.valid_to) {
113            (Some(a), Some(b)) => a < b,
114            _ => true,
115        };
116        let other_starts_before_self_ends = match (other.valid_from, self.valid_to) {
117            (Some(a), Some(b)) => a < b,
118            _ => true,
119        };
120        starts_before_other_ends && other_starts_before_self_ends
121    }
122}
123
124/// A rule violation detected before sending a profile to the station.
125#[derive(Debug, Clone, PartialEq, Serialize)]
126pub struct RuleViolation {
127    /// Requirement identifier from the specification.
128    pub rule: &'static str,
129    pub message: String,
130}
131
132impl RuleViolation {
133    pub fn new(rule: &'static str, message: impl Into<String>) -> Self {
134        RuleViolation {
135            rule,
136            message: message.into(),
137        }
138    }
139}
140
141/// Structural checks shared by all versions.
142pub fn check_structure(p: &Profile) -> Result<(), RuleViolation> {
143    let periods = &p.schedule.periods;
144    if periods.first().is_none_or(|f| f.start != 0) {
145        // 1.6 ยง5.16 note; 2.x ChargingSchedulePeriodType.startPeriod.
146        return Err(RuleViolation::new(
147            "SchedulePeriodStart",
148            "the first chargingSchedulePeriod must have startPeriod 0",
149        ));
150    }
151    if periods.windows(2).any(|w| w[1].start <= w[0].start) {
152        return Err(RuleViolation::new(
153            "SchedulePeriodOrder",
154            "chargingSchedulePeriod startPeriod values must be strictly increasing",
155        ));
156    }
157    match p.kind {
158        Kind::Absolute if p.schedule.start.is_none() => Err(RuleViolation::new(
159            "ProfileKind",
160            "an Absolute profile requires startSchedule",
161        )),
162        Kind::Recurring if p.recurrency.is_none() => Err(RuleViolation::new(
163            "ProfileKind",
164            "a Recurring profile requires recurrencyKind",
165        )),
166        Kind::Recurring if p.schedule.start.is_none() => Err(RuleViolation::new(
167            "ProfileKind",
168            "a Recurring profile requires startSchedule",
169        )),
170        Kind::Relative if p.schedule.start.is_some() => Err(RuleViolation::new(
171            "ProfileKind",
172            "a Relative profile must not have startSchedule",
173        )),
174        _ => Ok(()),
175    }
176    .and_then(|()| {
177        if let (Some(f), Some(t)) = (p.valid_from, p.valid_to)
178            && f >= t
179        {
180            return Err(RuleViolation::new(
181                "Validity",
182                "validFrom must be before validTo",
183            ));
184        }
185        Ok(())
186    })
187    .and_then(|()| match p.purpose {
188        Purpose::StationMax if p.evse != 0 => Err(RuleViolation::new(
189            "StationMaxEvse",
190            "a ChargingStationMaxProfile/ChargePointMaxProfile can only be set on EVSE/connector 0",
191        )),
192        Purpose::Tx if p.evse <= 0 => Err(RuleViolation::new(
193            "TxProfileEvse",
194            "a TxProfile can only be set on an EVSE/connector > 0",
195        )),
196        _ => Ok(()),
197    })
198}