1use 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 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 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
133pub 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
243fn 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
297pub fn check_request(version: Version, action: &str, payload: &Value) -> Result<()> {
299 check(version, action, payload, true)
300}
301pub 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
323pub 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 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}