Skip to content

feat(ocpp-cp): chain the v201 ISO 15118 smart-charging negotiation (M7) - #578

Merged
duyhuynh-vn merged 1 commit into
mainfrom
claude/inspiring-ramanujan-dpbbu9
Sep 27, 2026
Merged

duyhuynh-vn merged 1 commit into
mainfrom
claude/inspiring-ramanujan-dpbbu9

Conversation

@duyhuynh-vn

Copy link
Copy Markdown
Collaborator

Summary

Adds ChargePoint::negotiate_ev_charging, a thin driver that chains the two CP-initiated smart-charging legs into the real ISO 15118 negotiation a station performs when an EV plugs in: report the EV's declared needs, then — gated on the CSMS's response — report the schedule the EV intends to follow. Advances M7 — OCPP 2.0.1.

Closes #569

Real use case

Today a caller must orchestrate request_notify_ev_charging_needs and request_notify_ev_charging_schedule by hand and re-implement the gating rule every time. On a real station the sequence is fixed: the station reports the EV's needs, and only if the CSMS accepts them does it push the schedule the EV intends to follow (on Rejected the smart-charging service is unavailable; on Processing the CSMS is still computing and the station waits for a SetChargingProfile rather than pushing a schedule). This hook makes the simulator drive that exact sequence, so a CSMS integration test can exercise the whole plug-in negotiation with one call and assert the terminal state deterministically.

What changed

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

  • EvChargingNegotiationOutcome — a small typed outcome enum capturing which legs ran and the terminal status: NeedsRejected, NeedsProcessing (needs leg only; schedule not emitted), and Accepted { schedule_status } (both legs ran; carries the schedule leg's process-only GenericStatusEnumType). Mirrors the existing MonitorTripOutcome idiom.
  • negotiate_ev_charging(evse_id, charging_needs, max_schedule_tuples, time_base, charging_schedule) — runs the needs leg, then matches its status: Accepted → run the schedule leg and return Accepted { schedule_status }; Rejected/Processing → return the corresponding variant without emitting the schedule.

Design / trust boundary:

  • No new wire message — composes the two existing hooks and reuses their guards rather than duplicating them. The needs leg runs first and enforces V201-only + evseId > 0 before any CALL is emitted, so an unsupported version surfaces as OcppError::NotSupported and a non-positive evse_id as OcppError::ValidationError, with nothing on the wire (no partial negotiation).
  • Each status arm is handled explicitly (exhaustive match) — no arm panics; a Rejected/Processing needs status is a valid protocol outcome returned as Ok(..), not an Err.
  • Transport / timeout / CALLERROR failures on either leg propagate as OcppError via ?.
  • The EV's needs and intended schedule are injected deterministically by the caller (a pure simulator has no real EV) — the same opt-in behavior-injection pattern as unlock_outcome, firmware fault injection, and trip_variable_monitor. Injected values ride to the wire verbatim through each leg's call() schema validation.

What was ported

mobilityhouse/ocpp is protocol-only and models each message independently (no chained driver), so the message semantics are ported and the sequencing is written idiomatically as the simulator's own responsibility.

  • ocpp/v201/call.py — NotifyEVChargingNeeds, NotifyEVChargingSchedule (the two legs already ported).
  • ocpp/v201/enums.py — NotifyEVChargingNeedsStatusEnumType, GenericStatusEnumType.

Test plan

  • cargo fmt --all --check ✅
  • cargo clippy --all-targets -- -D warnings ✅
  • cargo test --workspace ✅ (all green)

New tests (via the capturing mock CSMS, so each asserts exactly what reached the wire and in what order):

  • Accepted needs → both legs run needs-first, outcome Accepted { Accepted }.
  • Accepted needs + Rejected schedule → outcome Accepted { Rejected }, both legs ran.
  • Rejected needs → outcome NeedsRejected, schedule leg not on the wire.
  • Processing needs → outcome NeedsProcessing, schedule leg not on the wire.
  • Non-positive evse_id → ValidationError (needs guard fires first, nothing emitted).
  • 1.6J station → NotSupported.

Acceptance criteria

  • Driver hook runs needs → schedule against a mock CSMS, gating the schedule leg on the needs status (Accepted → schedule emitted; Rejected → not; Processing → surfaced, not emitted).
  • Deterministic injection of the EV's needs/schedule (no reliance on a real EV).
  • Typed outcome surfaced so the sequence is assertable; no panic on any status arm.
  • V201-only; evseId > 0.
  • 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_01Chqxsy7wrYYWr9ducD5xj5


Generated by Claude Code

Add `ChargePoint::negotiate_ev_charging`, a driver hook that chains the two
existing CP-initiated legs into the real ISO 15118 smart-charging sequence a
station performs when an EV plugs in: report the EV's declared needs
(`NotifyEVChargingNeeds`), then — gated on the CSMS's needs status — report the
schedule the EV intends to follow (`NotifyEVChargingSchedule`).

The needs status gates the schedule leg: `Accepted` runs it and surfaces its
process-only `GenericStatus`; `Rejected` / `Processing` stop the sequence
without emitting the schedule. A new typed `EvChargingNegotiationOutcome`
records which legs ran and the terminal status so the sequence is assertable.

Composes the two hooks with no new wire message and reuses their guards: the
needs leg enforces V201-only and `evseId > 0` before anything reaches the wire,
so an unsupported version → `NotSupported` and a non-positive evse_id →
`ValidationError`, with nothing emitted. Each status arm is handled explicitly
(no panic); transport/CALLERROR failures on either leg propagate as `OcppError`.
The EV's needs and schedule are injected deterministically by the caller (a pure
simulator has no real EV), matching the other v201-only behavior-injection hooks.

mobilityhouse/ocpp is protocol-only (it models each message independently, no
chained driver), so the message semantics are ported from ocpp/v201/call.py
(`NotifyEVChargingNeeds`, `NotifyEVChargingSchedule`) and the sequencing is
written idiomatically as the simulator's own responsibility.

Tests (via the capturing mock CSMS): Accepted needs runs both legs needs-first;
Accepted needs with a Rejected schedule surfaces that status; Rejected and
Processing needs skip the schedule leg (asserted off the wire); non-positive
evse_id and a 1.6J station are both refused.

Closes #569

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Chqxsy7wrYYWr9ducD5xj5
@duyhuynh-vn
duyhuynh-vn merged commit 4795a3b into main Sep 27, 2026
9 checks passed
@duyhuynh-vn
duyhuynh-vn deleted the claude/inspiring-ramanujan-dpbbu9 branch September 27, 2026 12:17
duyhuynh-vn pushed a commit that referenced this pull request Sep 27, 2026
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
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