Skip to content

feat(ocpp-cp): auto-trip a v201 variable monitor on a threshold-crossing SetVariables write (M7) - #576

Merged
duyhuynh-vn merged 1 commit into
mainfrom
claude/inspiring-ramanujan-6jk1qd
Sep 12, 2026
Merged

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

Conversation

@duyhuynh-vn

Copy link
Copy Markdown
Collaborator

Summary

Completes the OCPP 2.0.1 variable-monitoring loop that #570 (issue #545) left half-done: a monitor now trips autonomously when a CSMS SetVariables write moves a monitored variable's Actual value across an installed threshold (or by ≥ a configured delta), emitting a NotifyEvent off the command queue. Advances M7 — OCPP 2.0.1.

Closes #573

Real use case

A CSMS installs an upper-threshold monitor on a charging station's OCPPCommCtrlr.HeartbeatInterval (alert if it ever exceeds 900 s) via SetVariableMonitoring. Later, an operator (or a misconfiguration) pushes a new heartbeat interval of 1000 s with SetVariables. On a real station the monitor fires the moment that write lands, so the CSMS learns — via an unsolicited NotifyEvent (trigger Alerting) — that a monitored value has entered its alarm band, without having to poll. Before this change the simulator stored the new value silently and the monitor never fired, so a CSMS integration test that relies on threshold alerting had nothing to observe. Now the write auto-originates the correlated NotifyEvent, and a subsequent write back below the threshold fires the leaving-band event — exactly as an operator watching that alarm would expect.

What changed

  • crates/ocpp-cp/src/v201_device_model.rs — new pure V201DeviceModel::monitors_tripped_by_write(component, variable, previous, new) selects only the write-driven monitors a numeric crossing hits, id-sorted:
    • Upper/LowerThreshold → trips on a crossing in either direction (opposite sides of the threshold); hysteresis falls out for free (a same-band write crosses nothing).
    • Delta → trips when the single write jumps by ≥ the configured magnitude.
    • Periodic/PeriodicClockAligned → never selected (time-driven; compile-exhaustive match).
    • Numeric-only: a previous/new that is not a finite f64 (non-numeric, NaN, inf) crosses nothing — the opaque value is still reported verbatim. Shared key-building extracted into monitor_lookup_key; MonitorEntry::to_tripped projection shared with monitors_for_variable.
  • crates/ocpp-cp/src/lib.rs — the SetVariables handler captures the prior Actual value under the write lock and, on an accepted, value-changing Actual write, enqueues one new RemoteCommand::V201MonitorTrip { monitors, actual_value } per crossed variable. Failure modes / trust boundary: rejected / unknown / non-Actual / no-op-rewrite writes trip nothing; caller values stay opaque and thread to the wire verbatim (oversized → outbound-schema Err, never a panic); no match → nothing enqueued, no seqNo burned; a gone consumer drops the trip best-effort after the honest CALLRESULT.
  • The trip is drained off the command-consumer task (never inline), so the SetVariablesResponse CALLRESULT is flushed before the outbound NotifyEvent — no receive-loop re-entrancy, the same discipline as NotifyReport / NotifyMonitoringReport.
  • The port(m7): CP simulator 2.0.1 — emit NotifyEvent when a CSMS-installed variable monitor trips #545 NotifyEvent build-and-send core is extracted into ChargePoint::emit_monitor_notify_event, reused by both trip_variable_monitor (inline injection seam) and the new V201MonitorTrip consumer — trigger derivation, CustomMonitor tagging, variableMonitoringId correlation, and the monotonic seqNo/eventId streams live in one place (no duplicated trip logic).

What was ported

mobilityhouse/ocpp is protocol-only, so the wire types are the port surface and the when-to-fire behavior is the Charging Station's:

Test plan

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

New tests:

  • Device model (pure): monitors_tripped_by_write selection (only crossed write-driven kinds, id-sorted, periodic excluded, non-numeric ignored); monitor_write_trips predicate (both threshold directions, on-threshold boundary, delta boundary, time-driven excluded); parse_finite_f64 rejects NaN/inf/non-numeric.
  • Handler (via v201_drain_commands): upper-threshold fires on each crossing with hysteresis; lower-threshold symmetric; delta fires on a jump but not a sub-delta write; multiple monitors ride one id-sorted trip; rejected write / non-monitored write / periodic monitor / no-op rewrite / non-numeric value all trip nothing.
  • End-to-end (mock CSMS): a crossing SetVariables write is answered Accepted and the correlated NotifyEvent (trigger: Alerting, CustomMonitor, variableMonitoringId, seqNo 0) follows on the wire — proving the event is emitted after the response, off the command queue.

Acceptance criteria

  • An accepted SetVariables write that crosses an installed threshold/delta monitor auto-originates one schema-valid NotifyEvent per matched variable, via the RemoteCommand queue (not inline).
  • Reuses the port(m7): CP simulator 2.0.1 — emit NotifyEvent when a CSMS-installed variable monitor trips #545 emitter (trigger derivation, EventDataType correlation, monotonic seqNo+eventId, CustomMonitor notification type) — no duplicated trip logic.
  • Periodic/PeriodicClockAligned monitors are not affected; rejected and non-monitored writes never trip; hysteresis prevents same-band re-fire.
  • Trust boundary preserved: opaque values threaded verbatim; oversized value → outbound-schema Err, no panic.
  • 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_01BSdgXz4N5iiSh1UiXPi7za


Generated by Claude Code

…ing SetVariables write (M7)

Closes #573. Completes the OCPP 2.0.1 variable-monitoring loop that #570
(the trip_variable_monitor injection seam) left half-done: a monitor now
trips autonomously when a CSMS SetVariables write moves a monitored
variable's Actual value across an installed threshold, or by >= a
configured delta.

Because the trip is raised from inside an inbound-CALL handler, it is
drained through the RemoteCommand queue (new V201MonitorTrip variant)
rather than emitted inline, so the SetVariablesResponse CALLRESULT is
flushed before the outbound NotifyEvent (no receive-loop re-entrancy) —
the same discipline NotifyReport / NotifyMonitoringReport already follow.

- V201DeviceModel::monitors_tripped_by_write selects only the write-driven
  monitors a numeric crossing hits (Upper/LowerThreshold cross either
  direction; Delta on a single-write jump; Periodic never). Hysteresis
  falls out of crossing detection; non-numeric / NaN / inf values cross
  nothing but are still reported verbatim.
- The SetVariables handler captures the prior Actual value and enqueues one
  V201MonitorTrip per crossed variable; rejected / unknown / non-Actual /
  no-op-rewrite writes trip nothing.
- The #545 NotifyEvent emitter core is extracted into
  ChargePoint::emit_monitor_notify_event, shared by the inline seam and the
  new consumer path — no duplicated trigger/correlation/seqNo logic.

Tests: pure device-model selector + predicate + parse tests; handler tests
via v201_drain_commands covering all threshold/delta/hysteresis/rejected/
periodic/no-op/non-numeric arms; and an end-to-end mock-CSMS test proving
the NotifyEvent is emitted after the response, off the command queue.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BSdgXz4N5iiSh1UiXPi7za
@duyhuynh-vn
duyhuynh-vn merged commit 3d7b6f9 into main Sep 12, 2026
9 checks passed
@duyhuynh-vn
duyhuynh-vn deleted the claude/inspiring-ramanujan-6jk1qd branch September 12, 2026 10:43
duyhuynh-vn pushed a commit that referenced this pull request Sep 12, 2026
Resolve a conflict in crates/ocpp-cp/src/lib.rs test module: #576's
SetVariables auto-trip tests and this PR's NotifyReport tbc-paging tests
were both appended at the same anchor, and git interleaved them on an
identical ChargePoint::new(..) block shared by both tests. Reconstructed
each test intact and de-interleaved; both now run and pass.

cargo fmt --check, clippy --all-targets -D warnings, and test --workspace
all green.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01PeYP9bfNgyvmXsD7aLqFpG
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