Skip to content

feat(ocpp-cp): emit v201 ReservationStatusUpdate on reservation expiry/removal (M7) - #548

Open
duyhuynh-vn wants to merge 1 commit into
mainfrom
nightly/2026-08-24-issue-546
Open

feat(ocpp-cp): emit v201 ReservationStatusUpdate on reservation expiry/removal (M7)#548
duyhuynh-vn wants to merge 1 commit into
mainfrom
nightly/2026-08-24-issue-546

Conversation

@duyhuynh-vn

Copy link
Copy Markdown
Collaborator

Summary

Wire the CP→CSMS half that closes the OCPP 2.0.1 reservation loop: when a held reservation expires or is removed, a for_version(V201) Charging Station now sends ReservationStatusUpdate.req so the CSMS learns the slot is free again. This completes the 2.0.1 reservation family alongside the already-wired inbound ReserveNow (#483) and CancelReservation (#484). Advances M7 — OCPP 2.0.1.

Closes #546

Real use case

A driver reserves EVSE 3 at a site for a 20-minute window and then never shows up. The station's auto-expiry timer fires when the expiryDateTime passes, frees the connector, and now also tells the back office ReservationStatusUpdate(Expired) — so the CSMS dashboard stops showing the bay as held and can offer it to the next driver. Separately, when an operator cancels a still-active reservation from the back office, the station acks the CancelReservation and reports ReservationStatusUpdate(Removed), closing the loop so the CSMS's view of which bays are held always matches the station's. Before this change the expiry timer freed the connector silently and the CSMS could keep a freed slot marked reserved indefinitely.

What changed

  • crates/ocpp-cp/src/v201_command.rs — a pure, unit-testable builder v201_reservation_status_update(reservation_id, status) -> ReservationStatusUpdateRequest (the CP→CSMS request half; the .conf is empty, so there is no response builder). reservation_id is only echoed, never parsed or indexed.
  • crates/ocpp-cp/src/lib.rs
    • RemoteCommand::V201ReservationStatusUpdate { reservation_id, status } + send_v201_reservation_status_update(...), queued off the inbound-CALL / timer path so the outbound CALL never re-enters the receive loop mid-dispatch and never blocks the auto-expiry task. Best-effort send (log-not-propagate): the slot is already freed locally, so a dropped update must not undo it. There is no in-flight store to clear — a single fire-and-forget notification, not a stream.
    • arm_reservation_expiry gains a protocol_version argument and, on V201 only, queues Expired when the timer actually claims and frees the reservation (still_held). The reservation/expiry machinery is otherwise shared verbatim between the two ReserveNow arms, so the version is threaded in rather than duplicating the timer body. 1.6J has no such message, so a 1.6J expiry only frees the connector.
    • The V201 CancelReservation handler queues Removed when it tears down a still-held reservation (freed was Some); a cancel of an unknown id is already Rejected and queues nothing.

Failure modes / concurrency. The Expired emit is gated on the atomic still_held claim (taken under the reservations write-lock) and Removed on the atomic freed removal, so a cancel-vs-expiry race reports exactly one of the two: whichever handler claims the map entry first emits, the other sees it gone and no-ops. The cancel also aborts the pending timer, so the same reservation can never be double-reported.

Trust boundary. reservation_id is CP-side state (echoed, never parsed); status is simulator-decided from the reservation lifecycle, never attacker input.

What was ported

The message type and enum were already ported and schema-validated (crates/ocpp-messages/src/v201/reservation_status_update.rs, crates/ocpp-types/src/v201/enums.rs); this PR is the CP-side emitter wiring + builder + tests.

Test plan

  • cargo fmt --all --check — clean.
  • cargo clippy --all-targets -- -D warnings — clean.
  • cargo test --workspace — green (1979 tests pass).
  • New tests (7):
    • Unit / schema (v201_command.rs): the builder forwards id + status verbatim; every ReservationStatusUpdate.req (both Expired/Removed, and extreme reservationId 0 / 1 / -1 / i32::MIN / i32::MAX) is OCPP 2.0.1 schema-valid and never panics.
    • Over-the-wire (lib.rs): an accepted CancelReservation of a held reservation queues exactly one Removed; a cancel of an unknown id queues none; a V201 auto-expiry frees the connector and queues exactly one Expired; a 1.6J auto-expiry frees the connector but queues no ReservationStatusUpdate (the version gate); a cancel disarms the pending timer so the reservation can never be double-reported (Removed once, no Expired, timer gone from the store). The expiry tests arm with a past expiryDate (ttl 0) and await the spawned timer handle, so they are deterministic — no wall-clock wait.

Acceptance criteria

  • RemoteCommand::V201ReservationStatusUpdate variant + emitter, queued off the inbound/timer path.
  • Pure ReservationStatusUpdate.req builder in v201_command with a schema-validity test covering both Expired and Removed.
  • Expiry timer emits Expired and frees the connector; CancelReservation of a held reservation emits Removed; cancel of an unknown id emits nothing.
  • No double-emit if a reservation is canceled and then its timer would also fire (timer is torn down on cancel).
  • cargo fmt --check, cargo clippy --all-targets -- -D warnings, cargo test --workspace all green.

Known gaps / notes

  • Removed is scoped to CSMS-initiated cancels of still-held reservations. The spec also allows a station to report Removed when a reservation is dropped for another local reason (e.g. the connector becomes Unavailable/Faulted while reserved). The simulator has no path that unilaterally faults a reserved connector today, so that trigger is left as a natural follow-up; the Removed arm and its wire/schema coverage are already in place for it.
  • 1.6J is deliberately unaffectedReservationStatusUpdate does not exist in 1.6J, so a 1.6J CP's reservation teardown emits neither status (asserted by a regression test).

🤖 Generated with Claude Code

https://claude.ai/code/session_01TMS4zMza6pdXDtQaZqZnFH


Generated by Claude Code

…y/removal (M7)

Wire the CP→CSMS half that closes the OCPP 2.0.1 reservation loop opened by
ReserveNow (#483) and CancelReservation (#484): when a held reservation
expires or is removed, a for_version(V201) station now sends
ReservationStatusUpdate.req so the CSMS learns the slot is free again.

- v201_command: pure `v201_reservation_status_update(reservation_id, status)`
  builder + schema-validity test (both Expired/Removed, extreme reservationId).
- RemoteCommand::V201ReservationStatusUpdate variant + send helper, queued off
  the inbound/timer path (no receive-loop re-entrancy, never blocks the timer).
- arm_reservation_expiry gains a protocol_version arg and, on V201 only, queues
  Expired when the timer actually claims and frees the reservation.
- V201 CancelReservation queues Removed when it tears down a still-held
  reservation; an unknown id (Rejected) queues nothing. 1.6J emits neither.

The Expired emit is gated on the atomic still-held claim and Removed on the
atomic freed removal, so a cancel-vs-expiry race reports exactly one of the two
(the cancel also aborts the timer). Ports ocpp.v201.call.ReservationStatusUpdate.

Closes #546

Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
Claude-Session: https://claude.ai/code/session_01TMS4zMza6pdXDtQaZqZnFH
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.

port(m7): CP simulator 2.0.1 — emit ReservationStatusUpdate on reservation expiry/removal

2 participants