# Production readiness
Gaps to close before pointing real hardware/fleets at this crate, in priority order.
1. ~~**No reconnection logic.**~~ **Done.** `Client<E>`'s background read loop now takes an
optional `Reconnector` (`src/reconnect.rs`) plus a `ReconnectPolicy` (bounded exponential
backoff, unlimited attempts) via `Client::from_transport_with_reconnect`. `connect_1_6` /
`connect_2_0_1` / `connect_2_1` wire up a WebSocket-backed `Reconnector` automatically and
reconnect **on by default** (`ConnectOptions::reconnect: ReconnectBehavior`, default
`Enabled(ReconnectPolicy::default())`; set to `Disabled` to opt out). Embedded/non-WebSocket
transports get the same behavior for free by implementing `Reconnector` themselves. Covered
by `tests/ocpp_1_6_reconnect.rs` (real socket drop + redial) and a unit test for the backoff
math. Not yet covered: failing in-flight requests immediately on disconnect (they still wait
out the full timeout) and a hook for re-running `BootNotification`/state resync after
reconnect — see item 2.
2. ~~**No session-resilience layer on top of reconnection.**~~ **Partially done.**
`Client::on_reconnect` (mirrors `on_ping`) fires a callback every time the background read
loop redials successfully - never on the initial connection, only on later reconnects, so
it's the natural place for a caller to re-run `BootNotification` or resync other session
state. Actually re-running `BootNotification`/replaying `StatusNotification` automatically
is still not this crate's job - it's a "protocol layer only" crate (that's what
`ocpp-charge-point` is for) - but the hook to build that on top of now exists, where before
there was no reconnect signal at all to hook into. Covered by
`tests/ocpp_1_6_on_reconnect.rs` (fake-transport test with a custom `Reconnector`, proving
the callback fires after a redial and not before). Where the redial *goes* is also a
caller's choice now: `ConnectOptions::reconnector` overrides the built-in
fixed-address `Reconnector`, and `websocket_transport(address, version, options)` opens the
transport halves such a reconnector must return, so pointing a live connection at a different
CSMS address does not mean reimplementing this crate's WebSocket plumbing - or dropping the
`Client` and losing every handler, in-flight request and queued message with it. That is what
an OCPP 2.x `SetNetworkProfile` switch needs from this layer. Covered by
`tests/ocpp_1_6_custom_reconnector.rs` (a real drop on one address, redialled onto a second
address the initial connect never saw, plus `ReconnectBehavior::Disabled` still winning over a
supplied reconnector).
3. ~~**CI is thin.**~~ **Done.** `.github/workflows/ci.yaml` now runs four parallel jobs on
push-to-`main` and on every pull request: `fmt` (`cargo fmt --all -- --check`), `clippy`
(`cargo clippy --all-targets --all-features -- -D warnings`), `test` (`cargo build` then
`cargo test --features test`, so the `wait_for_*` tests are no longer invisible to CI), and
`no_std` (the `cargo build --lib --no-default-features --features ocpp_{1_6,2_0_1,2_1}`
proof build for each version, per CLAUDE.md's embedded-support guarantee). Fixed the one
pre-existing clippy warning that blocked `-D warnings` (a collapsible `if` in
`connect.rs::setup_socket`) and silenced `result_large_err` in the WebSocket test files
(`tokio-tungstenite`'s handshake closure signature makes that one unavoidable without
restructuring the test helper).
4. ~~**`NotifyReport` (OCPP 2.1) is unimplemented**~~ **Done.** Resolved by migrating message
types from the `rust-ocpp` fork to [`ocpp-types`](https://github.com/flowionab/ocpp-types)
(see `MIGRATION_OCPP_TYPES.md`), which ships a real `NotifyReportRequest`/`Response`.
`ocpp_2_1_action!(NotifyReport, ...)` is wired up in `src/ocpp_2_1/actions.rs` like every
other action, covered by a fake-transport test proving a full CALL/CALLRESULT round trip
(`tests/ocpp_2_1_fake_transport.rs::call_resolves_notify_report_now_that_its_wired_up`).
Every 2.1 action is now implemented, including `NotifyPeriodicEventStream` as a genuine
`SEND` (OCPP-J message type 6, fire-and-forget, no CALLRESULT) rather than the earlier
call/response modeling workaround - `Client`'s engine gained real SEND-frame support
(`Client::send_notification`/`Client::on_notification`, backed by the new `SendAction`
trait) and `src/ocpp_2_1/actions.rs` wires the action up via the new
`ocpp_2_1_send_action!` macro. Covered by
`tests/ocpp_2_1_fake_transport.rs::send_notify_periodic_event_stream_writes_a_send_frame_and_does_not_wait_for_a_reply`
and `::on_notify_periodic_event_stream_fires_and_never_sends_a_reply`, the latter also
asserting the client never auto-replies to a received SEND.
5. **Versioning/release.** Crate is `0.2.0-alpha.1`. The git-fork dependency blocker is gone -
`ocpp-types` is a real crates.io release (`0.1.1`), not a git dependency, so that specific
`cargo publish` obstacle no longer applies. `ocpp-types` itself is early (`0.1.x`, same org as
the old `rust-ocpp` fork - see `MIGRATION_OCPP_TYPES.md`'s Risk section for a codegen bug
found and fixed upstream mid-migration), so pin it deliberately rather than assuming API
stability. API here is still alpha-stability regardless.
6. ~~**README overclaims embedded transport support.**~~ **Done.** It advertised "STM32-compatible
transport layer" / "STM32 transport: ✅ Supported," but only the WebSocket transport
actually existed. `no_std`+`alloc` compiles and the `TransportSink`/`TransportStream` traits
exist to build one against, and now a real embedded transport implementation ships too.
`crates/ocpp-transport-embassy-net` (new workspace member) is a chip-agnostic
`no_std`+`alloc` WebSocket transport over `embassy-net`, built on `embedded-websocket`'s
sans-io core - `cargo check`/`clippy -D warnings` pass against the real
`thumbv7em-none-eabihf` target (see that crate's README for the exact commands). It has not
been run against real hardware or a real CSMS, has no TLS support, and simplifies the
WebSocket close handshake (no close-reply frame sent) - accepted as-is: any real board deploy
requires hardware-specific customization anyway, so hardware-in-the-loop validation and TLS
are left to downstream integrators rather than blocking this crate's readiness. Checking it
against a real embedded
target (not just the host, which the top-level `no_std` CI job only does) surfaced two things
worth knowing about `ocpp-client` itself, documented in the new crate's README:
- `uuid`'s `v4` feature (used for OCPP-J message IDs) pulls in `getrandom`, which has no
backend on bare-metal `thumbv7em-none-eabihf` without the firmware binary opting into
getrandom's "custom backend" mechanism (`--cfg getrandom_backend="custom"` plus a
hardware-RNG-backed extern fn) - invisible from `ocpp-client`'s own no_std CI job, which
builds for the host, where a real OS-provided entropy source always exists.
- `ocpp_client::Executor`/`Reconnector` are declared `Send + Sync + 'static` (right for
tokio's multi-threaded model, which is the only `Executor` impl that exists today) but
`embassy_executor::Spawner`/`embassy_net::Stack` are deliberately `!Sync` (`Spawner` is
`!Send` too) since embassy's single-core cooperative executor has no real concurrent access
to guard against. The new crate bridges this with a handful of documented `unsafe impl
Send`/`Sync` assertions rather than a design change in `ocpp-client` itself; still worth
being aware this trait boundary was written with only a multi-threaded runtime in mind.
`crates/ocpp-board-stm32h723-nucleo` (new workspace member, excluded from
`default-members` since it needs a real ARM target) is the board-support crate: clock tree
and NUCLEO-H723ZG's onboard LAN8742A Ethernet RMII pin mapping (cross-checked against two
independent working reference sources - see that crate's README), hardware RNG shared
between `ocpp-transport-embassy-net`'s `RngFactory` and `getrandom`'s custom backend (closing
the loop on the `getrandom` gap above - `cargo build` there does a *full link*, not just
`check`, specifically to catch the missing-custom-backend-symbol failure that a `check`-only
CI job wouldn't). Builds and links for `thumbv7em-none-eabihf`; not yet flashed to real
hardware or tested against a real CSMS, which is expected to happen per-board during
downstream integration rather than in this repo. CI's new `embedded` job covers both new
crates against the real target on every push/PR (`.github/workflows/ci.yaml`). See
`ocpp-transport-embassy-net`'s README's "Next steps" for hardware-in-the-loop testing and TLS
as optional follow-ups for integrators.
7. ~~**No structured logging/telemetry.**~~ **Done.** Every diagnostic in `client.rs` (malformed
frames, unparsable CALL/CALLRESULT/CALLERROR, failed response encode/send, reconnect
attempts and successes) now goes through the `tracing` crate (`warn!`/`error!`/`info!` with
structured fields, e.g. `error = %err`, `attempt`) instead of a bare `std`-gated `eprintln!`.
`tracing` is `#![no_std]` itself and used unconditionally - not gated behind our `std`
feature - so these events fire under `no_std`+`alloc` too; our `std` feature now forwards to
`tracing/std` for thread-local dispatch. This crate never installs a global `Subscriber`
itself (that's the application's job - `tracing-subscriber`'s `fmt` layer on std, a
`defmt`/RTT-backed one on embedded), so there's zero behavior change for anyone not already
listening. Covered by `tests/ocpp_1_6_logging.rs` (`tracing-test`, dev-only, with its
`no-env-filter` feature - required because `tests/*.rs` integration tests are each their own
crate, so the default per-crate env filter would otherwise hide `ocpp_client`'s events).
8. ~~**TLS trust config isn't exposed.**~~ **Done.** `ConnectOptions::tls_config: Option<Arc<rustls::ClientConfig>>`
lets callers supply their own `rustls::ClientConfig` (custom root CA, mTLS client certs,
whatever `rustls` supports) instead of the default public-CA-only `webpki-roots` trust
store; `None` keeps the old default behavior. Threaded through reconnect too - the
`WebSocketReconnector` reuses the same config on every redial. `ocpp_client::rustls` now
re-exports the exact `rustls` version this crate was built against, so callers don't have to
pin a matching version themselves. Covered by `tests/ocpp_1_6_custom_tls.rs`: one test proves
a `wss://` connection succeeds against a self-signed cert when its exact cert is the
configured root, another proves the *default* config still rejects that same self-signed
cert (i.e. the escape hatch doesn't weaken the default trust posture). `rcgen` (dev-only,
`aws_lc_rs` backend to match `rustls`'s default and keep only one crypto provider in the
graph) generates the test certificate. OCPP Security Profile 3 (mutual TLS: client presents a
certificate, no HTTP Basic Auth) is covered too - `tests/ocpp_1_6_mtls.rs` builds a
`ClientConfig` via `.with_client_auth_cert(..)` and a server `ServerConfig` with a
`WebPkiClientVerifier` that only trusts that one client cert, proving a full CALL/CALLRESULT
round trip over mTLS; a second test proves the server rejects a client presenting no
certificate at all. Profiles 1/2/3 aren't modeled as a named concept anywhere in the crate -
they fall out of whatever the caller puts in `ConnectOptions` (`tls_config` +
`username`/`password`).
9. **True bare-metal no_std, no `alloc`.** `Client<E>`'s bookkeeping (`src/client.rs`) is
`alloc`-dependent by design: `Arc` for cross-task shared ownership, `BTreeMap` for the
pending-request/handler tables, `VecDeque` for pong waiters, `Box<dyn TransportSink/
TransportStream/Executor/Timer>` for the dyn-trait transport/runtime abstraction, plus
`String`/`format!` for error messages. `ocpp-types` itself supports no-alloc (bounded
`heapless` types), but this crate enables its `alloc` feature unconditionally regardless of
this crate's own `std` status. Closing this gap means replacing the above with fixed-capacity
`heapless` equivalents sized by const generics (bounding max in-flight requests, pong
waiters, subscribers, etc. at compile time) and turning the dyn-trait transport/executor
abstraction into a generic-parameter one instead - which cascades into `Client<E>`'s
single-generic design (see CLAUDE.md's `no_std`+`alloc` section for why the dyn-trait
approach was chosen). Not expected to move throughput - OCPP's request rate is low enough
that heap traffic here is nowhere near a hot path - the payoff would be deterministic
worst-case latency, no fragmentation risk over long uptimes, and static memory budgeting for
genuinely allocator-less embedded targets. No specific target driving this yet; tracked as a
future goal.
Scoping pass (no code changes yet) turned up two layers to this, not one:
- **The collections are mechanical.** `BTreeMap<Uuid, OneShot<..>>` (pending responses),
`BTreeMap<String, Chan<..>>` ×2 (request/notification handler registries), `VecDeque<OneShot<()>>`
(pong waiters), `Chan<T>`'s internal `VecDeque<T>`, and `Vec<Arc<Signal>>` (ping/reconnect
subscriber fan-out, in `src/sync.rs`) all become `heapless::FnvIndexMap`/`Deque`/`Vec` with
const-generic capacities. The one real design question is a backpressure policy for `Chan`'s
currently-unbounded queue (drop oldest / reject / block the sender) - bounded-by-construction is
the point on embedded, so "unbounded" isn't an option to preserve. `Arc` itself (used so a
`Client` clone handed to a spawned task shares state with the original, and so `OneShot`'s
`Signal` can live in a lookup map while the caller independently awaits its own clone) needs a
compile-time-sized slot pool referenced by index instead, or a redesign where handlers don't need
independent ownership of shared state.
- **The actual crux is task spawning, not collections.** `Executor::spawn` (`src/runtime.rs`) takes
`Pin<Box<dyn Future<Output = ()> + Send>>`, and `Client` spawns a *new background task per call*
to `on()`, `on_notification()`, `on_ping()`, `on_reconnect()`, plus the read loop itself. That's a
heap-task model by construction. `embassy-executor` - the realistic no_std target - has no
equivalent: task pools are statically sized at compile time via `#[embassy_executor::task]`, with
no "spawn an arbitrary boxed future at runtime" escape hatch. Reaching true no-alloc means `Client`
can't spawn per-handler tasks dynamically anymore - it needs to poll a fixed, compile-time-bounded
set of registered handlers from one task instead (a hand-rolled `select` over an array of
futures). That's a real change to `on()`/`on_notification()`'s semantics (how many concurrent
handlers are supported becomes a compile-time constant), not just an internal swap - which is
exactly the kind of decision that should be driven by a real embedded target's actual constraints
rather than guessed at ahead of one.
Separately (surfaced by the same pass, but independent of alloc): `TransportSink`/`TransportStream`'s
method futures (`src/transport.rs`) are bounded `+ Send`, which is wrong for a single-threaded
embedded executor where futures are never required to be `Send` (embassy tasks are commonly `!Send`
- see the `getrandom`/`Spawner`/`Stack` gaps item 6 already documents for the embedded satellite
crates). Dropping that bound isn't a local change, though: `Client`'s background read loop awaits
those futures inside the async block handed to `Executor::spawn`, whose own signature requires
`Pin<Box<dyn Future<Output = ()> + Send>>` (needed by `TokioExecutor::spawn` → `tokio::spawn`, which
itself requires `Send`). Relax the transport bound alone and the default tokio/std build stops
compiling - fixing it properly means reworking `Executor::spawn`'s `Send` bound too, which is really
part of the same generic-parameter redesign as the rest of this item, not a separate one.