Skip to main content

ocpp_service/smart/
v21.rs

1// SPDX-License-Identifier: Apache-2.0
2// Copyright 2026 Kevin Vavelin, Codetics (https://codetics.fr)
3
4//! OCPP 2.1 charging profiles (functional block K, including dynamic and
5//! priority charging profiles).
6
7use ocpp21::{
8    ChargingProfile, ChargingProfileKindEnum, ChargingRateUnitEnum, ChargingSchedule,
9    ChargingScheduleUpdate, RecurrencyKindEnum,
10};
11use serde_json::{Value, json};
12
13use super::model::{
14    self, Kind, Period, Profile, Purpose, Recurrency, RuleViolation, Schedule, Unit,
15};
16
17pub fn schedule(s: &ChargingSchedule) -> Schedule {
18    Schedule {
19        start: s.start_schedule,
20        duration: s.duration,
21        unit: match s.charging_rate_unit {
22            ChargingRateUnitEnum::A => Unit::A,
23            ChargingRateUnitEnum::W => Unit::W,
24        },
25        periods: s
26            .charging_schedule_period
27            .iter()
28            .map(|p| Period {
29                start: p.start_period,
30                limit: p.limit,
31                phases: p.number_phases,
32            })
33            .collect(),
34    }
35}
36
37/// `update`: latest dynamic schedule update (K28), applied to the single period.
38pub fn normalize(
39    evse_id: i64,
40    p: &ChargingProfile,
41    update: Option<(&ChargingScheduleUpdate, ocpp_core::DateTime)>,
42) -> Profile {
43    let mut schedule = schedule(&p.charging_schedule[0]);
44    let mut dyn_updated_at = p.dyn_update_time;
45    if let Some((u, at)) = update
46        && let Some(first) = schedule.periods.first_mut()
47    {
48        if u.limit.is_some() {
49            first.limit = u.limit;
50        }
51        dyn_updated_at = Some(at);
52    }
53    Profile {
54        id: p.id,
55        evse: evse_id,
56        purpose: Purpose::parse(p.charging_profile_purpose.as_str())
57            .expect("2.1 purposes are known"),
58        stack_level: p.stack_level,
59        kind: match p.charging_profile_kind {
60            ChargingProfileKindEnum::Absolute => Kind::Absolute,
61            ChargingProfileKindEnum::Recurring => Kind::Recurring,
62            ChargingProfileKindEnum::Relative => Kind::Relative,
63            ChargingProfileKindEnum::Dynamic => Kind::Dynamic,
64        },
65        recurrency: p.recurrency_kind.map(|r| match r {
66            RecurrencyKindEnum::Daily => Recurrency::Daily,
67            RecurrencyKindEnum::Weekly => Recurrency::Weekly,
68        }),
69        valid_from: p.valid_from,
70        valid_to: p.valid_to,
71        transaction: p.transaction_id.clone(),
72        schedule,
73        dyn_updated_at,
74    }
75}
76
77pub fn decode(v: &Value) -> Result<ChargingProfile, String> {
78    ocpp_core::decode_value(v.clone()).map_err(|e| e.to_string())
79}
80
81pub fn prepare(
82    evse_id: i64,
83    mut p: ChargingProfile,
84    active_tx: Option<&str>,
85) -> Result<(Profile, Value), RuleViolation> {
86    let purpose = p.charging_profile_purpose.as_str();
87    if purpose == "ChargingStationExternalConstraints" {
88        return Err(RuleViolation::new(
89            "K01.FR.22",
90            "the CSMS must not set ChargingStationExternalConstraints profiles",
91        ));
92    }
93    if purpose == "TxProfile" {
94        let Some(active) = active_tx else {
95            return Err(RuleViolation::new(
96                "K01.FR.03",
97                "a TxProfile requires an active transaction on the EVSE",
98            ));
99        };
100        match &p.transaction_id {
101            None => p.transaction_id = Some(active.to_owned()),
102            Some(t) if t != active => {
103                return Err(RuleViolation::new(
104                    "K01.FR.03",
105                    format!("transactionId {t} is not the active transaction {active} of the EVSE"),
106                ));
107            }
108            Some(_) => {}
109        }
110    }
111    let dynamic = p.charging_profile_kind == ChargingProfileKindEnum::Dynamic;
112    if dynamic
113        && (p.charging_schedule.len() != 1
114            || p.charging_schedule[0].charging_schedule_period.len() != 1)
115    {
116        return Err(RuleViolation::new(
117            "K28.FR.01",
118            "a Dynamic profile has exactly one chargingSchedule with one chargingSchedulePeriod",
119        ));
120    }
121    if !dynamic && p.dyn_update_interval.is_some() {
122        return Err(RuleViolation::new(
123            "K28.FR.04",
124            "dynUpdateInterval only applies to Dynamic profiles",
125        ));
126    }
127    for s in &p.charging_schedule {
128        if purpose == "PriorityCharging" && s.duration.is_some() {
129            return Err(RuleViolation::new(
130                "K01.FR.73",
131                "a PriorityCharging schedule must not have a duration",
132            ));
133        }
134        for per in &s.charging_schedule_period {
135            if per.phase_to_use.is_some() && per.number_phases != Some(1) {
136                return Err(RuleViolation::new(
137                    "K01.FR.19",
138                    "phaseToUse may only be set when numberPhases is 1",
139                ));
140            }
141            let has_l23 = per.limit_l2.is_some() || per.limit_l3.is_some();
142            if has_l23 && purpose != "TxProfile" {
143                return Err(RuleViolation::new(
144                    "K01.FR.143",
145                    "limit_L2/limit_L3 are only allowed in a TxProfile",
146                ));
147            }
148            if has_l23 && per.limit.is_none() {
149                return Err(RuleViolation::new(
150                    "K01.FR.145",
151                    "limit_L2/limit_L3 require limit (phase L1)",
152                ));
153            }
154        }
155    }
156    let profile = normalize(evse_id, &p, None);
157    model::check_structure(&profile)?;
158    let payload = json!({ "evseId": evse_id, "chargingProfile": p });
159    Ok((profile, payload))
160}