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)

//! Reservations (OCPP 1.6 §5.13 / §5.1, OCPP 2.x block H).

use serde::Serialize;

use super::{Db, Ts};

#[derive(Debug, Clone, Serialize, sqlx::FromRow)]
pub struct Reservation {
    /// OCPP reservationId.
    pub id: i32,
    pub station_id: i64,
    pub ocpp_version: String,
    pub evse_number: Option<i32>,
    pub connector_type: Option<String>,
    pub id_token: String,
    pub id_token_type: Option<String>,
    pub group_id_token: Option<String>,
    pub expires_at: Ts,
    pub status: String,
    pub transaction_id: Option<i64>,
    pub created_at: Ts,
    pub updated_at: Ts,
}

const COLS: &str = "id, station_id, ocpp_version, evse_number, connector_type, id_token, id_token_type, group_id_token, expires_at, status, \
    transaction_id, created_at, updated_at";

pub struct NewReservation<'a> {
    pub ocpp_version: &'a str,
    pub evse_number: Option<i32>,
    pub connector_type: Option<&'a str>,
    pub id_token: &'a str,
    pub id_token_type: Option<&'a str>,
    pub group_id_token: Option<&'a str>,
    pub expires_at: Ts,
}

pub async fn insert(db: &Db, station_id: i64, r: &NewReservation<'_>) -> sqlx::Result<Reservation> {
    sqlx::query_as(&format!(
        "INSERT INTO reservations (station_id, ocpp_version, evse_number, connector_type, id_token, id_token_type, group_id_token, expires_at, status) \
         VALUES ($1, $2, $3, $4, $5, $6, $7, $8, 'Requested') RETURNING {COLS}"
    ))
    .bind(station_id)
    .bind(r.ocpp_version)
    .bind(r.evse_number)
    .bind(r.connector_type)
    .bind(r.id_token)
    .bind(r.id_token_type)
    .bind(r.group_id_token)
    .bind(r.expires_at)
    .fetch_one(db)
    .await
}

pub async fn get(db: &Db, station_id: i64, id: i32) -> sqlx::Result<Option<Reservation>> {
    sqlx::query_as(&format!(
        "SELECT {COLS} FROM reservations WHERE station_id = $1 AND id = $2"
    ))
    .bind(station_id)
    .bind(id)
    .fetch_optional(db)
    .await
}

pub async fn list(
    db: &Db,
    station_id: i64,
    active_only: bool,
    limit: i64,
) -> sqlx::Result<Vec<Reservation>> {
    sqlx::query_as(&format!(
        "SELECT {COLS} FROM reservations WHERE station_id = $1 AND (NOT $2 OR (status = 'Accepted' AND expires_at > now())) ORDER BY id DESC LIMIT $3"
    ))
    .bind(station_id)
    .bind(active_only)
    .bind(limit)
    .fetch_all(db)
    .await
}

/// Sets the status, only from one of the `from` states.
pub async fn set_status(
    db: &Db,
    station_id: i64,
    id: i32,
    status: &str,
    from: &[&str],
) -> sqlx::Result<bool> {
    let from: Vec<String> = from.iter().map(|s| (*s).to_owned()).collect();
    Ok(sqlx::query("UPDATE reservations SET status = $3, updated_at = now() WHERE station_id = $1 AND id = $2 AND status = ANY($4)")
        .bind(station_id)
        .bind(id)
        .bind(status)
        .bind(&from)
        .execute(db)
        .await?
        .rows_affected()
        > 0)
}

/// A transaction started on the reservation (1.6 StartTransaction.reservationId, H01.FR.15).
pub async fn mark_used(
    db: &Db,
    station_id: i64,
    id: i32,
    transaction_id: i64,
) -> sqlx::Result<bool> {
    let mut tx = db.begin().await?;
    let updated = sqlx::query(
        "UPDATE reservations SET status = 'Used', transaction_id = $3, updated_at = now() WHERE station_id = $1 AND id = $2 AND status IN ('Requested', 'Accepted', 'Expired')",
    )
    .bind(station_id)
    .bind(id)
    .bind(transaction_id)
    .execute(&mut *tx)
    .await?
    .rows_affected()
        > 0;
    if updated {
        sqlx::query("UPDATE transactions SET reservation_db_id = $2 WHERE id = $1")
            .bind(transaction_id)
            .bind(id)
            .execute(&mut *tx)
            .await?;
    }
    tx.commit().await?;
    Ok(updated)
}

/// 1.6 §5.13: a reservation ends when its connector (or the Charge Point, connector 0)
/// becomes Faulted or Unavailable.
pub async fn remove_for_connector(db: &Db, station_id: i64, connector: i64) -> sqlx::Result<u64> {
    Ok(sqlx::query(
        "UPDATE reservations SET status = 'Removed', updated_at = now() WHERE station_id = $1 AND status = 'Accepted' \
         AND ($2 = 0 OR evse_number = $2)",
    )
    .bind(station_id)
    .bind(connector.clamp(0, i32::MAX as i64) as i32)
    .execute(db)
    .await?
    .rows_affected())
}

/// Closes reservations whose expiry time passed (the station ends them by itself).
pub async fn expire_due(db: &Db) -> sqlx::Result<u64> {
    Ok(sqlx::query("UPDATE reservations SET status = 'Expired', updated_at = now() WHERE status = 'Accepted' AND expires_at <= now()")
        .execute(db)
        .await?
        .rows_affected())
}