Skip to content

feat(ocpp-cp): emit v201 TransactionEvent(ChargingStateChanged) on suspend/resume (M7) - #580

Merged
duyhuynh-vn merged 3 commits into
mainfrom
claude/inspiring-ramanujan-3a3gwd
Sep 27, 2026
Merged

duyhuynh-vn merged 3 commits into
mainfrom
claude/inspiring-ramanujan-3a3gwd

Conversation

@duyhuynh-vn

Copy link
Copy Markdown
Collaborator

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 one TransactionEvent(Updated, triggerReason = ChargingStateChanged). Advances M7 — OCPP 2.0.1.

Closes #577

Real 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) with triggerReason = ChargingStateChanged carrying the new chargingState, 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 only Charging (Started/Updated) and Idle (Ended), so a CSMS integration test could never exercise the suspend semantics that smart-charging conformance depends on. This hook lets a test drive Charging → SuspendedEVSE → Charging deterministically and assert exactly what reached the wire.

What changed

crates/ocpp-cp/src/v201_transaction.rs and crates/ocpp-cp/src/lib.rs only.

  • v201_transaction::transaction_event_charging_state_changed(session, seq_no, new_state, timestamp) — a pure, schema-validated builder emitting a TransactionEvent(Updated) with triggerReason = ChargingStateChanged and transactionInfo.chargingState = new_state. A pure state-change event carries no meterValue (readings stay on the MeterValuePeriodic path — meterValue is optional in the 2.0.1 schema) and no idToken (the session is already authorized). Mirrors the existing transaction_event_* builder idiom.
  • 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, returning a typed ChargingStateChangeOutcome.
  • V201Session.charging_state — the session's current chargingState, tracked behind a per-session Mutex (opens Charging, matching the Started event).

Design / failure modes / trust boundary:

  • Deterministic injection — a pure simulator has no real EV to pause draw, so the transition is injected by the caller, the same opt-in behavior-injection seam as trip_variable_monitor / request_notify_charging_limit. Inputs are strongly typed (i32, ChargingStateEnumType); the CSMS .conf is discarded, so no untrusted wire data is parsed.
  • Hysteresis — a redundant transition (already in the target state) is a silent no-op: no event, no seqNo burned.
  • Inert on no context — no active transaction on the EVSE (including a non-positive / out-of-range evse_id, which matches nothing) returns NoTransaction with no CALL; never a panic.
  • Scope guard — Idle (terminal, reported by Ended) and EVConnected (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 with OcppError::ValidationError before any wire I/O; every accepted arm is handled explicitly (no panic).
  • seqNo is drawn from the transaction's shared monotonic counter (V201Session::next_seq_no), so a transition interleaves cleanly with the periodic sampler's Updated events and stays strictly between Started and Ended.
  • Locking — the compare-and-emit runs under the per-session charging_state Mutex, never the v201_sessions map lock, so the map lock is not held across the outbound send and a failed send leaves the modeled state unchanged (the seqNo is spent, as on any emitted event).
  • V201-only — a 1.6J station is refused with 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.

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:

  • Builder — transition shape (Updated / ChargingStateChanged / chargingState, no meterValue, no idToken); SuspendedEV / SuspendedEVSE / Charging serialize as the exact PascalCase wire tokens; each of the three satisfies the bundled 2.0.1 TransactionEvent JSON Schema.
  • Driver hook (via the capturing mock CSMS, asserting what reached the wire and in what order):
    • Charging → SuspendedEV → one event, chargingState = SuspendedEV, seqNo next, no meterValue/idToken.
    • Charging → SuspendedEVSE → Charging → two events, correct states in order, seqNo 1 then 2.
    • Redundant transition → Redundant, no event, no seqNo burned (the next real transition still claims seqNo 1).
    • No active transaction (and out-of-range evse_id) → NoTransaction, inert.
    • Idle / EVConnected target → ValidationError.
    • 1.6J station → NotSupported.

Acceptance criteria

  • A V201-only driver hook drives a connector Charging ↔ SuspendedEV ↔ SuspendedEVSE and originates one schema-valid TransactionEvent(Updated, triggerReason=ChargingStateChanged) per real transition, carrying the transitioned chargingState.
  • Pure builder with a schema-validity test for each of SuspendedEV, SuspendedEVSE, and the Charging resume.
  • Redundant / no-transition and no-active-transaction cases are inert (no event, no seqNo burned, no panic).
  • seqNo continues the transaction's monotonic stream; Ended still closes with Idle.
  • A 1.6J charge point refuses the hook with OcppError::NotSupported.
  • cargo fmt --check, cargo clippy --all-targets -- -D warnings, cargo test --workspace all green.

Known gaps / notes

🤖 Generated with Claude Code

https://claude.ai/code/session_01AaEcbYKZPx1xbT6tTMx6Mp


Generated by Claude Code

…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
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
@duyhuynh-vn
duyhuynh-vn merged commit 2eefb02 into main Sep 27, 2026
2 checks passed
@duyhuynh-vn
duyhuynh-vn deleted the claude/inspiring-ramanujan-3a3gwd branch September 27, 2026 12:56

Copy link
Copy Markdown
Collaborator Author

🌙 Nightly dev — 2026-09-27

This PR had gone un-mergeable (dirty): #578 merged into main and added EvChargingNegotiationOutcome at the same spot in crates/ocpp-cp/src/lib.rs where this branch adds ChargingStateChangeOutcome.

Resolved by merging latest main into the branch (merge commit 3f52a88, no history rewrite). The conflict was a clean "both added" — the two outcome enums are independent additions, so I kept both.

Re-validated on the merged tree:

  • cargo fmt --all --check ✅
  • cargo clippy --all-targets -- -D warnings ✅
  • cargo test --workspace ✅ (810 ocpp-cp lib tests + all crates green)

mergeable_state is now clean. No code behavior changed — this was purely a conflict resolution. Ready for your review whenever you'd like; I won't merge it myself.


Generated by Claude Code

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants