Skip to main content

netget/server/ocpp/
actions.rs

1use super::frame;
2use crate::llm::actions::{
3    protocol_trait::{ActionResult, Protocol, Server},
4    ActionDefinition, Parameter, ParameterDefinition,
5};
6use crate::protocol::log_template::LogTemplate;
7use crate::protocol::{EventType, SpawnContext};
8use crate::state::app_state::AppState;
9use anyhow::{bail, ensure, Context, Result};
10use serde_json::{json, Value};
11use std::sync::LazyLock;
12
13#[derive(Default)]
14pub struct OcppProtocol;
15impl OcppProtocol {
16    pub fn new() -> Self {
17        Self
18    }
19}
20
21pub fn parameter(name: &str, kind: &str, description: &str, required: bool) -> Parameter {
22    Parameter {
23        name: name.into(),
24        type_hint: kind.into(),
25        description: description.into(),
26        required,
27    }
28}
29
30pub fn action(
31    name: &str,
32    description: &str,
33    parameters: Vec<Parameter>,
34    example: Value,
35) -> ActionDefinition {
36    let log_template = match name {
37        "ocpp_call_result" => LogTemplate::new().with_info("-> OCPP CALLRESULT"),
38        "ocpp_call_error" => LogTemplate::new().with_info("-> OCPP CALLERROR {code}"),
39        "ocpp_send_call" | "ocpp_call" => LogTemplate::new().with_info("-> OCPP CALL {action}"),
40        _ => LogTemplate::new().with_info(format!("-> OCPP {name}")),
41    };
42    ActionDefinition {
43        name: name.into(),
44        description: description.into(),
45        parameters,
46        example,
47        log_template: Some(log_template),
48    }
49}
50
51pub fn result_action() -> ActionDefinition {
52    action(
53        "ocpp_call_result",
54        "Answer the pending CALL with a CALLRESULT. Rust echoes the message id and checks the core actions' required response fields (e.g. BootNotification needs status, currentTime, interval).",
55        vec![parameter("payload", "object", "The response payload for the action, e.g. {\"status\":\"Accepted\",\"currentTime\":\"2026-01-01T00:00:00Z\",\"interval\":300}", true)],
56        json!({"type":"ocpp_call_result","payload":{"status":"Accepted","currentTime":"2026-01-01T00:00:00Z","interval":300}}),
57    )
58}
59
60pub fn error_action() -> ActionDefinition {
61    action(
62        "ocpp_call_error",
63        "Answer the pending CALL with a CALLERROR.",
64        vec![
65            parameter("code", "string", "OCPP error code: NotImplemented, NotSupported, InternalError, ProtocolError, SecurityError, PropertyConstraintViolation, TypeConstraintViolation, GenericError (and the version's formation/occurrence spellings)", true),
66            parameter("description", "string", "Short human-readable description (≤255 characters)", false),
67            parameter("details", "object", "Extra error details object", false),
68        ],
69        json!({"type":"ocpp_call_error","code":"NotSupported","description":"not offered here"}),
70    )
71}
72
73fn send_call_action() -> ActionDefinition {
74    action(
75        "ocpp_send_call",
76        "Send a CSMS-initiated CALL to this charge point (one outstanding at a time); the reply raises ocpp_call_response.",
77        vec![
78            parameter("action", "string", "OCPP action, e.g. RemoteStartTransaction, Reset, ChangeConfiguration (1.6) or RequestStartTransaction, SetVariables (2.0.1)", true),
79            parameter("payload", "object", "The request payload", true),
80        ],
81        json!({"type":"ocpp_send_call","action":"Reset","payload":{"type":"Soft"}}),
82    )
83}
84
85fn disconnect_action() -> ActionDefinition {
86    action(
87        "disconnect",
88        "Close this charge point's WebSocket",
89        vec![],
90        json!({"type":"disconnect"}),
91    )
92}
93
94pub static CALL_EVENT: LazyLock<EventType> = LazyLock::new(|| {
95    EventType::new(
96        "ocpp_call",
97        "A charge point sent a CALL (BootNotification, Heartbeat, StatusNotification, Authorize, StartTransaction/StopTransaction or TransactionEvent, MeterValues, ...). Answer with a CALLRESULT or CALLERROR; there is no charging database in Rust.",
98        result_action().example.clone(),
99    )
100    .with_parameters(vec![
101        parameter("charge_point_id", "string", "Identity from the WebSocket URL", true),
102        parameter("ocpp_version", "string", "Negotiated version: 1.6 or 2.0.1", true),
103        parameter("action", "string", "The CALL's action name", true),
104        parameter("message_id", "string", "The CALL's unique id (echoed by Rust)", true),
105        parameter("payload", "object", "The CALL's payload", true),
106    ])
107    .with_actions(vec![result_action(), error_action()])
108});
109
110pub static CALL_RESPONSE_EVENT: LazyLock<EventType> = LazyLock::new(|| {
111    EventType::new(
112        "ocpp_call_response",
113        "A charge point answered a CSMS-initiated CALL",
114        json!({"type":"ocpp_send_call","action":"GetConfiguration","payload":{}}),
115    )
116    .with_parameters(vec![
117        parameter(
118            "charge_point_id",
119            "string",
120            "Identity from the WebSocket URL",
121            true,
122        ),
123        parameter("action", "string", "The CALL's action", true),
124        parameter(
125            "message_id",
126            "string",
127            "The id Rust assigned to the CALL this answers (csms-N or cp-N)",
128            true,
129        ),
130        parameter("payload", "object", "CALLRESULT payload", false),
131        parameter(
132            "error",
133            "object",
134            "CALLERROR {code, description, details}",
135            false,
136        ),
137    ])
138    .with_actions(vec![send_call_action(), disconnect_action()])
139});
140
141impl Protocol for OcppProtocol {
142    fn protocol_name(&self) -> &'static str {
143        "OCPP"
144    }
145    fn stack_name(&self) -> &'static str {
146        "ETH>IP>TCP>HTTP>WebSocket>OCPP"
147    }
148    fn description(&self) -> &'static str {
149        "OCPP-J 1.6 / 2.0.1 charging station management system (CSMS) over WebSocket"
150    }
151    fn keywords(&self) -> Vec<&'static str> {
152        vec![
153            "ocpp",
154            "ocpp-j",
155            "ev charging",
156            "csms",
157            "central system",
158            "charge point",
159        ]
160    }
161    fn get_async_actions(&self, _: &AppState) -> Vec<ActionDefinition> {
162        vec![send_call_action(), disconnect_action()]
163    }
164    fn get_sync_actions(&self) -> Vec<ActionDefinition> {
165        vec![result_action(), error_action()]
166    }
167    fn get_event_types(&self) -> Vec<EventType> {
168        vec![CALL_EVENT.clone(), CALL_RESPONSE_EVENT.clone()]
169    }
170    fn get_startup_parameters(&self) -> Vec<ParameterDefinition> {
171        vec![
172            ParameterDefinition {
173                name: "ocpp_versions".into(),
174                type_hint: "array".into(),
175                description: "Versions offered in subprotocol negotiation, in preference order: \"1.6\" and/or \"2.0.1\"".into(),
176                required: false,
177                example: json!(["2.0.1"]),
178                default: Some(json!(super::DEFAULT_VERSIONS)),
179            },
180            ParameterDefinition {
181                name: "call_timeout_secs".into(),
182                type_hint: "number".into(),
183                description: "Seconds (1..=300) a CSMS-initiated CALL waits for its answer".into(),
184                required: false,
185                example: json!(30),
186                default: Some(json!(super::CALL_TIMEOUT.as_secs())),
187            },
188        ]
189    }
190    fn metadata(&self) -> crate::protocol::metadata::ProtocolMetadataV2 {
191        use crate::protocol::metadata::*;
192        ProtocolMetadataV2::builder()
193            .state(DevelopmentState::Experimental)
194            .privilege_requirement(PrivilegeRequirement::None)
195            .implementation("tokio-tungstenite WebSocket with ocpp1.6/ocpp2.0.1 subprotocol negotiation; Rust owns RPC framing, message-id correlation, one outstanding CALL per direction and required-field checks for the core workflows")
196            .llm_control("Every CALLRESULT/CALLERROR to charge-point calls (boot acceptance, authorization, transaction ids, timers) and CSMS-initiated calls such as remote start, reset or configuration")
197            .e2e_testing("tests/server/ocpp: python ocpp 2.1.0 (independent, schema-validating) charge points over 1.6 and 2.0.1 run boot/heartbeat/status/authorize/transaction workflows and answer CSMS calls; frame and bound tests")
198            .notes("No charging database or payload schema engine: Rust checks frame structure and the core actions' required fields; full JSON-schema validation is the peer's job. Plain ws:// (put TLS and security profiles in front). 64 KiB messages.")
199            .answers_on_failure()
200            .max_inbound_bytes(frame::MAX_MESSAGE_BYTES)
201            .build()
202    }
203    fn example_prompt(&self) -> &'static str {
204        "OCPP 1.6 central system on port 9000 that accepts every charge point boot"
205    }
206    fn get_startup_examples(&self) -> crate::llm::actions::StartupExamples {
207        let llm = json!({"type":"open_server","base_stack":"ocpp","port":9000,"instruction":"Accept every BootNotification with a 300 s heartbeat; authorize tag ABC only"});
208        let mut static_example = llm.clone();
209        static_example["event_handlers"] = json!([{"event_pattern":"ocpp_call","handler":{"type":"static","actions":[{"type":"ocpp_call_error","code":"NotSupported","description":"static policy"}]}}]);
210        let mut scripted = llm.clone();
211        scripted["event_handlers"] = json!([{"event_pattern":"ocpp_call","handler":{"type":"script","language":"python","code":"import json,sys\ne=json.load(sys.stdin)['event']\nif e['action']=='Heartbeat':\n    a={'type':'ocpp_call_result','payload':{'currentTime':'2026-01-01T00:00:00Z'}}\nelse:\n    a={'type':'ocpp_call_error','code':'NotImplemented'}\nprint(json.dumps({'actions':[a]}))"}}]);
212        crate::llm::actions::StartupExamples::new(llm, scripted, static_example)
213    }
214    fn group_name(&self) -> &'static str {
215        "Industrial"
216    }
217}
218
219impl Server for OcppProtocol {
220    fn spawn(
221        &self,
222        ctx: SpawnContext,
223    ) -> std::pin::Pin<Box<dyn std::future::Future<Output = Result<std::net::SocketAddr>> + Send>>
224    {
225        Box::pin(super::spawn(ctx))
226    }
227    fn execute_action(&self, v: Value) -> Result<ActionResult> {
228        match v["type"].as_str() {
229            Some(name @ ("ocpp_call_result" | "ocpp_call_error" | "ocpp_send_call")) => {
230                validate_answer(&v)?;
231                Ok(ActionResult::Custom {
232                    name: name.into(),
233                    data: v,
234                })
235            }
236            Some("disconnect") => Ok(ActionResult::CloseConnection),
237            _ => bail!("Unknown OCPP server action"),
238        }
239    }
240}
241
242/// Shape checks that need no version; version-specific checks happen at send time.
243pub fn validate_answer(v: &Value) -> Result<()> {
244    match v["type"].as_str() {
245        Some("ocpp_call_result") => ensure!(
246            v["payload"].is_object() && frame::budget_ok(&v["payload"]),
247            "payload must be a bounded object"
248        ),
249        Some("ocpp_call_error") => {
250            let code = v["code"].as_str().context("code is required")?;
251            ensure!(!code.is_empty() && code.len() <= 64, "invalid error code");
252            if let Some(d) = v.get("description").filter(|d| !d.is_null()) {
253                ensure!(
254                    d.as_str().is_some_and(|s| s.len() <= 255),
255                    "description must be a string of ≤255 characters"
256                );
257            }
258            ensure!(
259                v.get("details")
260                    .is_none_or(|d| d.is_null() || d.is_object()),
261                "details must be an object"
262            );
263        }
264        Some("ocpp_send_call" | "ocpp_call") => {
265            let action = v["action"].as_str().context("action is required")?;
266            ensure!(
267                !action.is_empty()
268                    && action.len() <= 64
269                    && action.bytes().all(|b| b.is_ascii_alphanumeric()),
270                "action must be ASCII letters or digits"
271            );
272            ensure!(
273                v["payload"].is_object() && frame::budget_ok(&v["payload"]),
274                "payload must be a bounded object"
275            );
276        }
277        _ => bail!("unknown OCPP answer"),
278    }
279    Ok(())
280}