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)

//! Station registry endpoints.

use axum::Json;
use axum::extract::{Path, Query, State};
use axum::http::StatusCode;
use serde::Deserialize;
use serde_json::{Value, json};

use crate::auth;
use crate::db::{self, stations::Station};
use crate::error::AppError;
use crate::hub::Command;
use crate::state::App;

pub async fn load(app: &App, identity: &str) -> Result<Station, AppError> {
    db::stations::get_by_identity(&app.db, identity)
        .await?
        .ok_or_else(|| AppError::NotFound(format!("charging station '{identity}' not found")))
}

fn validate_identity(identity: &str) -> Result<(), AppError> {
    // identifierString (OCPP 2.0.1 Part 2): printable ASCII subset; no ':' (Basic auth separator), max 48.
    let ok = !identity.is_empty()
        && identity.len() <= 48
        && identity
            .chars()
            .all(|c| c.is_ascii_alphanumeric() || "*-_=+|@.".contains(c));
    if ok {
        Ok(())
    } else {
        Err(AppError::BadRequest(
            "ocpp_identity must be 1-48 characters of [A-Za-z0-9*-_=+|@.]".into(),
        ))
    }
}

fn validate_input(i: &db::stations::StationInput) -> Result<(), AppError> {
    if let Some(lat) = i.latitude
        && !(-90.0..=90.0).contains(&lat)
    {
        return Err(AppError::BadRequest(
            "latitude must be within [-90, 90]".into(),
        ));
    }
    if let Some(lon) = i.longitude
        && !(-180.0..=180.0).contains(&lon)
    {
        return Err(AppError::BadRequest(
            "longitude must be within [-180, 180]".into(),
        ));
    }
    if let Some(c) = &i.country_code
        && !(c.len() == 2 && c.chars().all(|c| c.is_ascii_alphabetic()))
    {
        return Err(AppError::BadRequest(
            "country_code must be an ISO 3166-1 alpha-2 code".into(),
        ));
    }
    if let Some(p) = &i.boot_policy
        && !["Accepted", "Pending", "Rejected"].contains(&p.as_str())
    {
        return Err(AppError::BadRequest(
            "boot_policy must be Accepted, Pending or Rejected".into(),
        ));
    }
    if let Some(p) = i.security_profile
        && !(0..=3).contains(&p)
    {
        return Err(AppError::BadRequest(
            "security_profile must be 0, 1, 2 or 3".into(),
        ));
    }
    if let Some(m) = &i.cost_mode
        && !["None", "Central", "Local"].contains(&m.as_str())
    {
        return Err(AppError::BadRequest(
            "cost_mode must be None, Central or Local".into(),
        ));
    }
    for v in i.allowed_versions.iter().flatten() {
        v.parse::<ocpp_core::OcppVersion>()
            .map_err(AppError::BadRequest)?;
    }
    Ok(())
}

#[derive(Debug, Deserialize)]
pub struct CreateStation {
    #[serde(flatten)]
    pub station: db::stations::StationInput,
    #[serde(flatten)]
    pub password: PasswordInput,
}

#[derive(Debug, Default, Deserialize)]
pub struct PasswordInput {
    /// UTF-8 password (OCPP 2.x passwordString, 16–40 characters recommended).
    pub password: Option<String>,
    /// Hex-encoded binary key (OCPP 1.6 AuthorizationKey, 40 hex characters = 20 bytes).
    pub password_hex: Option<String>,
}

impl PasswordInput {
    fn bytes(&self) -> Result<Option<Vec<u8>>, AppError> {
        match (&self.password, &self.password_hex) {
            (Some(_), Some(_)) => Err(AppError::BadRequest(
                "give either password or password_hex".into(),
            )),
            (Some(p), None) => Ok(Some(p.as_bytes().to_vec())),
            (None, Some(h)) => {
                let h = h.trim();
                if h.len() % 2 != 0 || !h.chars().all(|c| c.is_ascii_hexdigit()) {
                    return Err(AppError::BadRequest(
                        "password_hex must be an even-length hexadecimal string".into(),
                    ));
                }
                Ok(Some(
                    (0..h.len())
                        .step_by(2)
                        .map(|i| u8::from_str_radix(&h[i..i + 2], 16).expect("hex checked"))
                        .collect(),
                ))
            }
            (None, None) => Ok(None),
        }
    }
}

pub async fn create(
    State(app): State<App>,
    Json(body): Json<CreateStation>,
) -> Result<(StatusCode, Json<Station>), AppError> {
    let identity = body
        .station
        .ocpp_identity
        .clone()
        .ok_or_else(|| AppError::BadRequest("ocpp_identity is required".into()))?;
    let name = body
        .station
        .name
        .clone()
        .ok_or_else(|| AppError::BadRequest("name is required".into()))?;
    validate_identity(&identity)?;
    validate_input(&body.station)?;
    let password = body.password.bytes()?;
    let mut station = db::stations::create(&app.db, &body.station, &identity, &name).await?;
    if let Some(pw) = password {
        let hash = tokio::task::spawn_blocking(move || auth::hash_password(&pw))
            .await
            .map_err(|e| AppError::Internal(e.to_string()))?;
        db::stations::set_password_hash(&app.db, station.id, Some(&hash)).await?;
        station.auth_password_hash = Some(hash);
    }
    tracing::info!(identity = %station.ocpp_identity, evse_id = %station.evse_id, "charging station registered");
    Ok((StatusCode::CREATED, Json(station)))
}

pub async fn search(
    State(app): State<App>,
    Query(p): Query<db::stations::SearchParams>,
) -> Result<Json<Value>, AppError> {
    if p.lat.is_some() != p.lon.is_some() {
        return Err(AppError::BadRequest(
            "lat and lon must be given together".into(),
        ));
    }
    let hits = db::stations::search(&app.db, &p).await?;
    Ok(Json(json!({ "count": hits.len(), "items": hits })))
}

pub async fn get_one(
    State(app): State<App>,
    Path(identity): Path<String>,
) -> Result<Json<Value>, AppError> {
    let station = load(&app, &identity).await?;
    let evses = db::stations::list_evses(&app.db, station.id).await?;
    let live = app.hub.get(&identity).map(|h| json!({ "ocpp_version": h.version.to_string(), "connected_at": h.connected_at, "remote_addr": h.remote_addr }));
    let mut v = serde_json::to_value(&station).map_err(|e| AppError::Internal(e.to_string()))?;
    v["has_password"] = json!(station.has_password());
    v["evses"] = json!(evses);
    v["live_connection"] = live.unwrap_or(Value::Null);
    Ok(Json(v))
}

pub async fn update(
    State(app): State<App>,
    Path(identity): Path<String>,
    Json(input): Json<db::stations::StationInput>,
) -> Result<Json<Station>, AppError> {
    if input
        .ocpp_identity
        .as_deref()
        .is_some_and(|i| i != identity)
        || input.evse_id_body.is_some()
    {
        return Err(AppError::BadRequest(
            "ocpp_identity and evse_id cannot be changed".into(),
        ));
    }
    validate_input(&input)?;
    let station = load(&app, &identity).await?;
    Ok(Json(
        db::stations::update(&app.db, station.id, &input)
            .await?
            .ok_or_else(|| AppError::NotFound(identity.clone()))?,
    ))
}

pub async fn delete(
    State(app): State<App>,
    Path(identity): Path<String>,
) -> Result<StatusCode, AppError> {
    let station = load(&app, &identity).await?;
    if let Some(h) = app.hub.get(&identity) {
        let _ = h.tx.try_send(Command::Close {
            code: ocpp_ws::close_code::POLICY_VIOLATION,
            reason: "station deregistered",
        });
    }
    db::stations::delete(&app.db, station.id).await?;
    Ok(StatusCode::NO_CONTENT)
}

pub async fn set_password(
    State(app): State<App>,
    Path(identity): Path<String>,
    Json(body): Json<PasswordInput>,
) -> Result<StatusCode, AppError> {
    let station = load(&app, &identity).await?;
    let hash = match body.bytes()? {
        Some(pw) => Some(
            tokio::task::spawn_blocking(move || auth::hash_password(&pw))
                .await
                .map_err(|e| AppError::Internal(e.to_string()))?,
        ),
        None => None,
    };
    db::stations::set_password_hash(&app.db, station.id, hash.as_deref()).await?;
    Ok(StatusCode::NO_CONTENT)
}

pub async fn evses(
    State(app): State<App>,
    Path(identity): Path<String>,
) -> Result<Json<Value>, AppError> {
    let station = load(&app, &identity).await?;
    Ok(Json(json!(
        db::stations::list_evses(&app.db, station.id).await?
    )))
}

#[derive(Debug, Deserialize)]
pub struct TxQuery {
    pub active: Option<bool>,
    pub limit: Option<i64>,
}

pub async fn transactions(
    State(app): State<App>,
    Path(identity): Path<String>,
    Query(q): Query<TxQuery>,
) -> Result<Json<Value>, AppError> {
    let station = load(&app, &identity).await?;
    let list = db::transactions::list_for_station(
        &app.db,
        station.id,
        q.active.unwrap_or(false),
        q.limit.unwrap_or(50).clamp(1, 500),
    )
    .await?;
    Ok(Json(json!(list)))
}

pub async fn transaction(
    State(app): State<App>,
    Path(id): Path<i64>,
) -> Result<Json<Value>, AppError> {
    let tx = db::transactions::get(&app.db, id)
        .await?
        .ok_or_else(|| AppError::NotFound(format!("transaction {id} not found")))?;
    let meter = db::transactions::meter_values(&app.db, id).await?;
    let energy = match (tx.meter_start_wh, tx.meter_stop_wh) {
        (Some(a), Some(b)) => Some(b - a),
        _ => None,
    };
    Ok(Json(
        json!({ "transaction": tx, "energy_wh": energy, "meter_values": meter }),
    ))
}

#[derive(Debug, Deserialize)]
pub struct EventQuery {
    pub action: Option<String>,
    pub limit: Option<i64>,
}

pub async fn events(
    State(app): State<App>,
    Path(identity): Path<String>,
    Query(q): Query<EventQuery>,
) -> Result<Json<Value>, AppError> {
    let station = load(&app, &identity).await?;
    Ok(Json(json!(
        db::events::list(
            &app.db,
            station.id,
            q.action.as_deref(),
            q.limit.unwrap_or(100).clamp(1, 1000)
        )
        .await?
    )))
}