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> {
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 {
pub password: Option<String>,
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?
)))
}