1use crate::llm::actions::{
2 client_trait::{Client, ClientActionResult},
3 protocol_trait::Protocol,
4 ActionDefinition, ParameterDefinition,
5};
6use crate::protocol::{ConnectContext, EventType};
7use crate::server::ocpp::actions::{
8 action, error_action, parameter, result_action, validate_answer,
9};
10use anyhow::{bail, Result};
11use serde_json::{json, Value};
12use std::sync::LazyLock;
13
14#[derive(Default)]
15pub struct OcppClientProtocol;
16impl OcppClientProtocol {
17 pub fn new() -> Self {
18 Self
19 }
20}
21
22fn call_action() -> ActionDefinition {
23 action(
24 "ocpp_call",
25 "Send a CALL to the central system (one outstanding at a time). Rust assigns the message id, checks the core actions' required request fields and raises ocpp_call_response with the answer.",
26 vec![
27 parameter("action", "string", "BootNotification, Heartbeat, StatusNotification, Authorize, StartTransaction, StopTransaction, MeterValues (1.6) or TransactionEvent (2.0.1), ...", true),
28 parameter("payload", "object", "The request payload, e.g. {\"chargePointVendor\":\"NetGet\",\"chargePointModel\":\"Sim-1\"}", true),
29 ],
30 json!({"type":"ocpp_call","action":"BootNotification","payload":{"chargePointVendor":"NetGet","chargePointModel":"Sim-1"}}),
31 )
32}
33fn disconnect_action() -> ActionDefinition {
34 action(
35 "disconnect",
36 "Close the WebSocket to the central system",
37 vec![],
38 json!({"type":"disconnect"}),
39 )
40}
41fn actions() -> Vec<ActionDefinition> {
42 vec![
43 call_action(),
44 result_action(),
45 error_action(),
46 disconnect_action(),
47 ]
48}
49
50pub static CONNECTED_EVENT: LazyLock<EventType> = LazyLock::new(|| {
51 EventType::new("ocpp_connected", "WebSocket open and subprotocol agreed; a charge point usually sends BootNotification first", call_action().example.clone())
52 .with_parameters(vec![
53 parameter("charge_point_id", "string", "This charge point's identity", true),
54 parameter("ocpp_version", "string", "Negotiated version: 1.6 or 2.0.1", true),
55 ])
56 .with_actions(actions())
57});
58pub static RESPONSE_EVENT: LazyLock<EventType> = LazyLock::new(|| {
59 EventType::new(
60 "ocpp_call_response",
61 "The central system answered this charge point's CALL",
62 json!({"type":"ocpp_call","action":"Heartbeat","payload":{}}),
63 )
64 .with_parameters(vec![
65 parameter("action", "string", "The CALL's action", true),
66 parameter(
67 "message_id",
68 "string",
69 "The id Rust assigned to the CALL this answers (csms-N or cp-N)",
70 true,
71 ),
72 parameter("payload", "object", "CALLRESULT payload", false),
73 parameter(
74 "error",
75 "object",
76 "CALLERROR {code, description, details}",
77 false,
78 ),
79 ])
80 .with_actions(actions())
81});
82pub static CSMS_CALL_EVENT: LazyLock<EventType> = LazyLock::new(|| {
83 EventType::new("ocpp_csms_call", "The central system sent a CALL (RemoteStartTransaction, Reset, ChangeConfiguration, GetConfiguration, SetVariables, ...). Answer with ocpp_call_result or ocpp_call_error.", result_action().example.clone())
84 .with_parameters(vec![
85 parameter("action", "string", "The CALL's action", true),
86 parameter("message_id", "string", "The CALL's id (echoed by Rust)", true),
87 parameter("payload", "object", "The CALL's payload", true),
88 ])
89 .with_actions(actions())
90});
91
92impl Protocol for OcppClientProtocol {
93 fn protocol_name(&self) -> &'static str {
94 "OCPP"
95 }
96 fn stack_name(&self) -> &'static str {
97 "ETH>IP>TCP>HTTP>WebSocket>OCPP"
98 }
99 fn keywords(&self) -> Vec<&'static str> {
100 vec!["ocpp", "charge point", "ev charger simulator", "ocpp-j"]
101 }
102 fn description(&self) -> &'static str {
103 "Simulated OCPP-J 1.6 / 2.0.1 charge point talking to a central system"
104 }
105 fn get_async_actions(&self, _: &crate::state::app_state::AppState) -> Vec<ActionDefinition> {
106 actions()
107 }
108 fn get_sync_actions(&self) -> Vec<ActionDefinition> {
109 vec![]
110 }
111 fn get_event_types(&self) -> Vec<EventType> {
112 vec![
113 CONNECTED_EVENT.clone(),
114 RESPONSE_EVENT.clone(),
115 CSMS_CALL_EVENT.clone(),
116 ]
117 }
118 fn get_startup_parameters(&self) -> Vec<ParameterDefinition> {
119 vec![
120 ParameterDefinition {
121 name: "charge_point_id".into(),
122 type_hint: "string".into(),
123 description: "Identity appended to the WebSocket URL, e.g. CP-001".into(),
124 required: true,
125 example: json!("CP-001"),
126 default: None,
127 },
128 ParameterDefinition {
129 name: "ocpp_version".into(),
130 type_hint: "string".into(),
131 description: "Subprotocol to request: \"1.6\" or \"2.0.1\"".into(),
132 required: false,
133 example: json!("2.0.1"),
134 default: Some(json!(super::DEFAULT_VERSION)),
135 },
136 ParameterDefinition {
137 name: "path_prefix".into(),
138 type_hint: "string".into(),
139 description: "URL path before the charge point id, e.g. /ocpp/".into(),
140 required: false,
141 example: json!("/ocpp/"),
142 default: Some(json!(super::DEFAULT_PREFIX)),
143 },
144 ]
145 }
146 fn metadata(&self) -> crate::protocol::metadata::ProtocolMetadataV2 {
147 use crate::protocol::metadata::*;
148 ProtocolMetadataV2::builder()
149 .state(DevelopmentState::Experimental)
150 .privilege_requirement(PrivilegeRequirement::None)
151 .implementation("tokio-tungstenite client requesting ocpp1.6 or ocpp2.0.1; shared OCPP-J framing and core required-field checks; one outstanding CALL per direction")
152 .llm_control("Which calls the simulated charge point makes (boot, heartbeat, status, authorize, transactions) and how it answers central-system calls")
153 .e2e_testing("tests/client/ocpp: python ocpp 2.1.0 central systems (independent, schema-validating) over 1.6 and 2.0.1, including CSMS-initiated calls; NetGet pair and refusals")
154 .notes("Plain ws:// only, no security profiles. No meter or connector simulation in Rust: the handler supplies every payload. 64 KiB messages.")
155 .max_inbound_bytes(crate::server::ocpp::frame::MAX_MESSAGE_BYTES)
156 .build()
157 }
158 fn example_prompt(&self) -> &'static str {
159 "Simulate charge point CP-001 booting against the OCPP 1.6 central system at 127.0.0.1:9000"
160 }
161 fn get_startup_examples(&self) -> crate::llm::actions::StartupExamples {
162 let llm = json!({"type":"open_client","protocol":"ocpp","remote_addr":"127.0.0.1:9000","instruction":"Boot, then send a heartbeat","startup_params":{"charge_point_id":"CP-001"}});
163 let mut static_example = llm.clone();
164 static_example["event_handlers"] = json!([
165 {"event_pattern":"ocpp_connected","handler":{"type":"static","actions":[call_action().example]}},
166 {"event_pattern":"ocpp_call_response","handler":{"type":"static","actions":[]}},
167 {"event_pattern":"ocpp_csms_call","handler":{"type":"static","actions":[{"type":"ocpp_call_error","code":"NotSupported"}]}}
168 ]);
169 let mut scripted = static_example.clone();
170 scripted["event_handlers"][1]["handler"] = json!({"type":"script","language":"python","code":"import json\nprint(json.dumps({'actions':[]}))"});
171 crate::llm::actions::StartupExamples::new(llm, scripted, static_example)
172 }
173 fn group_name(&self) -> &'static str {
174 "Industrial"
175 }
176}
177
178impl Client for OcppClientProtocol {
179 fn connect(
180 &self,
181 ctx: ConnectContext,
182 ) -> std::pin::Pin<Box<dyn std::future::Future<Output = Result<std::net::SocketAddr>> + Send>>
183 {
184 Box::pin(super::connect(ctx))
185 }
186 fn execute_action(&self, v: Value) -> Result<ClientActionResult> {
187 match v["type"].as_str() {
188 Some(name @ ("ocpp_call" | "ocpp_call_result" | "ocpp_call_error")) => {
189 validate_answer(&v)?;
190 Ok(ClientActionResult::Custom {
191 name: name.into(),
192 data: v,
193 })
194 }
195 Some("disconnect") => Ok(ClientActionResult::Disconnect),
196 _ => bail!("Unknown OCPP client action"),
197 }
198 }
199}