Skip to main content

netget/client/ocpp/
actions.rs

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}