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)

//! Configuration from environment variables.

use std::net::SocketAddr;
use std::path::PathBuf;
use std::time::Duration;

use ocpp_core::OcppVersion;

use crate::tls::TlsSettings;

#[derive(Debug, Clone)]
pub struct Config {
    /// Plain (unencrypted) listener for charging stations; `none` disables it. Env: `OCPP_LISTEN`.
    pub ocpp_listen: Option<SocketAddr>,
    /// TLS listener for charging stations (Security Profiles 2 and 3). Env: `OCPP_TLS_LISTEN`.
    pub ocpp_tls_listen: Option<SocketAddr>,
    /// Env: `OCPP_TLS_CERT`/`OCPP_TLS_KEY` (+ optional `OCPP_TLS_CERT2`/`OCPP_TLS_KEY2`),
    /// `OCPP_TLS_CLIENT_CA`, `OCPP_TLS_CIPHERS`.
    pub tls: Option<TlsSettings>,
    /// CSO name expected in the O RDN of station certificates (A00.FR.404). Env: `OCPP_CSO_NAME`.
    pub cso_name: Option<String>,
    /// Maximum concurrent station connections per listener. Env: `OCPP_MAX_CONNECTIONS`.
    pub max_connections: usize,
    /// Listener for the internal HTTP API. Env: `API_LISTEN`.
    pub api_listen: SocketAddr,
    /// Env: `DATABASE_URL`.
    pub database_url: String,
    /// Env: `DATABASE_MAX_CONNECTIONS`.
    pub database_max_connections: u32,
    /// Identifies this instance in the `charging_stations.connected_node` column. Env: `NODE_ID`.
    pub node_id: String,
    /// OCPP versions offered to stations. Env: `OCPP_VERSIONS` (e.g. `1.6,2.0.1,2.1`).
    pub versions: Vec<OcppVersion>,
    /// Heartbeat interval returned in BootNotification responses. Env: `HEARTBEAT_INTERVAL_SECS`.
    pub heartbeat_interval: Duration,
    /// Timeout for CSMS-initiated calls. Env: `CALL_TIMEOUT_SECS`.
    pub call_timeout: Duration,
    /// Server-side WebSocket ping interval when the link is idle; 0 disables. Env: `PING_INTERVAL_SECS`.
    pub ping_interval: Duration,
    /// Env: `MAX_MESSAGE_SIZE` (bytes).
    pub max_message_size: usize,
    /// RFC 7692 permessage-deflate. Env: `WS_DEFLATE`.
    pub ws_deflate: bool,
    /// Impose client_no_context_takeover to save memory. Env: `WS_FORCE_CLIENT_NO_CONTEXT_TAKEOVER`.
    pub ws_force_client_no_context_takeover: bool,
    /// Reject stations that have no password configured (Security Profile ≥ 1 everywhere). Env: `OCPP_REQUIRE_PASSWORD`.
    pub require_password: bool,
    /// Accept id tokens that are not in the database. Env: `AUTHORIZE_UNKNOWN_TOKENS`.
    pub authorize_unknown_tokens: bool,
    /// Bearer token required by the HTTP API, if set. Env: `API_TOKEN`.
    pub api_token: Option<String>,
    /// Max queued commands per station connection. Env: `COMMAND_QUEUE_SIZE`.
    pub command_queue_size: usize,
    /// Max concurrent password verifications (argon2 is memory-hard). Env: `AUTH_CONCURRENCY`.
    pub auth_concurrency: usize,
    /// Nominal phase voltage used to convert A ↔ W in composite schedules. Env: `NOMINAL_VOLTAGE`.
    pub nominal_voltage: f64,
    /// Answer ISO 15118 NotifyEVChargingNeeds with a TxProfile (K15–K19). Env: `SMART_CHARGING_EV_NEEDS`.
    pub ev_charging_needs: bool,
    /// Accept bidirectional energy transfer modes requested by EVs (OCPP 2.1). Env: `ALLOW_BIDIRECTIONAL`.
    pub allow_bidirectional: bool,
    /// How often expired reservations are closed. Env: `RESERVATION_SWEEP_SECS`.
    pub reservation_sweep_interval: Duration,
    /// Time zone of stations without their own (tariff conditions use local time). Env: `DEFAULT_TIMEZONE`.
    pub default_timezone: String,
    /// Directory of the IANA time zone database. Env: `TZDIR`.
    pub zoneinfo_dir: PathBuf,
}

fn env_addr(name: &str, default: Option<&str>) -> Result<Option<SocketAddr>, String> {
    let v = std::env::var(name).ok().filter(|v| !v.trim().is_empty());
    match v.as_deref().or(default) {
        None => Ok(None),
        Some(v) if v.trim().eq_ignore_ascii_case("none") => Ok(None),
        Some(v) => v
            .trim()
            .parse()
            .map(Some)
            .map_err(|_| format!("invalid address for {name}: {v}")),
    }
}

fn env_path(name: &str) -> Option<PathBuf> {
    std::env::var(name)
        .ok()
        .filter(|v| !v.trim().is_empty())
        .map(PathBuf::from)
}

fn tls_settings() -> Result<Option<TlsSettings>, String> {
    let mut identities = Vec::new();
    for (c, k) in [
        ("OCPP_TLS_CERT", "OCPP_TLS_KEY"),
        ("OCPP_TLS_CERT2", "OCPP_TLS_KEY2"),
    ] {
        match (env_path(c), env_path(k)) {
            (Some(cert), Some(key)) => identities.push((cert, key)),
            (None, None) => {}
            _ => return Err(format!("{c} and {k} must be set together")),
        }
    }
    if identities.is_empty() {
        return Ok(None);
    }
    Ok(Some(TlsSettings {
        identities,
        client_ca: env_path("OCPP_TLS_CLIENT_CA"),
        ciphers: std::env::var("OCPP_TLS_CIPHERS")
            .ok()
            .filter(|v| !v.is_empty())
            .unwrap_or_else(|| crate::tls::DEFAULT_CIPHERS.into()),
    }))
}

fn env<T: std::str::FromStr>(name: &str, default: T) -> Result<T, String> {
    match std::env::var(name) {
        Ok(v) if !v.trim().is_empty() => v
            .trim()
            .parse()
            .map_err(|_| format!("invalid value for {name}: {v}")),
        _ => Ok(default),
    }
}

fn env_bool(name: &str, default: bool) -> Result<bool, String> {
    match std::env::var(name) {
        Ok(v) => match v.trim().to_ascii_lowercase().as_str() {
            "" => Ok(default),
            "1" | "true" | "yes" | "on" => Ok(true),
            "0" | "false" | "no" | "off" => Ok(false),
            _ => Err(format!("invalid boolean for {name}: {v}")),
        },
        Err(_) => Ok(default),
    }
}

impl Config {
    pub fn from_env() -> Result<Config, String> {
        let versions = std::env::var("OCPP_VERSIONS")
            .unwrap_or_else(|_| "1.6,2.0.1,2.1".into())
            .split(',')
            .filter(|s| !s.trim().is_empty())
            .map(str::parse)
            .collect::<Result<Vec<OcppVersion>, _>>()?;
        if versions.is_empty() {
            return Err("OCPP_VERSIONS must enable at least one version".into());
        }
        let tls = tls_settings()?;
        if std::env::var("OCPP_TLS_LISTEN")
            .is_ok_and(|v| !v.trim().is_empty() && v.trim() != "none")
            && tls.is_none()
        {
            return Err("OCPP_TLS_LISTEN requires OCPP_TLS_CERT and OCPP_TLS_KEY".into());
        }
        let cpus = std::thread::available_parallelism()
            .map(|n| n.get())
            .unwrap_or(4);
        Ok(Config {
            ocpp_listen: env_addr("OCPP_LISTEN", Some("0.0.0.0:9000"))?,
            ocpp_tls_listen: env_addr("OCPP_TLS_LISTEN", None)?,
            tls,
            cso_name: std::env::var("OCPP_CSO_NAME")
                .ok()
                .filter(|s| !s.is_empty()),
            max_connections: env("OCPP_MAX_CONNECTIONS", 200_000)?,
            api_listen: env("API_LISTEN", "127.0.0.1:8080".parse().unwrap())?,
            database_url: std::env::var("DATABASE_URL")
                .map_err(|_| "DATABASE_URL is required".to_string())?,
            database_max_connections: env("DATABASE_MAX_CONNECTIONS", 20)?,
            node_id: std::env::var("NODE_ID")
                .ok()
                .filter(|s| !s.is_empty())
                .unwrap_or_else(default_node_id),
            versions,
            heartbeat_interval: Duration::from_secs(env("HEARTBEAT_INTERVAL_SECS", 300)?),
            call_timeout: Duration::from_secs(env("CALL_TIMEOUT_SECS", 30)?),
            ping_interval: Duration::from_secs(env("PING_INTERVAL_SECS", 60)?),
            max_message_size: env("MAX_MESSAGE_SIZE", 4 << 20)?,
            ws_deflate: env_bool("WS_DEFLATE", true)?,
            ws_force_client_no_context_takeover: env_bool(
                "WS_FORCE_CLIENT_NO_CONTEXT_TAKEOVER",
                false,
            )?,
            require_password: env_bool("OCPP_REQUIRE_PASSWORD", false)?,
            authorize_unknown_tokens: env_bool("AUTHORIZE_UNKNOWN_TOKENS", false)?,
            api_token: std::env::var("API_TOKEN").ok().filter(|s| !s.is_empty()),
            command_queue_size: env("COMMAND_QUEUE_SIZE", 16)?,
            auth_concurrency: env("AUTH_CONCURRENCY", cpus)?,
            nominal_voltage: env("NOMINAL_VOLTAGE", 230.0)?,
            ev_charging_needs: env_bool("SMART_CHARGING_EV_NEEDS", true)?,
            allow_bidirectional: env_bool("ALLOW_BIDIRECTIONAL", false)?,
            reservation_sweep_interval: Duration::from_secs(
                env("RESERVATION_SWEEP_SECS", 30)?.max(1),
            ),
            default_timezone: std::env::var("DEFAULT_TIMEZONE")
                .ok()
                .filter(|s| !s.is_empty())
                .unwrap_or_else(|| "UTC".into()),
            zoneinfo_dir: env_path("TZDIR").unwrap_or_else(|| PathBuf::from("/usr/share/zoneinfo")),
        })
    }
}

fn default_node_id() -> String {
    std::env::var("HOSTNAME")
        .ok()
        .filter(|s| !s.is_empty())
        .unwrap_or_else(|| format!("node-{}", std::process::id()))
}