Skip to content

feat(ocpp-cp): page v201 NotifyReport / NotifyMonitoringReport across tbc pages (M7) - #575

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

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

Conversation

@duyhuynh-vn

Copy link
Copy Markdown
Collaborator

Summary

Chunk the OCPP 2.0.1 device-model report streams into multiple correlated pages with toBeContinued (tbc) paging, so a large report is streamed faithfully instead of being silently capped at one WebSocket frame. Advances M7 — OCPP 2.0.1.

Closes #574

Real use case

A back office asks a charging station for its full device model — GetBaseReport(FullInventory) — to reconcile firmware/config before a maintenance window. A real station's device model is dozens to hundreds of variables, far more than fits in one practical WebSocket message. OCPP 2.0.1 defines paging precisely for this: the station streams several NotifyReport CALLs, each carrying a monotonic seqNo and tbc: true until the final page, all correlated by requestId. Until now the simulator emitted a single page regardless of size, so a large inventory (or a large GetMonitoringReport monitor set) was silently truncated — the operator would see only the first frame's worth of variables and reconcile against an incomplete picture. This makes the station report the whole model, in order, across as many pages as it takes.

What changed

crates/ocpp-cp only.

  • v201_command — two pure page-splitters, v201_notify_report_pages and v201_notify_monitoring_report_pages, sharing a v201_report_pages core. Each chunks the snapshot into pages of at most V201_REPORT_ENTRIES_PER_PAGE (25) entries, numbers them with a monotonic seqNo from 0, and flags tbc: Some(true) on every page but the last. Every page echoes the triggering requestId and shares one generatedAt (the snapshot instant, passed in so the builder stays pure and clock-free).
  • Empty report → still exactly one final page (seqNo 0, tbc omitted), with the array omitted rather than sent as []: the reportData / monitor arrays are minItems: 1 when present. No zero-page emission.
  • Failure mode / trust boundary — seqNo is i32 on the wire; an i32::try_from(..).unwrap_or(i32::MAX) guard means a pathologically long report can never panic on the cast. Report contents are the station's own computed snapshot (not attacker-supplied); an oversized individual entry still surfaces as an outbound-schema Err from call(), never a panic or silent truncation — the same guarantee as the single-page path.
  • lib.rs — rewired send_v201_notify_report / send_v201_notify_monitoring_report to emit each page in order via self.call(..), preserving requestId correlation and the command-consumer ordering guarantee (pages sent only after the triggering CALLRESULT is flushed, off the inbound-CALL path). Page size (25) sits comfortably above the seeded standard profile (~10 entries), so the default station still reports in a single page — the existing GetBaseReport(FullInventory) "exactly one page" test is unchanged.

What was ported

mobilityhouse/ocpp is protocol-only, so the paging behavior is the Charging Station's, not a Python module; the wire types being paged are already ported. Faithful to the paging contract of ocpp.v201.call.NotifyReport / NotifyMonitoringReport (tbc, seq_no, request_id, generated_at) and ocpp/v201/datatypes.py ReportDataType / MonitoringDataType. Mirrors the in-repo v201_notify_customer_information_pages / v201_report_charging_profiles_pages idiom.

Test plan

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

New tests:

  • Pure builders (v201_command), both streams: small report → one unpaged page (seqNo 0, tbc omitted); large report (2*page+1 / page+1 entries) → correct page count, tbc: Some(true) on all but the last, monotonic seqNo, requestId echoed, one shared generatedAt, entries preserved in order across the boundary; empty → exactly one final page with the array omitted; every built page (incl. the empty page) is schema-valid.
  • Integration (lib, capturing mock CSMS): grow the device model past one page, stream a real FullInventory report, and assert the ordered multi-page NotifyReport stream on the wire (per-page seqNo / tbc / requestId).

Acceptance criteria

  • Both emitters emit multi-page streams: tbc: true on every page but the last, monotonic seqNo from 0, correlated by requestId, in order, off the command-consumer task.
  • Pure page-splitters with schema-validity + tbc/seqNo unit tests, mirroring v201_notify_customer_information_pages.
  • Small and empty reports keep today's single-page behavior; no zero-page emission.
  • seqNo cast cannot panic; oversized entries surface as an outbound-schema Err, never a panic/truncation.
  • 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_01CiC3yVXvLLuCQMfknqv4vb


Generated by Claude Code

… tbc pages (M7)

The OCPP 2.0.1 device-model report streams (`GetBaseReport`/`GetReport` →
`NotifyReport`, `GetMonitoringReport` → `NotifyMonitoringReport`) emitted a
single page regardless of inventory size, hard-coding `seqNo: 0` / `tbc: None`.
A large `FullInventory` was silently capped at one frame — a conformance and
scaling gap the two emitters explicitly deferred as a "later slice".

Close that slice by paging both streams, mirroring the existing
`NotifyCustomerInformation` / `ReportChargingProfiles` / `NotifyDisplayMessages`
pager idiom (pure page-splitter + thin command-consumer emitter loop):

- Add `v201_notify_report_pages` and `v201_notify_monitoring_report_pages` pure
  builders in `v201_command`, sharing a `v201_report_pages` core that chunks the
  snapshot into pages of at most `V201_REPORT_ENTRIES_PER_PAGE` (25) entries,
  numbers them with a monotonic `seqNo` from 0, and flags `tbc: Some(true)` on
  every page but the last. All pages echo the triggering `requestId` and share
  one `generatedAt` (the snapshot instant, passed in so the builder stays pure).
- An empty report still yields exactly one final page (`seqNo` 0, `tbc` omitted)
  with the array omitted — the report arrays are `minItems: 1` when present, so
  an empty page omits `reportData` / `monitor` rather than sending `[]`.
- `seqNo` is `i32` on the wire; an `i32::try_from(..).unwrap_or(i32::MAX)` guard
  means a pathologically long report can never panic on the cast.
- Rewire `send_v201_notify_report` / `send_v201_notify_monitoring_report` to emit
  each page in order via `self.call(..)`, preserving `requestId` correlation and
  the command-consumer ordering guarantee (pages sent after the triggering
  CALLRESULT is flushed). Page size (25) sits above the seeded standard profile
  (~10 entries), so the default station still reports in a single page.

Tests: pure-pager unit tests (single / multi-page tbc + monotonic seqNo /
empty-one-page / schema validity across shapes) for both streams, plus an
over-the-wire integration test that grows the device model past one page and
asserts the ordered multi-page `NotifyReport` stream via a capturing mock CSMS.

Ports the paging behavior of `ocpp.v201.call.NotifyReport` /
`NotifyMonitoringReport` (`tbc`, `seq_no`, `request_id`, `generated_at`).

Closes #574.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CiC3yVXvLLuCQMfknqv4vb
`cargo doc` runs with `-D warnings` in CI, and `rustdoc::private_intra_doc_links`
rejects a public item's docs linking to a private one. The two public report
pagers linked to the private `v201_report_pages` core; demote those to plain
code spans, matching the fix applied to the NotifyEvent docs previously.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CiC3yVXvLLuCQMfknqv4vb
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
@duyhuynh-vn
duyhuynh-vn merged commit 2752ff7 into main Sep 12, 2026
2 checks passed

Copy link
Copy Markdown
Collaborator Author

🌙 Nightly dev — 2026-09-12: resolved the merge conflict against main.

This PR had drifted to dirty after #576 (v201 SetVariables auto-trip monitor) landed on main. Both PRs appended test code to the same anchor in crates/ocpp-cp/src/lib.rs, and git interleaved the two tests on an identical ChargePoint::new(..) block they happen to share, producing the conflict.

Resolution (merge commit b3f088c, no history rewrite):

Verified locally on the merge result:

  • cargo fmt --all --check ✅
  • cargo clippy --all-targets -- -D warnings ✅
  • cargo test --workspace ✅ — all green; both formerly-conflicting tests pass.

mergeable_state is now clean. No product logic changed — the conflict was confined to the test module. Ready for your review.


Generated by Claude Code

@duyhuynh-vn
duyhuynh-vn deleted the claude/inspiring-ramanujan-w7u8ug branch September 12, 2026 10:58
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