From 8c4d47973ec60daf7c78831781498084c4a02a80 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 27 Sep 2026 14:02:05 +0000 Subject: [PATCH 1/2] feat(ocpp-cp): emit v201 TransactionEvent(CablePluggedIn) for the EVConnected plug-in phase (M7) Port the OCPP 2.0.1 cable-plugged, pre-authorization transaction phase into the CP simulator: a new V201-only driver hook drives a connector into the EVConnected state and originates one TransactionEvent(triggerReason = CablePluggedIn, chargingState = EVConnected). This is the companion #577 (landed as #580) deferred, and extends #577's state-transition plumbing rather than duplicating it: - v201_transaction::transaction_event_state_transition: a new pub(crate) core that both the ChargingStateChanged builder (#577) and the new CablePluggedIn builder delegate to (same interim-Updated shape: new chargingState, no meterValue, no idToken). - transaction_event_cable_plugged_in: the new pure builder. - ChargePoint::plug_in_cable(evse_id): the new driver hook, sharing the resolve -> compare-and-set -> emit core (emit_transaction_state_change) with transition_charging_state. Redundant / no-transaction cases are inert; a 1.6J station refuses with OcppError::NotSupported. Reconciling the full TxStartPoint variants (having the plug-in genuinely open the transaction before authorization, Started vs Updated per start point) is a deliberate follow-up, out of this slice's scope. Closes #579. Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_01RR4rxKX9fJungJiibgJZ5S --- crates/ocpp-cp/src/lib.rs | 206 ++++++++++++++++++++++++- crates/ocpp-cp/src/v201_transaction.rs | 114 +++++++++++++- 2 files changed, 312 insertions(+), 8 deletions(-) diff --git a/crates/ocpp-cp/src/lib.rs b/crates/ocpp-cp/src/lib.rs index 0cd2413..b70c44b 100644 --- a/crates/ocpp-cp/src/lib.rs +++ b/crates/ocpp-cp/src/lib.rs @@ -165,7 +165,7 @@ use ocpp_types::v201::{ RegistrationStatusEnumType, ReportDataType, RequestStartStopStatusEnumType, ReservationUpdateStatusEnumType, ReserveNowStatusEnumType, ResetStatusEnumType, SetNetworkProfileStatusEnumType, SetVariableResultType, SetVariableStatusEnumType, - StatusInfoType, TriggerMessageStatusEnumType, UnlockStatusEnumType, + StatusInfoType, TriggerMessageStatusEnumType, TriggerReasonEnumType, UnlockStatusEnumType, UpdateFirmwareStatusEnumType, UploadLogStatusEnumType, }; use serde::{Deserialize, Serialize}; @@ -251,10 +251,12 @@ pub enum MonitorTripOutcome { } /// The result of driving a live 2.0.1 transaction across a charging-state -/// transition via [`ChargePoint::transition_charging_state`] (Issue #577). +/// transition — via [`ChargePoint::transition_charging_state`] (the interim +/// suspend/resume states, Issue #577) or [`ChargePoint::plug_in_cable`] (the +/// pre-authorization `EVConnected` plug-in phase, Issue #579). /// -/// A transition either advanced a real change — emitting one -/// `TransactionEvent(Updated, triggerReason = ChargingStateChanged)` — or was a +/// A transition either advanced a real change — emitting one interim +/// `TransactionEvent(Updated)` under the relevant `triggerReason` — or was a /// benign no-op (the connector was already in the target state, or no /// transaction is running on the EVSE). The outcome is returned (never /// panicked) so a caller or test can assert exactly what the injection did @@ -7292,6 +7294,93 @@ impl ChargePoint { } } + self.emit_transaction_state_change( + evse_id, + new_state, + TriggerReasonEnumType::ChargingStateChanged, + ) + .await + } + + /// Drive a live 2.0.1 transaction into the **cable-plugged, pre-authorization** + /// [`EVConnected`](ChargingStateEnumType::EVConnected) phase, originating one + /// `TransactionEvent(triggerReason = CablePluggedIn, chargingState = + /// EVConnected)` (Issue #579). + /// + /// The companion to [`transition_charging_state`](Self::transition_charging_state): + /// where that hook drives the interim `Charging ↔ SuspendedEV ↔ SuspendedEVSE` + /// suspend/resume transitions, this one models plugging the cable on a station + /// whose `TxStartPoint` includes `EVConnected` — the "cable in, not yet + /// authorized/charging" phase plug-and-charge and delayed-authorization + /// conformance exercises. Both share the same behavior-injection contract and + /// the same [`emit_transaction_state_change`](Self::emit_transaction_state_change) + /// core, differing only in the trigger reason and target state. + /// + /// # Outcome + /// + /// - **No transaction on `evse_id`** → [`ChargingStateChangeOutcome::NoTransaction`], + /// inert — no CALL, no `seqNo` burned. A non-positive / out-of-range + /// `evse_id` matches nothing and lands here. + /// - **Already `EVConnected`** → [`ChargingStateChangeOutcome::Redundant`], a + /// silent no-op — no CALL, no `seqNo` burned (the hysteresis guarantee). + /// - **A real transition** → one `TransactionEvent(CablePluggedIn)` is emitted + /// and, on the CSMS's ack, [`ChargingStateChangeOutcome::Emitted`] reports + /// the `seqNo` used, drawn from the transaction's monotonic stream so it + /// interleaves cleanly with the other events and stays strictly between + /// `Started` and `Ended`. + /// + /// # Scope + /// + /// This drives the `EVConnected` / `CablePluggedIn` plug-in phase against an + /// already-open transaction context, reusing #577's state-transition + /// plumbing. Reconciling the full `TxStartPoint` variants (Authorized / + /// PowerPathClosed / EnergyTransfer) — i.e. having the plug-in genuinely + /// *open* the transaction before authorization — is a deliberate follow-up, + /// out of this slice's scope. + /// + /// V201-only: `TransactionEvent` and its `chargingState` are 2.0.1 + /// constructs, so a call on a `V16J` station is refused with + /// [`OcppError::NotSupported`]. + pub async fn plug_in_cable(&self, evse_id: i32) -> OcppResult { + if self.config.protocol_version != OcppVersion::V201 { + return Err(OcppError::NotSupported { + feature: + "TransactionEvent(CablePluggedIn) is an OCPP 2.0.1 message; not available on a 1.6J station" + .to_string(), + }); + } + + self.emit_transaction_state_change( + evse_id, + ChargingStateEnumType::EVConnected, + TriggerReasonEnumType::CablePluggedIn, + ) + .await + } + + /// Shared core behind [`transition_charging_state`](Self::transition_charging_state) + /// (#577) and [`plug_in_cable`](Self::plug_in_cable) (#579): resolve the live + /// transaction on `evse_id`, compare-and-set its `chargingState`, and — only + /// on a *real* change — emit one interim `TransactionEvent(Updated)` under + /// `trigger_reason` carrying `new_state`. + /// + /// The caller is responsible for the version guard and (where applicable) + /// validating `new_state` against the hook's allowed set; this core assumes a + /// 2.0.1 station and a caller-approved target state. + /// + /// The compare-and-emit runs under the session's own `charging_state` + /// [`Mutex`], not the `v201_sessions` map lock, so a redundant transition is + /// a true no-op and two concurrent transitions can't both emit; holding it + /// across the send serializes only this connector's state changes (inherently + /// sequential), never the whole station. On a transport/timeout/CALLERROR + /// failure the modeled state is left unchanged (the `seqNo` is spent, as on + /// any emitted event) and the error propagates as an [`OcppError`]. + async fn emit_transaction_state_change( + &self, + evse_id: i32, + new_state: ChargingStateEnumType, + trigger_reason: TriggerReasonEnumType, + ) -> OcppResult { // Resolve the live transaction on this EVSE. `active_transactions` maps // transactionId → ConnectorId, and the v201 store keys by EVSE id // (= connector value) in the simulator's flat topology, so the EVSE is @@ -7339,9 +7428,10 @@ impl ChargePoint { evse_id, connector_id: 1, }; - let request = v201_transaction::transaction_event_charging_state_changed( + let request = v201_transaction::transaction_event_state_transition( &session_ref, seq_no, + trigger_reason, new_state, &v201_now(), ); @@ -7355,8 +7445,9 @@ impl ChargePoint { info!( evse_id, state = ?new_state, + trigger = ?trigger_reason, seq_no, - "originated TransactionEvent(ChargingStateChanged); CSMS acknowledged the charging-state transition" + "originated TransactionEvent charging-state transition; CSMS acknowledged" ); Ok(ChargingStateChangeOutcome::Emitted { seq_no }) @@ -13582,6 +13673,17 @@ mod tests { /// the session opens `Charging`, both matching `open_transaction`. Returns the /// transaction id. async fn insert_live_v201_session(cp: &ChargePoint, evse_id: i32) -> i32 { + insert_live_v201_session_in_state(cp, evse_id, ChargingStateEnumType::Charging).await + } + + /// As [`insert_live_v201_session`], but seeds the session's `chargingState` + /// so a test can exercise a transition *into* a target state (e.g. seed + /// `EVConnected` to assert the plug-in hook's redundant no-op). + async fn insert_live_v201_session_in_state( + cp: &ChargePoint, + evse_id: i32, + initial_state: ChargingStateEnumType, + ) -> i32 { let transaction_id = 700 + evse_id; let connector_id = ConnectorId::new(evse_id as u32).unwrap(); cp.active_transactions @@ -13595,7 +13697,7 @@ mod tests { next_seq_no: Arc::new(AtomicI32::new(1)), authorized: Arc::new(AtomicBool::new(true)), group_id_token: None, - charging_state: Arc::new(Mutex::new(ChargingStateEnumType::Charging)), + charging_state: Arc::new(Mutex::new(initial_state)), }, ); transaction_id @@ -13775,6 +13877,96 @@ mod tests { )); } + // --- plug_in_cable() → TransactionEvent(CablePluggedIn / EVConnected) (#579) - + // + // The station drives a live transaction into the pre-authorization + // EVConnected plug-in phase, emitting one Updated event with + // triggerReason = CablePluggedIn. Redundant / no-transaction cases are inert; + // V201-only. Reuses #577's driver-hook plumbing via the shared core. + + #[tokio::test] + async fn plug_in_cable_emits_one_ev_connected_event() { + let (addr, mut rx) = spawn_mock_csms_capturing(transaction_event_routes()).await; + let cp = ChargePoint::new(ChargePointConfig { + central_system_url: format!("ws://{addr}"), + ..ChargePointConfig::for_version(OcppVersion::V201) + }) + .unwrap(); + cp.connect().await.unwrap(); + insert_live_v201_session(&cp, 1).await; + + // → EVConnected: one Updated event at the next seqNo (1). + let outcome = cp.plug_in_cable(1).await.unwrap(); + assert_eq!(outcome, ChargingStateChangeOutcome::Emitted { seq_no: 1 }); + + let payload = recv_transaction_event(&mut rx).await; + assert_eq!(payload["eventType"], "Updated"); + assert_eq!(payload["triggerReason"], "CablePluggedIn"); + assert_eq!(payload["seqNo"], 1); + assert_eq!(payload["transactionInfo"]["chargingState"], "EVConnected"); + // The plug-in event carries no meter reading and performs no auth. + assert!( + payload.get("meterValue").is_none(), + "a CablePluggedIn event carries no meterValue" + ); + assert!(payload.get("idToken").is_none()); + } + + #[tokio::test] + async fn plug_in_cable_redundant_transition_is_a_noop() { + let (addr, mut rx) = spawn_mock_csms_capturing(transaction_event_routes()).await; + let cp = ChargePoint::new(ChargePointConfig { + central_system_url: format!("ws://{addr}"), + ..ChargePointConfig::for_version(OcppVersion::V201) + }) + .unwrap(); + cp.connect().await.unwrap(); + // Already in EVConnected: a plug-in emits nothing and burns no seqNo. + insert_live_v201_session_in_state(&cp, 1, ChargingStateEnumType::EVConnected).await; + + assert_eq!( + cp.plug_in_cable(1).await.unwrap(), + ChargingStateChangeOutcome::Redundant + ); + + // A real transition afterwards still uses seqNo 1 (none was burned by the + // redundant call), and reaches the wire as the only event. + assert_eq!( + cp.transition_charging_state(1, ChargingStateEnumType::Charging) + .await + .unwrap(), + ChargingStateChangeOutcome::Emitted { seq_no: 1 } + ); + let payload = recv_transaction_event(&mut rx).await; + assert_eq!(payload["seqNo"], 1); + assert_eq!(payload["transactionInfo"]["chargingState"], "Charging"); + } + + #[tokio::test] + async fn plug_in_cable_with_no_active_transaction_is_inert() { + // No session on the EVSE (and an out-of-range evse_id) both land on the + // inert NoTransaction outcome — no panic, no CALL. + let cp = ChargePoint::new(ChargePointConfig::for_version(OcppVersion::V201)).unwrap(); + assert_eq!( + cp.plug_in_cable(1).await.unwrap(), + ChargingStateChangeOutcome::NoTransaction + ); + assert_eq!( + cp.plug_in_cable(0).await.unwrap(), + ChargingStateChangeOutcome::NoTransaction + ); + } + + #[tokio::test] + async fn plug_in_cable_is_v201_only() { + // A 1.6J station has no TransactionEvent / chargingState path. + let cp = ChargePoint::new(ChargePointConfig::default()).unwrap(); + assert!(matches!( + cp.plug_in_cable(1).await, + Err(OcppError::NotSupported { .. }) + )); + } + // ---- NotifyReport tbc paging over the wire (#574) ---- fn notify_report_routes() -> std::collections::HashMap { diff --git a/crates/ocpp-cp/src/v201_transaction.rs b/crates/ocpp-cp/src/v201_transaction.rs index e357563..54f4d22 100644 --- a/crates/ocpp-cp/src/v201_transaction.rs +++ b/crates/ocpp-cp/src/v201_transaction.rs @@ -358,11 +358,80 @@ pub fn transaction_event_charging_state_changed( seq_no: i32, new_state: ChargingStateEnumType, timestamp: &str, +) -> TransactionEventRequest { + transaction_event_state_transition( + session, + seq_no, + TriggerReasonEnumType::ChargingStateChanged, + new_state, + timestamp, + ) +} + +/// Build a `TransactionEvent(Updated)` for the **cable-plugged, pre-authorization** +/// phase — `triggerReason = CablePluggedIn` and `transactionInfo.chargingState = +/// EVConnected` (Issue #579). +/// +/// On a station whose `TxStartPoint` includes `EVConnected`, plugging the cable +/// drives the connector into the "cable in, not yet authorized/charging" phase +/// before the `Charging` state today's `Started`/`Updated` path reports — the +/// phase plug-and-charge and delayed-authorization conformance exercises. This +/// is the exact companion #577 deferred; it shares #577's state-transition +/// machinery ([`transaction_event_state_transition`]) rather than adding a +/// parallel builder, differing only in the trigger reason and target state. +/// +/// Like the suspend/resume transitions it carries **no** `meterValue` (a pure +/// state change, not a `Sample.Periodic` reading) and **no** `idToken` (the +/// event predates — and does not perform — authorization), and `seq_no` +/// continues the transaction's monotonic stream. +/// +/// The wire tokens are pinned by the ported +/// [`ChargingStateEnumType::EVConnected`] (`"EVConnected"`) / +/// [`TriggerReasonEnumType::CablePluggedIn`] (`"CablePluggedIn"`) +/// ([`ocpp/v201/enums.py`](https://github.com/mobilityhouse/ocpp/blob/master/ocpp/v201/enums.py)) +/// and the [`TransactionEvent`](https://github.com/mobilityhouse/ocpp/blob/master/ocpp/v201/call.py) +/// payload; mobilityhouse/ocpp is protocol-only, so emitting the plug-in phase +/// is the simulator's own responsibility. +#[must_use] +pub fn transaction_event_cable_plugged_in( + session: &SessionRef, + seq_no: i32, + timestamp: &str, +) -> TransactionEventRequest { + transaction_event_state_transition( + session, + seq_no, + TriggerReasonEnumType::CablePluggedIn, + ChargingStateEnumType::EVConnected, + timestamp, + ) +} + +/// The shared core behind [`transaction_event_charging_state_changed`] (#577) +/// and [`transaction_event_cable_plugged_in`] (#579): an interim +/// `TransactionEvent(Updated)` that reports a charging-state transition and +/// nothing else. +/// +/// Both callers emit the same shape — an `Updated` event carrying the new +/// `transactionInfo.chargingState` under a state-change `triggerReason`, with +/// no `meterValue` (the energy reading rides the `MeterValuePeriodic` path) and +/// no `idToken` (not an authorization event). They differ only in the +/// `trigger_reason` / `new_state` pair, so the body lives here once. +/// +/// `pub(crate)` so the simulator's driver hooks can build the request directly +/// with the right trigger without routing through a public wrapper. +#[must_use] +pub(crate) fn transaction_event_state_transition( + session: &SessionRef, + seq_no: i32, + trigger_reason: TriggerReasonEnumType, + new_state: ChargingStateEnumType, + timestamp: &str, ) -> TransactionEventRequest { TransactionEventRequest { event_type: TransactionEventEnumType::Updated, timestamp: timestamp.to_string(), - trigger_reason: TriggerReasonEnumType::ChargingStateChanged, + trigger_reason, seq_no, transaction_info: TransactionType { transaction_id: session.transaction_id.to_string(), @@ -794,6 +863,9 @@ mod tests { TS, ), transaction_event_charging_state_changed(&s, 7, ChargingStateEnumType::Charging, TS), + // The pre-authorization cable-plugged (EVConnected/CablePluggedIn) + // transition must also satisfy the schema (#579). + transaction_event_cable_plugged_in(&s, 8, TS), ] { let payload = serde_json::to_value(&req).unwrap(); assert!( @@ -805,4 +877,44 @@ mod tests { ); } } + + #[test] + fn cable_plugged_in_event_carries_ev_connected_shape() { + let s = session(); + // Cable plugged before authorization: EVConnected, CablePluggedIn. + let req = transaction_event_cable_plugged_in(&s, 3, TS); + + // An interim Updated flagged as a state change, not a periodic sample. + assert_eq!(req.event_type, TransactionEventEnumType::Updated); + assert_eq!(req.trigger_reason, TriggerReasonEnumType::CablePluggedIn); + assert_eq!(req.seq_no, 3); + assert_eq!( + req.transaction_info.charging_state, + Some(ChargingStateEnumType::EVConnected) + ); + assert!(req.transaction_info.stopped_reason.is_none()); + // Predates authorization and carries no reading — no idToken, no meterValue. + assert!( + req.id_token.is_none(), + "the plug-in event does not perform authorization" + ); + assert!( + req.meter_value.is_none(), + "a state-change event carries no Sample.Periodic reading" + ); + // The evse binding is still present so the CSMS knows which EVSE changed. + let evse = req.evse.as_ref().expect("state change carries evse"); + assert_eq!(evse.id, 1); + } + + #[test] + fn cable_plugged_in_round_trips_the_exact_wire_tokens() { + // `EVConnected` / `CablePluggedIn` serialize as their exact PascalCase + // wire tokens (not snake/camel-cased), so a CSMS reads them faithfully. + let s = session(); + let req = transaction_event_cable_plugged_in(&s, 1, TS); + let payload = serde_json::to_value(&req).unwrap(); + assert_eq!(payload["triggerReason"], "CablePluggedIn"); + assert_eq!(payload["transactionInfo"]["chargingState"], "EVConnected"); + } } From 8096dea2824bffeed3a0503b2f16f0a0b528cf32 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 27 Sep 2026 14:06:02 +0000 Subject: [PATCH 2/2] docs(ocpp-cp): demote intra-doc links to private items to plain code spans MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The `Documentation` CI check (`cargo doc --document-private-items` with `RUSTDOCFLAGS=--deny warnings`) failed on `rustdoc::private_intra_doc_links`: the public docs for `transaction_event_cable_plugged_in` and `ChargePoint::plug_in_cable` linked the private `transaction_event_state_transition` and `emit_transaction_state_change`. Such links resolve only under `--document-private-items` and break in the public docs, so rustdoc denies them. Reference both as plain code spans instead — no public link to a private item. Co-Authored-By: Claude Opus 4.8 (1M context) Claude-Session: https://claude.ai/code/session_01RR4rxKX9fJungJiibgJZ5S --- crates/ocpp-cp/src/lib.rs | 2 +- crates/ocpp-cp/src/v201_transaction.rs | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/crates/ocpp-cp/src/lib.rs b/crates/ocpp-cp/src/lib.rs index b70c44b..82a1040 100644 --- a/crates/ocpp-cp/src/lib.rs +++ b/crates/ocpp-cp/src/lib.rs @@ -7313,7 +7313,7 @@ impl ChargePoint { /// whose `TxStartPoint` includes `EVConnected` — the "cable in, not yet /// authorized/charging" phase plug-and-charge and delayed-authorization /// conformance exercises. Both share the same behavior-injection contract and - /// the same [`emit_transaction_state_change`](Self::emit_transaction_state_change) + /// the same `emit_transaction_state_change` /// core, differing only in the trigger reason and target state. /// /// # Outcome diff --git a/crates/ocpp-cp/src/v201_transaction.rs b/crates/ocpp-cp/src/v201_transaction.rs index 54f4d22..882462d 100644 --- a/crates/ocpp-cp/src/v201_transaction.rs +++ b/crates/ocpp-cp/src/v201_transaction.rs @@ -377,7 +377,7 @@ pub fn transaction_event_charging_state_changed( /// before the `Charging` state today's `Started`/`Updated` path reports — the /// phase plug-and-charge and delayed-authorization conformance exercises. This /// is the exact companion #577 deferred; it shares #577's state-transition -/// machinery ([`transaction_event_state_transition`]) rather than adding a +/// machinery (`transaction_event_state_transition`) rather than adding a /// parallel builder, differing only in the trigger reason and target state. /// /// Like the suspend/resume transitions it carries **no** `meterValue` (a pure