Skip to main content

netget/server/ocpp/
frame.rs

1//! OCPP-J RPC frames (CALL, CALLRESULT, CALLERROR) for OCPP 1.6 and 2.0.1, shared by both
2//! roles, with the required-field checks for the core charging workflows.
3use anyhow::{bail, ensure, Context, Result};
4use serde_json::{json, Map, Value};
5
6pub const MAX_MESSAGE_BYTES: usize = 64 * 1024;
7pub const MAX_ID_CHARS: usize = 36;
8
9#[derive(Clone, Copy, Debug, PartialEq, Eq)]
10pub enum Version {
11    V16,
12    V201,
13}
14
15impl Version {
16    pub fn subprotocol(self) -> &'static str {
17        match self {
18            Version::V16 => "ocpp1.6",
19            Version::V201 => "ocpp2.0.1",
20        }
21    }
22    pub fn label(self) -> &'static str {
23        match self {
24            Version::V16 => "1.6",
25            Version::V201 => "2.0.1",
26        }
27    }
28    pub fn from_subprotocol(s: &str) -> Option<Self> {
29        match s.trim() {
30            "ocpp1.6" => Some(Version::V16),
31            "ocpp2.0.1" => Some(Version::V201),
32            _ => None,
33        }
34    }
35    pub fn from_label(s: &str) -> Option<Self> {
36        match s {
37            "1.6" => Some(Version::V16),
38            "2.0.1" => Some(Version::V201),
39            _ => None,
40        }
41    }
42    /// The error code for a missing required field, spelled as each version spells it.
43    pub fn occurrence_code(self) -> &'static str {
44        match self {
45            Version::V16 => "OccurenceConstraintViolation",
46            Version::V201 => "OccurrenceConstraintViolation",
47        }
48    }
49    pub fn error_codes(self) -> &'static [&'static str] {
50        match self {
51            Version::V16 => &[
52                "NotImplemented",
53                "NotSupported",
54                "InternalError",
55                "ProtocolError",
56                "SecurityError",
57                "FormationViolation",
58                "PropertyConstraintViolation",
59                "OccurenceConstraintViolation",
60                "TypeConstraintViolation",
61                "GenericError",
62            ],
63            Version::V201 => &[
64                "FormatViolation",
65                "GenericError",
66                "InternalError",
67                "MessageTypeNotSupported",
68                "NotImplemented",
69                "NotSupported",
70                "OccurrenceConstraintViolation",
71                "PropertyConstraintViolation",
72                "ProtocolError",
73                "RpcFrameworkError",
74                "SecurityError",
75                "TypeConstraintViolation",
76            ],
77        }
78    }
79    /// Malformed frame / not a JSON RPC array.
80    pub fn formation_code(self) -> &'static str {
81        match self {
82            Version::V16 => "FormationViolation",
83            Version::V201 => "FormatViolation",
84        }
85    }
86}
87
88#[derive(Clone, Debug, PartialEq)]
89pub enum Frame {
90    Call {
91        id: String,
92        action: String,
93        payload: Value,
94    },
95    Result {
96        id: String,
97        payload: Value,
98    },
99    Error {
100        id: String,
101        code: String,
102        description: String,
103        details: Value,
104    },
105}
106
107fn id_ok(id: &str) -> Result<()> {
108    ensure!(
109        !id.is_empty() && id.chars().count() <= MAX_ID_CHARS,
110        "message id must be 1..=36 characters"
111    );
112    ensure!(
113        !id.chars().any(|c| c.is_control()),
114        "message id contains a control character"
115    );
116    Ok(())
117}
118
119fn action_ok(action: &str) -> Result<()> {
120    ensure!(
121        !action.is_empty()
122            && action.len() <= 64
123            && action.bytes().all(|b| b.is_ascii_alphanumeric()),
124        "action must be 1..=64 ASCII letters or digits"
125    );
126    Ok(())
127}
128
129pub fn budget_ok(v: &Value) -> bool {
130    crate::utils::json_budget::within_budget(v, MAX_MESSAGE_BYTES * 2, 8192, 32)
131}
132
133/// Parse one WebSocket text message. `Err` carries (message id if known, reason).
134pub fn parse(text: &str) -> std::result::Result<Frame, (Option<String>, String)> {
135    let fail = |id: Option<String>, why: &str| Err((id, why.to_owned()));
136    if text.len() > MAX_MESSAGE_BYTES {
137        return fail(None, "message exceeds 64 KiB");
138    }
139    let value: Value = match serde_json::from_str(text) {
140        Ok(v) => v,
141        Err(_) => return fail(None, "not JSON"),
142    };
143    if !budget_ok(&value) {
144        return fail(None, "message nesting or size exceeds the bound");
145    }
146    let Some(items) = value.as_array() else {
147        return fail(None, "an OCPP-J message is a JSON array");
148    };
149    let id = items.get(1).and_then(Value::as_str).map(str::to_owned);
150    let Some(id_text) = id.clone() else {
151        return fail(None, "message id must be a string");
152    };
153    if let Err(e) = id_ok(&id_text) {
154        return fail(None, &e.to_string());
155    }
156    match items.first().and_then(Value::as_u64) {
157        Some(2) => {
158            if items.len() != 4 {
159                return fail(id, "CALL has four elements");
160            }
161            let Some(action) = items[2].as_str() else {
162                return fail(id, "CALL action must be a string");
163            };
164            if action_ok(action).is_err() {
165                return fail(id, "invalid CALL action name");
166            }
167            if !items[3].is_object() {
168                return fail(id, "CALL payload must be an object");
169            }
170            Ok(Frame::Call {
171                id: id_text,
172                action: action.to_owned(),
173                payload: items[3].clone(),
174            })
175        }
176        Some(3) => {
177            if items.len() != 3 || !items[2].is_object() {
178                return fail(id, "CALLRESULT is [3, id, {payload}]");
179            }
180            Ok(Frame::Result {
181                id: id_text,
182                payload: items[2].clone(),
183            })
184        }
185        Some(4) => {
186            if items.len() != 5 || !items[4].is_object() {
187                return fail(id, "CALLERROR is [4, id, code, description, {details}]");
188            }
189            let (Some(code), Some(description)) = (items[2].as_str(), items[3].as_str()) else {
190                return fail(id, "CALLERROR code and description must be strings");
191            };
192            Ok(Frame::Error {
193                id: id_text,
194                code: code.to_owned(),
195                description: description.to_owned(),
196                details: items[4].clone(),
197            })
198        }
199        _ => fail(id, "message type must be 2, 3 or 4"),
200    }
201}
202
203pub fn encode(frame: &Frame) -> Result<String> {
204    let v = match frame {
205        Frame::Call {
206            id,
207            action,
208            payload,
209        } => {
210            id_ok(id)?;
211            action_ok(action)?;
212            ensure!(payload.is_object(), "payload must be an object");
213            json!([2, id, action, payload])
214        }
215        Frame::Result { id, payload } => {
216            id_ok(id)?;
217            ensure!(payload.is_object(), "payload must be an object");
218            json!([3, id, payload])
219        }
220        Frame::Error {
221            id,
222            code,
223            description,
224            details,
225        } => {
226            id_ok(id)?;
227            ensure!(details.is_object(), "details must be an object");
228            ensure!(
229                description.len() <= 255,
230                "error description exceeds 255 characters"
231            );
232            json!([4, id, code, description, details])
233        }
234    };
235    let text = serde_json::to_string(&v)?;
236    ensure!(
237        text.len() <= MAX_MESSAGE_BYTES && budget_ok(&v),
238        "message exceeds the OCPP-J bound"
239    );
240    Ok(text)
241}
242
243/// Required payload members of the selected core actions, (request, response).
244fn required(
245    version: Version,
246    action: &str,
247) -> Option<(&'static [&'static str], &'static [&'static str])> {
248    Some(match (version, action) {
249        (Version::V16, "BootNotification") => (
250            &["chargePointVendor", "chargePointModel"],
251            &["status", "currentTime", "interval"],
252        ),
253        (Version::V16, "Heartbeat") => (&[], &["currentTime"]),
254        (Version::V16, "StatusNotification") => (&["connectorId", "errorCode", "status"], &[]),
255        (Version::V16, "Authorize") => (&["idTag"], &["idTagInfo"]),
256        (Version::V16, "StartTransaction") => (
257            &["connectorId", "idTag", "meterStart", "timestamp"],
258            &["transactionId", "idTagInfo"],
259        ),
260        (Version::V16, "StopTransaction") => (&["transactionId", "meterStop", "timestamp"], &[]),
261        (Version::V16, "MeterValues") => (&["connectorId", "meterValue"], &[]),
262        (Version::V16, "RemoteStartTransaction") => (&["idTag"], &["status"]),
263        (Version::V16, "RemoteStopTransaction") => (&["transactionId"], &["status"]),
264        (Version::V16, "Reset") => (&["type"], &["status"]),
265        (Version::V16, "ChangeConfiguration") => (&["key", "value"], &["status"]),
266        (Version::V16, "GetConfiguration") => (&[], &[]),
267        (Version::V201, "BootNotification") => (
268            &["chargingStation", "reason"],
269            &["currentTime", "interval", "status"],
270        ),
271        (Version::V201, "Heartbeat") => (&[], &["currentTime"]),
272        (Version::V201, "StatusNotification") => (
273            &["timestamp", "connectorStatus", "evseId", "connectorId"],
274            &[],
275        ),
276        (Version::V201, "Authorize") => (&["idToken"], &["idTokenInfo"]),
277        (Version::V201, "TransactionEvent") => (
278            &[
279                "eventType",
280                "timestamp",
281                "triggerReason",
282                "seqNo",
283                "transactionInfo",
284            ],
285            &[],
286        ),
287        (Version::V201, "MeterValues") => (&["evseId", "meterValue"], &[]),
288        (Version::V201, "RequestStartTransaction") => (&["idToken", "remoteStartId"], &["status"]),
289        (Version::V201, "RequestStopTransaction") => (&["transactionId"], &["status"]),
290        (Version::V201, "Reset") => (&["type"], &["status"]),
291        (Version::V201, "SetVariables") => (&["setVariableData"], &["setVariableResult"]),
292        (Version::V201, "GetVariables") => (&["getVariableData"], &["getVariableResult"]),
293        _ => return None,
294    })
295}
296
297/// Check a request payload; `Err` names the first missing member.
298pub fn check_request(version: Version, action: &str, payload: &Value) -> Result<()> {
299    check(version, action, payload, true)
300}
301/// Check a response payload to `action`.
302pub fn check_response(version: Version, action: &str, payload: &Value) -> Result<()> {
303    check(version, action, payload, false)
304}
305fn check(version: Version, action: &str, payload: &Value, request: bool) -> Result<()> {
306    let obj: &Map<String, Value> = payload.as_object().context("payload must be an object")?;
307    if let Some((req, res)) = required(version, action) {
308        for key in if request { req } else { res } {
309            ensure!(
310                obj.contains_key(*key),
311                "{action} {} requires '{key}'",
312                if request { "request" } else { "response" }
313            );
314        }
315    }
316    Ok(())
317}
318
319pub fn is_core(version: Version, action: &str) -> bool {
320    required(version, action).is_some()
321}
322
323/// A charge point identity from the URL path's last segment.
324pub fn charge_point_id(path: &str) -> Result<String> {
325    let last = path.trim_end_matches('/').rsplit('/').next().unwrap_or("");
326    let id = percent_decode(last)?;
327    ensure!(
328        !id.is_empty() && id.len() <= 48,
329        "charge point id must be 1..=48 characters"
330    );
331    ensure!(
332        id.bytes().all(|b| b.is_ascii_graphic() && b != b'/'),
333        "charge point id must be printable ASCII without '/'"
334    );
335    Ok(id)
336}
337
338fn percent_decode(text: &str) -> Result<String> {
339    let bytes = text.as_bytes();
340    let mut out = Vec::new();
341    let mut i = 0;
342    while i < bytes.len() {
343        if bytes[i] == b'%' {
344            let hex = text
345                .get(i + 1..i + 3)
346                .context("truncated percent-encoding")?;
347            out.push(u8::from_str_radix(hex, 16)?);
348            i += 3;
349        } else {
350            out.push(bytes[i]);
351            i += 1;
352        }
353    }
354    String::from_utf8(out).context("charge point id is not UTF-8")
355}
356
357pub fn error_frame(id: &str, code: &str, description: &str) -> String {
358    encode(&Frame::Error {
359        id: id.into(),
360        code: code.into(),
361        description: description.into(),
362        details: json!({}),
363    })
364    .unwrap_or_else(|_| {
365        // An id that cannot be echoed is answered with a fixed placeholder id.
366        format!("[4,\"-1\",\"{code}\",\"invalid message\",{{}}]")
367    })
368}
369
370pub fn validate_error_code(version: Version, code: &str) -> Result<()> {
371    if !version.error_codes().contains(&code) {
372        bail!("'{code}' is not an OCPP {} error code", version.label());
373    }
374    Ok(())
375}