ocpp-client 0.3.0

OCPP Client Implementation. Use this library to implement an OCPP charge point
Documentation
# CLAUDE.md

Guidance for Claude Code (claude.ai/code) working in this repository.

## Project overview

`ocpp-client` implements the **charge point** (client) side of OCPP — the network/protocol layer
only, not charging logic or hardware control. Current version `0.3.0` (unreleased; 0.2.2 is the
newest on crates.io). Pre-1.0 and still moving: 0.3.0 alone breaks `TransportSink`/`TransportStream`,
`ReconnectPolicy`, and `ConnectOptions`' defaults.

**OCPP 1.6, 2.0.1 and 2.1** are all implemented, tested end to end, and in `default` features. Every
action `ocpp-types` defines is wired up for every version (28 / 64 / 91) — `tests/action_coverage.rs`
fails the build otherwise. WebSocket is the only production transport; an experimental `embassy-net`
one lives under `crates/` (see the bottom of this file).

Message types come from **`ocpp-types`** (crates.io, `flowionab` org, currently `0.1.3`) — see
`MIGRATION_OCPP_TYPES.md` for the migration off the old `rust-ocpp` fork. It's `no_std` and
allocation-free by default; this crate enables its `alloc` feature unconditionally, so
spec-unbounded fields are plain `String`/`Vec`. Bounded fields (e.g. `IdTag`) stay
`heapless::String<N>` either way, so construction goes through fallible `TryFrom`. `ocpp-types` has
no per-version cargo features; this crate's `ocpp_1_6`/`ocpp_2_0_1`/`ocpp_2_1` features only gate its
own modules.

## Architecture

### One generic engine, not one implementation per version

`src/client.rs` defines `Client<E: ProtocolError>` — the entire CALL/RESULT/ERROR dispatch, timeout
and bookkeeping, written **once**. `OCPP1_6Client`/`OCPP2_0_1Client`/`OCPP2_1Client` are just
`pub type ..Client = Client<..Error>;`. Porting 2.0.1 and 2.1 required zero changes to
`client.rs`/`error.rs`/`envelope.rs`/`transport.rs`, which is the proof this design does its job.

- `src/error.rs` — the `ProtocolError` trait (implemented once per version) and `ClientError<E>`
  (`Protocol`/`Timeout`/`Decode`/`Transport`/`Closed`).
- `src/envelope.rs``RawCall`/`RawResult`/`RawError`/`RawSend` tuple structs mirroring the OCPP-J
  wire arrays. Shared by all versions; the envelope shape doesn't differ between them.
- `src/action.rs` — the `Action` trait (`const NAME`, `type Request`, `type Response`) and its
  single-payload sibling `SendAction` for OCPP-J 2.1 `SEND` (message type 6, fire-and-forget). This
  is also the custom-message extension path: implement `Action` for your own type (vendor fields via
  `#[serde(flatten)]`) and it goes through the same `Client::call`/`Client::on`.
- `src/transport.rs``TransportSink`/`TransportStream`, dyn-safe via hand-written boxed futures
  (**not** the `async-trait` crate, which is deliberately not a dependency), plus
  `TransportEvent::{Frame, Ping(Vec<u8>), Pong(Vec<u8>)}`. `src/transport/websocket.rs` (feature
  `websocket`) wraps a split `tokio-tungstenite` stream.
- `src/keepalive.rs``KeepalivePolicy`/`KeepaliveBehavior`, deliberately shaped like
  `src/reconnect.rs`'s policy/behavior pair. The loop itself is `keepalive_loop` in `client.rs`.
- `src/runtime.rs``Executor`/`Timer` traits plus `with_timeout`/`with_cancel`, both hand-rolled
  `poll_fn` races (no `futures::select` dependency). `runtime/tokio.rs` has the std impls.
- `src/sync.rs``SharedMutex`/`OneShot`/`Chan`/`Notify`/`BroadcastRegistry`, replacing the tokio
  primitives `client.rs` used to need. Built on `embassy-sync` fixed to `CriticalSectionRawMutex`.
- `src/connect.rs` (feature `websocket`) — `connect_1_6`/`_2_0_1`/`_2_1`/`connect` do the handshake
  and protocol negotiation, then build a `Client` via
  `Client::from_transport_with_config(sink, stream, executor, timer, config)`. Options live in
  `ClientConfig`, not a positional list; the older `from_transport`/`from_transport_with_reconnect`
  remain as thin wrappers. `ConnectOptions::default()` enables reconnect **and** keepalive, while
  `ClientConfig::new` enables neither — the convenience path opts in, the raw-transport path stays
  inert.

### Invariants that are easy to break

These each cost a real bug once. Read them before touching `client.rs`.

- **Every bookkeeping entry must be removed by whoever added it, on every exit path.** `Client` keeps
  four tables (`pending_responses`, `pong_waiters`, `request_senders`, `notification_senders`). The
  read loop only removes an entry when a *response arrives*, so any path that gives up — timeout,
  transport send failure — must clean up itself or the table grows forever. `do_send_request` and
  `send_ping_with_timeout` have each shipped this bug once. `tests/ocpp_1_6_bookkeeping.rs` guards
  both via the `test`-gated `pending_request_count`/`pending_ping_count` accessors; add a case there
  when you add a table.
- **Retiring a handler means closing its channel, not just dropping the map entry.** `on`/
  `on_notification` spawn a task parked on a `Chan`; overwriting the registration alone leaves that
  task alive forever. `Chan::close` ends it, and drains first so the outgoing handler still answers
  calls already dispatched to it. `wait_for` likewise removes its own registration on return, guarded
  by `Chan::is_same`.
- **Pongs are matched by correlation token, not arrival order.** Pings carry an 8-byte token as
  payload and `PongState` keys outstanding pings by it. Positional matching meant one timed-out or
  unsolicited pong desynced every later ping permanently. Transports must therefore carry ping/pong
  payloads verbatim (RFC 6455 requires a pong to echo the ping's).
- **The read loop has three distinct exit paths**`LoopExit::{Eof, Forced, Shutdown}` — and
  collapsing them is a bug. `Eof` redials if configured; `Forced` (keepalive gave up, or
  `force_reconnect`) redials after a bounded courtesy close; `Shutdown` (`disconnect()`, via a sticky
  `closed: AtomicBool`) exits without redialling. `closed` also short-circuits the keepalive loop,
  `set_ping_interval`, `force_reconnect` and every send path (which return `ClientError::Closed`).
  Anything new reacting to the connection ending must check it.
- **`recv` is raced against the wake signal unconditionally**, so `disconnect()` can pull the loop
  out of a parked `recv`. That's why `TransportStream::recv`'s cancel-safety contract is
  unconditional too.
- **Reconnect backoff escalates on "this connection never worked", not "the dial failed".**
  `attempt` lives across connections and resets *only* when inbound traffic arrives — never on a dial
  merely completing, which is what made an accept-then-immediately-close peer a zero-delay hot loop
  (~5k dials/s, measured). The delay applies *before* every dial for the same reason.
  `ReconnectPolicy::jitter` (default on) spreads delays over `[d/2, d]` so a fleet doesn't retry in
  lockstep. Keep both halves: escalation must not punish a connection that genuinely worked.
- **`no_std` + `alloc` must keep compiling.** `client.rs`, `transport.rs`, `error.rs`, `runtime.rs`,
  `sync.rs` and the per-version files have all leaked `std` before (`std::error::Error`, `std::fmt`,
  bare `String`/`format!`). Re-run the three proof builds after touching any of them. Diagnostics go
  through `tracing` (itself `#![no_std]`, used unconditionally); this crate never installs a
  subscriber. `tests/ocpp_1_6_logging.rs` shows how to capture events in a test — it needs
  `tracing-test`'s `no-env-filter` feature, since each `tests/*.rs` is its own crate.
- True bare-metal `no_std` (no `alloc`) is out of scope: the engine's `BTreeMap`/`VecDeque`/`Arc`
  bookkeeping is alloc-dependent by design. See PRODUCTION_READINESS.md item 9.

### Per-version modules

`src/ocpp_1_6/` is the template:
- `error.rs` — that version's `ProtocolError` impl, written with one `macro_rules!`
  (`define_error!`) listing `Variant => "WireCode"` pairs, not hand-written match arms.
- `actions.rs` — one `ocpp_1_6_action!(Name, Request, Response, "ActionName", send_x, on_x,
  wait_for_x)` per action, expanding to the marker type plus the three methods. Adding an action is
  one macro line, but callers still get named, autocomplete-friendly methods.
- `mod.rs` — re-exports plus the `OCPP1_6Client` alias.

### Adding an action

Add one `ocpp_{1_6,2_0_1,2_1}_action!(...)` line in that version's `actions.rs` with the
`ocpp_types::{v16,v201,v21}` request/response types. **Write the test first.** Nested enum/field
types live under that version's `common` submodule, not the version root. The action name string is
the struct name minus its `Request`/`Response` suffix; method names are its snake_case (existing
invocations are the reference for acronym runs — `Get15118EVCertificate` →
`get_15118_ev_certificate`, `ClearDERControl` → `clear_der_control`). 2.1's SEND-only
`NotifyPeriodicEventStream` uses `ocpp_2_1_send_action!` instead, since it has no response type.

## TDD is mandatory

Every behavior has a test in `tests/`. Two styles, mirrored per version:

- **Fake-transport** (`tests/ocpp_{1_6,2_0_1,2_1}_fake_transport.rs`) — drive a real `Client` over
  the in-memory transport in `tests/common/mod.rs`. The right default for dispatch, timeouts and
  error propagation.
- **Real-transport** (`tests/ocpp_{1_6,2_0_1,2_1}_websocket.rs`) — a real `tokio-tungstenite` server
  plus `connect_*`. One per version+transport is enough; don't duplicate action-level tests over a
  real socket.

Tests needing the `test` feature (`wait_for_*`, bookkeeping) are invisible to a plain `cargo test`;
run `cargo test --features test`.

Three suites don't fit either style and aren't mirrored per version:
- `tests/action_coverage.rs` — locates the `ocpp-types` source via `cargo metadata`, reads the
  `const ACTION` from each `*_request.rs`, and fails if any action lacks a macro invocation. Guards
  the gap that shipped in 0.2.0 (five actions with types present but no wrapper). If `ocpp-types`
  changes its one-file-per-type layout, this is what breaks.
- `tests/ocpp_1_6_keepalive.rs` / `_websocket_keepalive.rs` — ping/pong and scheduled keepalive.
  Version-independent, so 1.6 only. Millisecond intervals with generous `timeout(..)` bounds rather
  than a mock clock; the real-socket file exists to prove a real peer echoes ping payloads.
- `tests/ocpp_1_6_bookkeeping.rs` / `_disconnect.rs` / `_reconnect_backoff.rs` — the invariants
  above. Each case was confirmed to fail against the unfixed code; keep that property.

## Common commands

This repo is a Cargo **workspace** whose root is also the `ocpp-client` package.
`default-members = ["."]`, so a plain `cargo build`/`test` only touches `ocpp-client`. The two
satellite crates under `crates/` need an explicit `-p`. Don't use `--workspace` without
`--target thumbv7em-none-eabihf` — `ocpp-board-stm32h723-nucleo` depends on `cortex-m`'s inline
`asm!` and won't build for the host.

```sh
cargo build                                   # default: std, tokio-runtime, websocket, all three versions
cargo test --features test                    # all tests, including the test-gated ones
cargo test <name>                             # single test, substring match
cargo fmt                                     # rustfmt, per CONTRIBUTING.md
cargo build --no-default-features --features std,ocpp_1_6      # core + 1.6, no tokio/WebSocket
cargo build --lib --no-default-features --features ocpp_1_6    # no_std+alloc proof (lib-only, no --target needed)
```

MSRV is **1.87** for the library (bound by `heapless` via `ocpp-types`); the test suite needs 1.88
for dev-dependencies. Verify with `cargo +1.87 check --lib --all-features`.

CI (`.github/workflows/ci.yaml`) runs five jobs on push-to-`main` and every PR: `fmt`, `clippy`
(`--all-targets --all-features -D warnings`), `test` (`cargo build` then
`cargo test --features test`), `no_std` (the three lib-only proof builds), and `embedded`
(checks/clippies `ocpp-transport-embassy-net` and full-links `ocpp-board-stm32h723-nucleo` against
the real `thumbv7em-none-eabihf` target, with `RUSTFLAGS: --cfg getrandom_backend="custom"`). Keep
all five green — the `embedded` job in particular catches dead code that host builds don't.

## Embedded satellite crates

`crates/ocpp-transport-embassy-net` (chip-agnostic `no_std`+`alloc` WebSocket transport over
`embassy-net`) and `crates/ocpp-board-stm32h723-nucleo` (NUCLEO-H723ZG firmware scaffold) are
PRODUCTION_READINESS.md item 6. Both compile — and the board crate fully links — against
`thumbv7em-none-eabihf`, but **neither has been run against real hardware or a real CSMS**, and the
transport has no TLS. Each crate's README states its exact status and the two `ocpp-client`-relevant
gaps their existence surfaced: `getrandom` needs a custom backend on bare-metal targets, and
embassy's `Spawner`/`Stack` are `!Sync` where `Executor`/`Reconnector` require `Send + Sync`.