Skip to content

port(m7): CP simulator 2.0.1 — emit TransactionEvent(Updated, ChargingStateChanged) on SuspendedEV / SuspendedEVSE / Charging transitions #577

Description

@duyhuynh-vn

Context

The v201 transaction lifecycle in crates/ocpp-cp/src/v201_transaction.rs currently reports only two charging states across an entire session: Charging on Started/Updated and Idle on Ended. transaction_event_updated() hardcodes chargingState = Charging and triggerReason = MeterValuePeriodic (verified — v201_transaction.rs:258–289). A simulator-wide inventory confirms the gap: ChargingStateEnumType::{SuspendedEV, SuspendedEVSE, EVConnected} and TriggerReasonEnumType::ChargingStateChanged are never constructed anywhere in crates/ocpp-cp.

Real charging stations emit an interim TransactionEvent(Updated) with triggerReason = ChargingStateChanged whenever the session's charging state transitions — the EV pauses draw (SuspendedEV), the EVSE/CSMS pauses delivery (e.g. a 0 A charging profile / load management → SuspendedEVSE), or charging resumes (Charging). Without these, a downstream CSMS/CDR sees a session that is "always charging" until it abruptly ends — not faithful to OCPP 2.0.1 Part 2, and it hides the suspend semantics that smart-charging conformance exercises.

Scope (small slice)

  • Mid-transaction suspend/resume only: transitions among Charging ↔ SuspendedEV ↔ SuspendedEVSE, each emitted as one TransactionEvent(Updated) with triggerReason = ChargingStateChanged and the new transactionInfo.chargingState.
  • A pure builder alongside the existing ones (e.g. transaction_event_charging_state_changed(session, seq_no, new_state, timestamp)), plus a V201-only driver hook on the simulator to drive a connector into SuspendedEV/SuspendedEVSE and back to Charging — the same opt-in behavior-injection pattern as the monitor-trip (port(m7): CP simulator 2.0.1 — emit NotifyEvent when a CSMS-installed variable monitor trips #545) / charging-limit hooks.
  • seqNo continues the transaction's monotonic stream (strictly between Started and Ended); no idToken (already authorized); a pure state-change event carries chargingState, not a Sample.Periodic reading — leave meter samples on the MeterValuePeriodic path.
  • Out of scope (explicit follow-up split): the pre-authorization EVConnected state + triggerReason = CablePluggedIn before Started (ties into TxStartPoint semantics) — file a companion issue if this lands clean. A Sample.Periodic reading emitted while suspended is also out of scope.

Failure modes / trust boundary

Test matrix

  • Charging → SuspendedEV: one Updated event, chargingState = SuspendedEV, triggerReason = ChargingStateChanged, seqNo = next.
  • SuspendedEV → Charging (resume): one event, chargingState = Charging.
  • Charging → SuspendedEVSE → Charging: two events with the correct states in order.
  • Redundant transition (SuspendedEV → SuspendedEV): no event emitted.
  • Suspend with no active transaction: inert, no panic.
  • seqNo monotonic and strictly between the Started and Ended events; builder output passes the v201 schema validator.
  • chargingState round-trips (serde) as the exact PascalCase wire tokens SuspendedEV / SuspendedEVSE.

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 (matches the other v201-only hooks).
  • cargo fmt --check, cargo clippy --all-targets -- -D warnings, cargo test --workspace all green.

Reference

mobilityhouse/ocpp is protocol-only, so the behavior ported is the Charging Station side; the wire semantics are pinned by the ported types:

  • States & triggers: ocpp/v201/enums.py — ChargingStateEnumType (Charging, EVConnected, SuspendedEV, SuspendedEVSE, Idle) and TriggerReasonEnumType.charging_state_changed = "ChargingStateChanged" (plus cable_plugged_in for the deferred follow-up).
  • Event payload: ocpp/v201/call.py class TransactionEvent; ocpp/v201/datatypes.py TransactionType.charging_state.
  • Existing Rust: crates/ocpp-cp/src/v201_transaction.rs (builders), crates/ocpp-types/src/v201/enums.rs:230 (ChargingStateEnumType), :206 (ChargingStateChanged).

Milestone / labels

M7 — OCPP 2.0.1. Labels: port, m7. Small (one builder + one driver hook + tests; well under the ~500-LOC target).


🌙 Filed by the nightly dev during issue grooming (2026-09-12) — with #574/#573 now in-flight as PR #575/#576, the M7 earliest-incomplete queue drops toward fewer than 3 unclaimed well-scoped port issues once they merge. This is a genuine, code-verified gap (the simulator constructs only Charging/Idle, never a ChargingStateChanged suspend/resume transition), not queue padding. Direction for the broader M7→M8 track remains tracked in #256.

Activity

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

Metadata

Metadata

Assignees

No one assigned

    Labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions