Skip to main content

ocpp_service/smart/
mod.rs

1// SPDX-License-Identifier: Apache-2.0
2// Copyright 2026 Kevin Vavelin, Codetics (https://codetics.fr)
3
4//! Smart charging (OCPP 1.6 §3.13 / Smart Charging profile, OCPP 2.x block K).
5//!
6//! The CSMS keeps a registry of the charging profiles it installed (and of
7//! those reported by stations), enforces the CSMS-side rules of the
8//! specification before sending a profile, computes composite schedules, and
9//! answers ISO 15118 charging needs with a TxProfile.
10
11pub mod composite;
12pub mod model;
13pub mod v16;
14pub mod v201;
15pub mod v21;
16
17use ocpp_core::{DateTime, OcppVersion};
18use serde::{Deserialize, Serialize};
19use serde_json::{Value, json};
20
21use self::composite::{CompositePeriod, Params};
22use self::model::{Kind, Profile, Purpose, RuleViolation, Unit};
23use crate::calls;
24use crate::db::{self, Ts, charging::ProfileRow, stations::Station};
25use crate::error::AppError;
26use crate::state::App;
27
28fn rule_error(v: RuleViolation) -> AppError {
29    AppError::BadRequest(format!("{} ({})", v.message, v.rule))
30}
31
32/// Normalizes a stored profile row.
33pub fn normalize_row(row: &ProfileRow) -> Option<Profile> {
34    let evse = row.evse_number as i64;
35    match row.ocpp_version.as_str() {
36        "1.6" => v16::decode(&row.profile)
37            .ok()
38            .map(|p| v16::normalize(evse, &p)),
39        "2.0.1" => v201::decode(&row.profile)
40            .ok()
41            .map(|p| v201::normalize(evse, &p)),
42        "2.1" => v21::decode(&row.profile).ok().map(|p| {
43            let update = row.dyn_update.as_ref().and_then(|u| {
44                ocpp_core::decode_value::<ocpp21::ChargingScheduleUpdate>(u.clone()).ok()
45            });
46            v21::normalize(
47                evse,
48                &p,
49                update
50                    .as_ref()
51                    .zip(row.dyn_updated_at)
52                    .map(|(u, t)| (u, t.0)),
53            )
54        }),
55        _ => None,
56    }
57}
58
59/// External limits (NotifyChargingLimit) as profiles of purpose
60/// ChargingStationExternalConstraints (or LocalGeneration, 2.1).
61fn external_profiles(limits: &[db::charging::ExternalLimit]) -> Vec<Profile> {
62    let mut out = Vec::new();
63    for (i, l) in limits.iter().enumerate() {
64        let Some(Value::Array(schedules)) = &l.schedules else {
65            continue;
66        };
67        // A 2.0.1 ChargingScheduleType is a subset of the 2.1 one.
68        let Some(first) = schedules
69            .first()
70            .and_then(|s| ocpp_core::decode_value::<ocpp21::ChargingSchedule>(s.clone()).ok())
71        else {
72            continue;
73        };
74        let mut schedule = v21::schedule(&first);
75        if schedule.start.is_none() {
76            schedule.start = Some(l.received_at.0);
77        }
78        out.push(Profile {
79            id: -(i as i64) - 1,
80            evse: l.evse_number as i64,
81            purpose: if l.is_local_generation == Some(true) {
82                Purpose::LocalGeneration
83            } else {
84                Purpose::ExternalConstraints
85            },
86            stack_level: 0,
87            kind: Kind::Absolute,
88            recurrency: None,
89            valid_from: None,
90            valid_to: None,
91            transaction: None,
92            schedule,
93            dyn_updated_at: None,
94        });
95    }
96    out
97}
98
99/// All profiles currently applicable at the station, including external limits.
100pub async fn station_profiles(app: &App, station_id: i64) -> Result<Vec<Profile>, AppError> {
101    let rows = db::charging::installed(&app.db, station_id).await?;
102    let mut profiles: Vec<Profile> = rows.iter().filter_map(normalize_row).collect();
103    profiles.extend(external_profiles(
104        &db::charging::external_limits(&app.db, station_id).await?,
105    ));
106    Ok(profiles)
107}
108
109/// K01.FR.06 / K01.FR.39: no two profiles with the same stack level and purpose valid at the same time.
110fn check_conflicts(new: &Profile, installed: &[Profile]) -> Result<(), RuleViolation> {
111    for other in installed
112        .iter()
113        .filter(|o| o.id != new.id && o.purpose == new.purpose && o.stack_level == new.stack_level)
114    {
115        if new.purpose == Purpose::Tx {
116            if other.transaction == new.transaction {
117                return Err(RuleViolation::new(
118                    "K01.FR.39",
119                    format!(
120                        "TxProfile {} already uses stackLevel {} for this transaction",
121                        other.id, new.stack_level
122                    ),
123                ));
124            }
125        } else if other.evse == new.evse && new.validity_overlaps(other) {
126            return Err(RuleViolation::new(
127                "K01.FR.06",
128                format!(
129                    "profile {} already uses this stackLevel and purpose on EVSE {} with an overlapping validity",
130                    other.id, new.evse
131                ),
132            ));
133        }
134    }
135    Ok(())
136}
137
138#[derive(Debug, Serialize)]
139pub struct InstallOutcome {
140    pub status: String,
141    pub response: Value,
142    pub profile: Value,
143}
144
145/// Validates a profile against the specification rules, sends it with
146/// SetChargingProfile and records the result.
147pub async fn install(
148    app: &App,
149    station: &Station,
150    version: OcppVersion,
151    evse: i64,
152    profile_json: Value,
153) -> Result<InstallOutcome, AppError> {
154    let active = if evse > 0 {
155        db::transactions::active_on_evse(&app.db, station.id, evse).await?
156    } else {
157        None
158    };
159    let active_uid = active.as_ref().map(|(uid, _)| uid.as_str());
160    let (profile, payload) = match version {
161        OcppVersion::V16 => v16::prepare(
162            evse,
163            v16::decode(&profile_json).map_err(AppError::BadRequest)?,
164            active_uid,
165        ),
166        OcppVersion::V201 => v201::prepare(
167            evse,
168            v201::decode(&profile_json).map_err(AppError::BadRequest)?,
169            active_uid,
170        ),
171        OcppVersion::V21 => v21::prepare(
172            evse,
173            v21::decode(&profile_json).map_err(AppError::BadRequest)?,
174            active_uid,
175        ),
176    }
177    .map_err(rule_error)?;
178    if version != OcppVersion::V16 {
179        let installed: Vec<Profile> = db::charging::installed(&app.db, station.id)
180            .await?
181            .iter()
182            .filter_map(normalize_row)
183            .collect();
184        check_conflicts(&profile, &installed)
185            .map_err(|v| AppError::Conflict(format!("{} ({})", v.message, v.rule)))?;
186    }
187    let stored = payload
188        .get("csChargingProfiles")
189        .or_else(|| payload.get("chargingProfile"))
190        .cloned()
191        .unwrap_or(Value::Null);
192    let row_id = db::charging::insert_pending(
193        &app.db,
194        station.id,
195        &db::charging::NewProfile {
196            ocpp_version: version.as_str(),
197            profile_id: profile.id,
198            evse_number: evse,
199            purpose: stored["chargingProfilePurpose"]
200                .as_str()
201                .unwrap_or_default(),
202            stack_level: profile.stack_level,
203            kind: stored["chargingProfileKind"].as_str().unwrap_or_default(),
204            transaction_uid: profile.transaction.as_deref(),
205            valid_from: profile.valid_from.map(Ts),
206            valid_to: profile.valid_to.map(Ts),
207            source: "CSO",
208            profile: &stored,
209        },
210    )
211    .await?;
212    let response = match calls::call(
213        app,
214        &station.ocpp_identity,
215        version,
216        "SetChargingProfile",
217        payload,
218    )
219    .await
220    {
221        Ok(r) => r,
222        Err(e) => {
223            db::charging::discard_pending(&app.db, row_id).await?;
224            return Err(e);
225        }
226    };
227    let status = calls::status_of(&response).to_owned();
228    if status == "Accepted" {
229        // 1.6 stations replace a profile with the same connector/purpose/stack level (§3.13.2).
230        db::charging::mark_installed(&app.db, station.id, row_id, version == OcppVersion::V16)
231            .await?;
232    } else {
233        db::charging::mark_rejected(&app.db, row_id, response.get("statusInfo")).await?;
234    }
235    tracing::info!(station = %station.ocpp_identity, profile = profile.id, evse, %status, "SetChargingProfile");
236    Ok(InstallOutcome {
237        status,
238        response,
239        profile: stored,
240    })
241}
242
243#[derive(Debug, Default, Deserialize)]
244pub struct ClearRequest {
245    pub profile_id: Option<i64>,
246    pub evse_id: Option<i64>,
247    /// Version-specific purpose name (e.g. `ChargePointMaxProfile` in 1.6).
248    pub purpose: Option<String>,
249    pub stack_level: Option<i64>,
250}
251
252pub async fn clear(
253    app: &App,
254    station: &Station,
255    version: OcppVersion,
256    req: &ClearRequest,
257) -> Result<Value, AppError> {
258    let payload = if version == OcppVersion::V16 {
259        json!({ "id": req.profile_id, "connectorId": req.evse_id, "chargingProfilePurpose": req.purpose, "stackLevel": req.stack_level })
260    } else {
261        if req.profile_id.is_none()
262            && req.evse_id.is_none()
263            && req.purpose.is_none()
264            && req.stack_level.is_none()
265        {
266            return Err(AppError::BadRequest(
267                "give profile_id, or at least one of evse_id, purpose, stack_level (K10.FR.02)"
268                    .into(),
269            ));
270        }
271        if matches!(
272            req.purpose.as_deref(),
273            Some("ChargingStationExternalConstraints" | "LocalGeneration")
274        ) {
275            return Err(AppError::BadRequest("the CSMS must not clear ChargingStationExternalConstraints or LocalGeneration profiles (K10.FR.06)".into()));
276        }
277        let criteria = (req.evse_id.is_some() || req.purpose.is_some() || req.stack_level.is_some())
278            .then(|| json!({ "evseId": req.evse_id, "chargingProfilePurpose": req.purpose, "stackLevel": req.stack_level }));
279        json!({ "chargingProfileId": req.profile_id, "chargingProfileCriteria": criteria })
280    };
281    let payload = strip_nulls(payload);
282    let response = calls::call(
283        app,
284        &station.ocpp_identity,
285        version,
286        "ClearChargingProfile",
287        payload,
288    )
289    .await?;
290    if calls::status_of(&response) == "Accepted" {
291        let criteria = db::charging::ClearCriteria {
292            profile_id: req.profile_id,
293            evse_number: req.evse_id,
294            purpose: req.purpose.as_deref(),
295            stack_level: req.stack_level,
296        };
297        let n = db::charging::clear(&app.db, station.id, &criteria).await?;
298        tracing::info!(station = %station.ocpp_identity, cleared = n, "ClearChargingProfile");
299    }
300    Ok(response)
301}
302
303/// Removes `null` members recursively (OCPP-J does not allow null for absent fields).
304pub fn strip_nulls(v: Value) -> Value {
305    match v {
306        Value::Object(m) => Value::Object(
307            m.into_iter()
308                .filter(|(_, v)| !v.is_null())
309                .map(|(k, v)| (k, strip_nulls(v)))
310                .collect(),
311        ),
312        Value::Array(a) => Value::Array(a.into_iter().map(strip_nulls).collect()),
313        other => other,
314    }
315}
316
317/// CSMS-side composite schedule of an EVSE.
318pub async fn composite_for(
319    app: &App,
320    station: &Station,
321    evse: i64,
322    start: DateTime,
323    duration: i64,
324    unit: Unit,
325) -> Result<Vec<CompositePeriod>, AppError> {
326    let active = if evse > 0 {
327        db::transactions::active_on_evse(&app.db, station.id, evse).await?
328    } else {
329        None
330    };
331    let profiles: Vec<Profile> = station_profiles(app, station.id)
332        .await?
333        .into_iter()
334        // A TxProfile only applies to its own (active) transaction.
335        .filter(|p| {
336            p.purpose != Purpose::Tx
337                || (p.transaction.is_some()
338                    && p.transaction.as_deref() == active.as_ref().map(|a| a.0.as_str()))
339        })
340        .collect();
341    let params = Params {
342        start,
343        duration,
344        unit,
345        voltage: app.cfg.nominal_voltage,
346        default_phases: 3,
347        tx_start: active.and_then(|a| a.1).map(|t| t.0),
348    };
349    Ok(composite::composite(&profiles, evse, &params))
350}
351
352/// K28.FR.07 / K28.FR.12: values for a PullDynamicScheduleUpdateRequest.
353pub async fn pull_dynamic_update(
354    app: &App,
355    station_id: i64,
356    profile_id: i64,
357) -> Result<Option<Value>, AppError> {
358    let Some(row) = db::charging::find_installed(&app.db, station_id, profile_id).await? else {
359        return Ok(None);
360    };
361    if row.kind != "Dynamic" {
362        return Ok(None);
363    }
364    if let Some(u) = row.dyn_update {
365        return Ok(Some(u));
366    }
367    // No update yet: the values of the profile's single period.
368    let period = &row.profile["chargingSchedule"][0]["chargingSchedulePeriod"][0];
369    let fields = [
370        "limit",
371        "limit_L2",
372        "limit_L3",
373        "dischargeLimit",
374        "dischargeLimit_L2",
375        "dischargeLimit_L3",
376        "setpoint",
377        "setpoint_L2",
378        "setpoint_L3",
379        "setpointReactive",
380        "setpointReactive_L2",
381        "setpointReactive_L3",
382    ];
383    let update: serde_json::Map<String, Value> = fields
384        .iter()
385        .filter_map(|f| period.get(*f).map(|v| ((*f).to_owned(), v.clone())))
386        .collect();
387    Ok(Some(Value::Object(update)))
388}
389
390/// Stores a dynamic schedule update (2.1 K28) and, with `push`, sends UpdateDynamicSchedule.
391pub async fn dynamic_update(
392    app: &App,
393    station: &Station,
394    version: OcppVersion,
395    profile_id: i64,
396    update: Value,
397    push: bool,
398) -> Result<Value, AppError> {
399    if version != OcppVersion::V21 {
400        return Err(AppError::BadRequest(
401            "dynamic charging profiles exist in OCPP 2.1 only".into(),
402        ));
403    }
404    let row = db::charging::find_installed(&app.db, station.id, profile_id)
405        .await?
406        .ok_or_else(|| AppError::NotFound(format!("no installed charging profile {profile_id}")))?;
407    if row.kind != "Dynamic" {
408        return Err(AppError::BadRequest(
409            "the charging profile is not Dynamic (K28.FR.11)".into(),
410        ));
411    }
412    let update: ocpp21::ChargingScheduleUpdate =
413        ocpp_core::decode_value(update).map_err(|e| AppError::BadRequest(e.to_string()))?;
414    let update = serde_json::to_value(update).map_err(|e| AppError::Internal(e.to_string()))?;
415    if !push {
416        db::charging::set_dyn_update(&app.db, row.id, &update).await?;
417        return Ok(json!({ "stored": true }));
418    }
419    let response = calls::call(
420        app,
421        &station.ocpp_identity,
422        version,
423        "UpdateDynamicSchedule",
424        json!({ "chargingProfileId": profile_id, "scheduleUpdate": update }),
425    )
426    .await?;
427    if calls::status_of(&response) == "Accepted" {
428        db::charging::set_dyn_update(&app.db, row.id, &update).await?;
429    }
430    Ok(response)
431}
432
433/// ISO 15118 charging needs reported by the station (NotifyEVChargingNeeds), version-neutral.
434#[derive(Debug, Clone)]
435pub struct EvNeeds {
436    pub evse: i64,
437    /// The EV requests bidirectional power transfer (2.1 *_BPT / DER modes).
438    pub bidirectional: bool,
439    /// AC: maximum current per phase (A).
440    pub ac_max_current: Option<f64>,
441    /// DC: maximum power (W).
442    pub dc_max_power: Option<f64>,
443    pub departure: Option<DateTime>,
444    pub max_schedule_tuples: Option<i64>,
445}
446
447/// K15/K17/K18/K19: decides the NotifyEVChargingNeeds status and, when
448/// accepted, sends a TxProfile for the transaction in the background (the
449/// station expects it within 60 s).
450pub async fn on_ev_charging_needs(
451    app: &App,
452    station_id: i64,
453    version: OcppVersion,
454    needs: EvNeeds,
455) -> Result<&'static str, AppError> {
456    if !app.cfg.ev_charging_needs {
457        return Ok("Rejected");
458    }
459    if needs.bidirectional && !app.cfg.allow_bidirectional {
460        // K18.FR.02 / K19.FR.02: requested energy transfer not accepted.
461        return Ok("Rejected");
462    }
463    if needs.ac_max_current.is_none() && needs.dc_max_power.is_none() {
464        return Ok("Rejected");
465    }
466    let Some((uid, _)) = db::transactions::active_on_evse(&app.db, station_id, needs.evse).await?
467    else {
468        return Ok("Rejected");
469    };
470    let app = app.clone();
471    tokio::spawn(async move {
472        if let Err(e) = send_ev_schedule(&app, station_id, version, &uid, &needs).await {
473            tracing::warn!(station_id, transaction = %uid, error = %e, "could not send a TxProfile for the EV charging needs");
474        }
475    });
476    Ok("Accepted")
477}
478
479async fn send_ev_schedule(
480    app: &App,
481    station_id: i64,
482    version: OcppVersion,
483    uid: &str,
484    needs: &EvNeeds,
485) -> Result<(), AppError> {
486    let station = db::stations::get(&app.db, station_id)
487        .await?
488        .ok_or_else(|| AppError::NotFound("station".into()))?;
489    let now = DateTime::now();
490    let horizon = needs.departure.map_or(86_400, |d| {
491        (d.unix_seconds() - now.unix_seconds()).clamp(3600, 86_400)
492    });
493    let (unit, ev_max) = match (needs.ac_max_current, needs.dc_max_power) {
494        (Some(a), _) => (Unit::A, a),
495        (None, Some(w)) => (Unit::W, w),
496        _ => return Ok(()),
497    };
498    // Station-wide and default limits, without the TxProfiles of this transaction (they get replaced).
499    let installed = db::charging::installed(&app.db, station.id).await?;
500    let own: Vec<&ProfileRow> = installed
501        .iter()
502        .filter(|r| r.purpose == "TxProfile" && r.transaction_uid.as_deref() == Some(uid))
503        .collect();
504    let profiles: Vec<Profile> = station_profiles(app, station.id)
505        .await?
506        .into_iter()
507        .filter(|p| p.purpose != Purpose::Tx)
508        .collect();
509    let params = Params {
510        start: now,
511        duration: horizon,
512        unit,
513        voltage: app.cfg.nominal_voltage,
514        default_phases: 3,
515        tx_start: None,
516    };
517    let mut periods: Vec<(i64, f64)> = composite::composite(&profiles, needs.evse, &params)
518        .into_iter()
519        .map(|p| (p.start_period, p.limit.map_or(ev_max, |l| l.min(ev_max))))
520        .collect();
521    periods.dedup_by(|b, a| a.1 == b.1);
522    // Respect maxScheduleTuples: the last kept period takes the lowest remaining limit.
523    if let Some(max) = needs
524        .max_schedule_tuples
525        .filter(|m| *m > 0)
526        .map(|m| m as usize)
527        && periods.len() > max
528    {
529        let tail_min = periods[max - 1..]
530            .iter()
531            .map(|p| p.1)
532            .fold(f64::INFINITY, f64::min);
533        periods.truncate(max);
534        periods[max - 1].1 = tail_min;
535    }
536    // Reuse the profile id of a previous EV-needs TxProfile of this transaction, otherwise allocate one.
537    let (id, stack) = match own.iter().find(|r| r.profile_id >= 100_000) {
538        Some(r) => (r.profile_id as i64, r.stack_level as i64),
539        None => (
540            db::charging::next_profile_id(&app.db, station.id).await?,
541            own.iter()
542                .map(|r| r.stack_level as i64 + 1)
543                .max()
544                .unwrap_or(0),
545        ),
546    };
547    let profile = json!({
548        "id": id,
549        "stackLevel": stack,
550        "chargingProfilePurpose": "TxProfile",
551        "chargingProfileKind": "Absolute",
552        "transactionId": uid,
553        "chargingSchedule": [{
554            "id": 1,
555            "startSchedule": now,
556            "duration": horizon,
557            "chargingRateUnit": if unit == Unit::A { "A" } else { "W" },
558            "chargingSchedulePeriod": periods.iter().map(|(s, l)| json!({ "startPeriod": s, "limit": (l * 10.0).round() / 10.0 })).collect::<Vec<_>>(),
559        }],
560    });
561    let outcome = install(app, &station, version, needs.evse, profile).await?;
562    tracing::info!(station = %station.ocpp_identity, transaction = uid, status = %outcome.status, "TxProfile sent for EV charging needs");
563    Ok(())
564}
565
566/// Stores profiles reported with ReportChargingProfiles (2.x K09).
567pub async fn record_reported(
568    app: &App,
569    station_id: i64,
570    version: OcppVersion,
571    evse: i64,
572    source: &str,
573    profiles: &[Value],
574) -> Result<(), AppError> {
575    for p in profiles {
576        let normalized = match version {
577            OcppVersion::V201 => v201::decode(p).map(|x| v201::normalize(evse, &x)),
578            OcppVersion::V21 => v21::decode(p).map(|x| v21::normalize(evse, &x, None)),
579            OcppVersion::V16 => continue,
580        };
581        let Ok(n) = normalized else { continue };
582        db::charging::upsert_reported(
583            &app.db,
584            station_id,
585            &db::charging::NewProfile {
586                ocpp_version: version.as_str(),
587                profile_id: n.id,
588                evse_number: evse,
589                purpose: p["chargingProfilePurpose"].as_str().unwrap_or_default(),
590                stack_level: n.stack_level,
591                kind: p["chargingProfileKind"].as_str().unwrap_or_default(),
592                transaction_uid: n.transaction.as_deref(),
593                valid_from: n.valid_from.map(Ts),
594                valid_to: n.valid_to.map(Ts),
595                source,
596                profile: p,
597            },
598        )
599        .await?;
600    }
601    Ok(())
602}
603
604#[cfg(test)]
605mod tests {
606    use super::*;
607    use crate::smart::model::{Period, Schedule};
608
609    fn p(id: i64, evse: i64, purpose: Purpose, stack: i64, tx: Option<&str>) -> Profile {
610        Profile {
611            id,
612            evse,
613            purpose,
614            stack_level: stack,
615            kind: Kind::Absolute,
616            recurrency: None,
617            valid_from: None,
618            valid_to: None,
619            transaction: tx.map(str::to_owned),
620            schedule: Schedule {
621                start: Some(DateTime::UNIX_EPOCH),
622                duration: None,
623                unit: Unit::A,
624                periods: vec![Period {
625                    start: 0,
626                    limit: Some(16.0),
627                    phases: None,
628                }],
629            },
630            dyn_updated_at: None,
631        }
632    }
633
634    #[test]
635    fn conflict_rules() {
636        let installed = vec![
637            p(1, 1, Purpose::TxDefault, 0, None),
638            p(2, 1, Purpose::Tx, 1, Some("T1")),
639        ];
640        assert!(check_conflicts(&p(3, 1, Purpose::TxDefault, 0, None), &installed).is_err());
641        assert!(
642            check_conflicts(&p(1, 1, Purpose::TxDefault, 0, None), &installed).is_ok(),
643            "same id replaces"
644        );
645        assert!(
646            check_conflicts(&p(3, 2, Purpose::TxDefault, 0, None), &installed).is_ok(),
647            "other EVSE"
648        );
649        assert!(
650            check_conflicts(&p(3, 1, Purpose::TxDefault, 1, None), &installed).is_ok(),
651            "other stack level"
652        );
653        assert!(check_conflicts(&p(4, 1, Purpose::Tx, 1, Some("T1")), &installed).is_err());
654        assert!(check_conflicts(&p(4, 1, Purpose::Tx, 1, Some("T2")), &installed).is_ok());
655        let mut later = p(5, 1, Purpose::TxDefault, 0, None);
656        later.valid_from = Some(DateTime::from_unix_seconds(100));
657        let mut earlier = p(1, 1, Purpose::TxDefault, 0, None);
658        earlier.valid_to = Some(DateTime::from_unix_seconds(100));
659        assert!(
660            check_conflicts(&later, &[earlier]).is_ok(),
661            "non-overlapping validity"
662        );
663    }
664
665    #[test]
666    fn strip_nulls_removes_absent_fields() {
667        assert_eq!(
668            strip_nulls(json!({ "a": null, "b": { "c": null, "d": 1 } })),
669            json!({ "b": { "d": 1 } })
670        );
671    }
672}