1pub 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
32pub 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
59fn 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 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
99pub 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
109fn 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
145pub 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 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 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
303pub 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
317pub 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 .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, ¶ms))
350}
351
352pub 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 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
390pub 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#[derive(Debug, Clone)]
435pub struct EvNeeds {
436 pub evse: i64,
437 pub bidirectional: bool,
439 pub ac_max_current: Option<f64>,
441 pub dc_max_power: Option<f64>,
443 pub departure: Option<DateTime>,
444 pub max_schedule_tuples: Option<i64>,
445}
446
447pub 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 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 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, ¶ms)
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 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 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
566pub 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}