feat(ocpp-cp): emit v201 TransactionEvent(ChargingStateChanged) on suspend/resume (M7) - #580
Merged
Merged
Conversation
…spend/resume (M7) Ports the OCPP 2.0.1 Charging Station behavior of reporting interim charging-state transitions during a live transaction (Issue #577). Until now the v201 transaction loop reported only two states across a whole session — `Charging` on Started/Updated and `Idle` on Ended — so a downstream CSMS/CDR saw a session that was "always charging" until it abruptly ended, hiding the suspend semantics smart-charging conformance exercises. - `v201_transaction::transaction_event_charging_state_changed`: a pure, schema-validated builder emitting a `TransactionEvent(Updated)` with `triggerReason = ChargingStateChanged` and the new `transactionInfo.chargingState`. A pure state-change event carries no `meterValue` (readings stay on the MeterValuePeriodic path) and no `idToken` (already authorized). - `ChargePoint::transition_charging_state(evse_id, new_state)`: a V201-only driver hook that drives a live transaction across the interim `Charging <-> SuspendedEV <-> SuspendedEVSE` transitions, emitting one event per real change. Per-session hysteresis makes a redundant transition a silent no-op (no event, no seqNo burned); no active transaction on the EVSE is inert; `Idle`/`EVConnected` targets are rejected with ValidationError (Idle is terminal, EVConnected is the #579 follow-up); a 1.6J station is refused with NotSupported. The transition's seqNo is drawn from the transaction's shared monotonic counter, so it interleaves cleanly with the periodic sampler. - Tracks the session's `chargingState` on `V201Session` behind a per-session Mutex so the compare-and-emit never holds the `v201_sessions` map lock across the outbound send. Python reference: ocpp/v201/enums.py (ChargingStateEnumType, TriggerReasonEnumType.charging_state_changed), ocpp/v201/call.py (TransactionEvent). mobilityhouse/ocpp is protocol-only, so the interim suspend/resume reporting is written idiomatically as the simulator's own responsibility. Tests: pure-builder shape + wire-token round-trip + schema validity for SuspendedEV/SuspendedEVSE/Charging; driver-hook suspend, suspend-then- resume ordering, redundant no-op, no-transaction inertness, invalid-target rejection, and V201-only. cargo fmt --check, clippy -D warnings, and cargo test --workspace all green. Closes #577 Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01AaEcbYKZPx1xbT6tTMx6Mp
…ging_state The Documentation CI job builds with `RUSTDOCFLAGS=--deny warnings`, which denies `rustdoc::private_intra_doc_links`: the public `transition_charging_state` doc linked to the private `V201Session::next_seq_no` / `V201Session::charging_state` fields, failing `cargo doc`. Reword those two references as plain code spans (the prose is unchanged) so the public API docs no longer link private items. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01AaEcbYKZPx1xbT6tTMx6Mp
duyhuynh-vn
enabled auto-merge (squash)
September 27, 2026 12:17
Resolves the lib.rs conflict from #578 (EvChargingNegotiationOutcome) landing alongside this PR's ChargingStateChangeOutcome enum. Both outcome enums are independent additions at the same location; kept both. fmt/clippy/test --workspace all green after resolution. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01H9MRGtFszCXLVdJKQrWUND
Collaborator
Author
|
🌙 Nightly dev — 2026-09-27 This PR had gone un-mergeable ( Resolved by merging latest Re-validated on the merged tree:
Generated by Claude Code |
This was referenced Sep 27, 2026
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
Teaches the OCPP 2.0.1 simulator to report interim charging-state transitions during a live transaction — the EV pausing draw (
SuspendedEV), the EVSE/CSMS pausing delivery (SuspendedEVSE), or charging resuming (Charging) — each as oneTransactionEvent(Updated, triggerReason = ChargingStateChanged). Advances M7 — OCPP 2.0.1.Closes #577Real use case
A driver is charging when the utility sends a load-management signal that drops the connector to 0 A: the EV stops drawing, the session is now suspended, not charging. Minutes later the constraint lifts and charging resumes. On a real OCPP 2.0.1 station each of those moments emits an interim
TransactionEvent(Updated)withtriggerReason = ChargingStateChangedcarrying the newchargingState, so the CSMS's billing/CDR and the operator's dashboard show a session that paused and resumed rather than one that was "always charging" until it abruptly ended. Until this change the simulator reported onlyCharging(Started/Updated) andIdle(Ended), so a CSMS integration test could never exercise the suspend semantics that smart-charging conformance depends on. This hook lets a test driveCharging → SuspendedEVSE → Chargingdeterministically and assert exactly what reached the wire.What changed
crates/ocpp-cp/src/v201_transaction.rsandcrates/ocpp-cp/src/lib.rsonly.v201_transaction::transaction_event_charging_state_changed(session, seq_no, new_state, timestamp)— a pure, schema-validated builder emitting aTransactionEvent(Updated)withtriggerReason = ChargingStateChangedandtransactionInfo.chargingState = new_state. A pure state-change event carries nometerValue(readings stay on theMeterValuePeriodicpath —meterValueis optional in the 2.0.1 schema) and noidToken(the session is already authorized). Mirrors the existingtransaction_event_*builder idiom.ChargePoint::transition_charging_state(evse_id, new_state)— a V201-only driver hook that drives a live transaction across the interimCharging ↔ SuspendedEV ↔ SuspendedEVSEtransitions, returning a typedChargingStateChangeOutcome.V201Session.charging_state— the session's currentchargingState, tracked behind a per-sessionMutex(opensCharging, matching theStartedevent).Design / failure modes / trust boundary:
trip_variable_monitor/request_notify_charging_limit. Inputs are strongly typed (i32,ChargingStateEnumType); the CSMS.confis discarded, so no untrusted wire data is parsed.seqNoburned.evse_id, which matches nothing) returnsNoTransactionwith no CALL; never a panic.Idle(terminal, reported byEnded) andEVConnected(the pre-authorization plug-in state, follow-up port(m7): CP simulator 2.0.1 — emit the pre-authorization EVConnected state + TransactionEvent(triggerReason=CablePluggedIn) when the cable is plugged before Started #579) are rejected withOcppError::ValidationErrorbefore any wire I/O; every accepted arm is handled explicitly (no panic).seqNois drawn from the transaction's shared monotonic counter (V201Session::next_seq_no), so a transition interleaves cleanly with the periodic sampler'sUpdatedevents and stays strictly betweenStartedandEnded.charging_stateMutex, never thev201_sessionsmap lock, so the map lock is not held across the outbound send and a failed send leaves the modeled state unchanged (theseqNois spent, as on any emitted event).OcppError::NotSupported.What was ported
mobilityhouse/ocpp is protocol-only and models each message independently (no state-transition driver), so the wire semantics are ported and the interim-transition reporting is written idiomatically as the simulator's own responsibility.
ocpp/v201/enums.py—ChargingStateEnumType(Charging/SuspendedEV/SuspendedEVSE),TriggerReasonEnumType.charging_state_changed.ocpp/v201/call.py—class TransactionEvent;ocpp/v201/datatypes.pyTransactionType.charging_state.Test plan
cargo fmt --all --check✅cargo clippy --all-targets -- -D warnings✅cargo test --workspace✅ (804 ocpp-cp lib tests + all crates green)New tests:
chargingState, nometerValue, noidToken);SuspendedEV/SuspendedEVSE/Chargingserialize as the exact PascalCase wire tokens; each of the three satisfies the bundled 2.0.1TransactionEventJSON Schema.Charging → SuspendedEV→ one event,chargingState = SuspendedEV,seqNonext, nometerValue/idToken.Charging → SuspendedEVSE → Charging→ two events, correct states in order,seqNo1 then 2.Redundant, no event, noseqNoburned (the next real transition still claimsseqNo1).evse_id) →NoTransaction, inert.Idle/EVConnectedtarget →ValidationError.NotSupported.Acceptance criteria
Charging ↔ SuspendedEV ↔ SuspendedEVSEand originates one schema-validTransactionEvent(Updated, triggerReason=ChargingStateChanged)per real transition, carrying the transitionedchargingState.SuspendedEV,SuspendedEVSE, and theChargingresume.seqNoburned, no panic).seqNocontinues the transaction's monotonic stream;Endedstill closes withIdle.OcppError::NotSupported.cargo fmt --check,cargo clippy --all-targets -- -D warnings,cargo test --workspaceall green.Known gaps / notes
EVConnectedstate +triggerReason = CablePluggedInbeforeStartedis the explicit companion follow-up, tracked in port(m7): CP simulator 2.0.1 — emit the pre-authorization EVConnected state + TransactionEvent(triggerReason=CablePluggedIn) when the cable is plugged before Started #579 (this hook rejectsEVConnectedfor now and reuses the same plumbing when port(m7): CP simulator 2.0.1 — emit the pre-authorization EVConnected state + TransactionEvent(triggerReason=CablePluggedIn) when the cable is plugged before Started #579 lands).Sample.Periodicreading emitted while suspended is out of scope (noted on the issue).🤖 Generated with Claude Code
https://claude.ai/code/session_01AaEcbYKZPx1xbT6tTMx6Mp
Generated by Claude Code