Skip to main content

ocpp_service/
config.rs

1// SPDX-License-Identifier: Apache-2.0
2// Copyright 2026 Kevin Vavelin, Codetics (https://codetics.fr)
3
4//! Configuration from environment variables.
5
6use std::net::SocketAddr;
7use std::path::PathBuf;
8use std::time::Duration;
9
10use ocpp_core::OcppVersion;
11
12use crate::tls::TlsSettings;
13
14#[derive(Debug, Clone)]
15pub struct Config {
16    /// Plain (unencrypted) listener for charging stations; `none` disables it. Env: `OCPP_LISTEN`.
17    pub ocpp_listen: Option<SocketAddr>,
18    /// TLS listener for charging stations (Security Profiles 2 and 3). Env: `OCPP_TLS_LISTEN`.
19    pub ocpp_tls_listen: Option<SocketAddr>,
20    /// Env: `OCPP_TLS_CERT`/`OCPP_TLS_KEY` (+ optional `OCPP_TLS_CERT2`/`OCPP_TLS_KEY2`),
21    /// `OCPP_TLS_CLIENT_CA`, `OCPP_TLS_CIPHERS`.
22    pub tls: Option<TlsSettings>,
23    /// CSO name expected in the O RDN of station certificates (A00.FR.404). Env: `OCPP_CSO_NAME`.
24    pub cso_name: Option<String>,
25    /// Maximum concurrent station connections per listener. Env: `OCPP_MAX_CONNECTIONS`.
26    pub max_connections: usize,
27    /// Listener for the internal HTTP API. Env: `API_LISTEN`.
28    pub api_listen: SocketAddr,
29    /// Env: `DATABASE_URL`.
30    pub database_url: String,
31    /// Env: `DATABASE_MAX_CONNECTIONS`.
32    pub database_max_connections: u32,
33    /// Identifies this instance in the `charging_stations.connected_node` column. Env: `NODE_ID`.
34    pub node_id: String,
35    /// OCPP versions offered to stations. Env: `OCPP_VERSIONS` (e.g. `1.6,2.0.1,2.1`).
36    pub versions: Vec<OcppVersion>,
37    /// Heartbeat interval returned in BootNotification responses. Env: `HEARTBEAT_INTERVAL_SECS`.
38    pub heartbeat_interval: Duration,
39    /// Timeout for CSMS-initiated calls. Env: `CALL_TIMEOUT_SECS`.
40    pub call_timeout: Duration,
41    /// Server-side WebSocket ping interval when the link is idle; 0 disables. Env: `PING_INTERVAL_SECS`.
42    pub ping_interval: Duration,
43    /// Env: `MAX_MESSAGE_SIZE` (bytes).
44    pub max_message_size: usize,
45    /// RFC 7692 permessage-deflate. Env: `WS_DEFLATE`.
46    pub ws_deflate: bool,
47    /// Impose client_no_context_takeover to save memory. Env: `WS_FORCE_CLIENT_NO_CONTEXT_TAKEOVER`.
48    pub ws_force_client_no_context_takeover: bool,
49    /// Reject stations that have no password configured (Security Profile ≥ 1 everywhere). Env: `OCPP_REQUIRE_PASSWORD`.
50    pub require_password: bool,
51    /// Accept id tokens that are not in the database. Env: `AUTHORIZE_UNKNOWN_TOKENS`.
52    pub authorize_unknown_tokens: bool,
53    /// Bearer token required by the HTTP API, if set. Env: `API_TOKEN`.
54    pub api_token: Option<String>,
55    /// Max queued commands per station connection. Env: `COMMAND_QUEUE_SIZE`.
56    pub command_queue_size: usize,
57    /// Max concurrent password verifications (argon2 is memory-hard). Env: `AUTH_CONCURRENCY`.
58    pub auth_concurrency: usize,
59    /// Nominal phase voltage used to convert A ↔ W in composite schedules. Env: `NOMINAL_VOLTAGE`.
60    pub nominal_voltage: f64,
61    /// Answer ISO 15118 NotifyEVChargingNeeds with a TxProfile (K15–K19). Env: `SMART_CHARGING_EV_NEEDS`.
62    pub ev_charging_needs: bool,
63    /// Accept bidirectional energy transfer modes requested by EVs (OCPP 2.1). Env: `ALLOW_BIDIRECTIONAL`.
64    pub allow_bidirectional: bool,
65    /// How often expired reservations are closed. Env: `RESERVATION_SWEEP_SECS`.
66    pub reservation_sweep_interval: Duration,
67    /// Time zone of stations without their own (tariff conditions use local time). Env: `DEFAULT_TIMEZONE`.
68    pub default_timezone: String,
69    /// Directory of the IANA time zone database. Env: `TZDIR`.
70    pub zoneinfo_dir: PathBuf,
71}
72
73fn env_addr(name: &str, default: Option<&str>) -> Result<Option<SocketAddr>, String> {
74    let v = std::env::var(name).ok().filter(|v| !v.trim().is_empty());
75    match v.as_deref().or(default) {
76        None => Ok(None),
77        Some(v) if v.trim().eq_ignore_ascii_case("none") => Ok(None),
78        Some(v) => v
79            .trim()
80            .parse()
81            .map(Some)
82            .map_err(|_| format!("invalid address for {name}: {v}")),
83    }
84}
85
86fn env_path(name: &str) -> Option<PathBuf> {
87    std::env::var(name)
88        .ok()
89        .filter(|v| !v.trim().is_empty())
90        .map(PathBuf::from)
91}
92
93fn tls_settings() -> Result<Option<TlsSettings>, String> {
94    let mut identities = Vec::new();
95    for (c, k) in [
96        ("OCPP_TLS_CERT", "OCPP_TLS_KEY"),
97        ("OCPP_TLS_CERT2", "OCPP_TLS_KEY2"),
98    ] {
99        match (env_path(c), env_path(k)) {
100            (Some(cert), Some(key)) => identities.push((cert, key)),
101            (None, None) => {}
102            _ => return Err(format!("{c} and {k} must be set together")),
103        }
104    }
105    if identities.is_empty() {
106        return Ok(None);
107    }
108    Ok(Some(TlsSettings {
109        identities,
110        client_ca: env_path("OCPP_TLS_CLIENT_CA"),
111        ciphers: std::env::var("OCPP_TLS_CIPHERS")
112            .ok()
113            .filter(|v| !v.is_empty())
114            .unwrap_or_else(|| crate::tls::DEFAULT_CIPHERS.into()),
115    }))
116}
117
118fn env<T: std::str::FromStr>(name: &str, default: T) -> Result<T, String> {
119    match std::env::var(name) {
120        Ok(v) if !v.trim().is_empty() => v
121            .trim()
122            .parse()
123            .map_err(|_| format!("invalid value for {name}: {v}")),
124        _ => Ok(default),
125    }
126}
127
128fn env_bool(name: &str, default: bool) -> Result<bool, String> {
129    match std::env::var(name) {
130        Ok(v) => match v.trim().to_ascii_lowercase().as_str() {
131            "" => Ok(default),
132            "1" | "true" | "yes" | "on" => Ok(true),
133            "0" | "false" | "no" | "off" => Ok(false),
134            _ => Err(format!("invalid boolean for {name}: {v}")),
135        },
136        Err(_) => Ok(default),
137    }
138}
139
140impl Config {
141    pub fn from_env() -> Result<Config, String> {
142        let versions = std::env::var("OCPP_VERSIONS")
143            .unwrap_or_else(|_| "1.6,2.0.1,2.1".into())
144            .split(',')
145            .filter(|s| !s.trim().is_empty())
146            .map(str::parse)
147            .collect::<Result<Vec<OcppVersion>, _>>()?;
148        if versions.is_empty() {
149            return Err("OCPP_VERSIONS must enable at least one version".into());
150        }
151        let tls = tls_settings()?;
152        if std::env::var("OCPP_TLS_LISTEN")
153            .is_ok_and(|v| !v.trim().is_empty() && v.trim() != "none")
154            && tls.is_none()
155        {
156            return Err("OCPP_TLS_LISTEN requires OCPP_TLS_CERT and OCPP_TLS_KEY".into());
157        }
158        let cpus = std::thread::available_parallelism()
159            .map(|n| n.get())
160            .unwrap_or(4);
161        Ok(Config {
162            ocpp_listen: env_addr("OCPP_LISTEN", Some("0.0.0.0:9000"))?,
163            ocpp_tls_listen: env_addr("OCPP_TLS_LISTEN", None)?,
164            tls,
165            cso_name: std::env::var("OCPP_CSO_NAME")
166                .ok()
167                .filter(|s| !s.is_empty()),
168            max_connections: env("OCPP_MAX_CONNECTIONS", 200_000)?,
169            api_listen: env("API_LISTEN", "127.0.0.1:8080".parse().unwrap())?,
170            database_url: std::env::var("DATABASE_URL")
171                .map_err(|_| "DATABASE_URL is required".to_string())?,
172            database_max_connections: env("DATABASE_MAX_CONNECTIONS", 20)?,
173            node_id: std::env::var("NODE_ID")
174                .ok()
175                .filter(|s| !s.is_empty())
176                .unwrap_or_else(default_node_id),
177            versions,
178            heartbeat_interval: Duration::from_secs(env("HEARTBEAT_INTERVAL_SECS", 300)?),
179            call_timeout: Duration::from_secs(env("CALL_TIMEOUT_SECS", 30)?),
180            ping_interval: Duration::from_secs(env("PING_INTERVAL_SECS", 60)?),
181            max_message_size: env("MAX_MESSAGE_SIZE", 4 << 20)?,
182            ws_deflate: env_bool("WS_DEFLATE", true)?,
183            ws_force_client_no_context_takeover: env_bool(
184                "WS_FORCE_CLIENT_NO_CONTEXT_TAKEOVER",
185                false,
186            )?,
187            require_password: env_bool("OCPP_REQUIRE_PASSWORD", false)?,
188            authorize_unknown_tokens: env_bool("AUTHORIZE_UNKNOWN_TOKENS", false)?,
189            api_token: std::env::var("API_TOKEN").ok().filter(|s| !s.is_empty()),
190            command_queue_size: env("COMMAND_QUEUE_SIZE", 16)?,
191            auth_concurrency: env("AUTH_CONCURRENCY", cpus)?,
192            nominal_voltage: env("NOMINAL_VOLTAGE", 230.0)?,
193            ev_charging_needs: env_bool("SMART_CHARGING_EV_NEEDS", true)?,
194            allow_bidirectional: env_bool("ALLOW_BIDIRECTIONAL", false)?,
195            reservation_sweep_interval: Duration::from_secs(
196                env("RESERVATION_SWEEP_SECS", 30)?.max(1),
197            ),
198            default_timezone: std::env::var("DEFAULT_TIMEZONE")
199                .ok()
200                .filter(|s| !s.is_empty())
201                .unwrap_or_else(|| "UTC".into()),
202            zoneinfo_dir: env_path("TZDIR").unwrap_or_else(|| PathBuf::from("/usr/share/zoneinfo")),
203        })
204    }
205}
206
207fn default_node_id() -> String {
208    std::env::var("HOSTNAME")
209        .ok()
210        .filter(|s| !s.is_empty())
211        .unwrap_or_else(|| format!("node-{}", std::process::id()))
212}