# Changelog
Notable changes per release. Dates are release dates on crates.io.
This file starts at 0.2.0; earlier releases (0.1.x, a per-version tokio-hardwired design that
predates the current generic engine) are covered by git history only.
## 0.5.0 - 2026-08-08
Tracks `ocpp-types` 0.3.0. A dependency-only release: no source file in `src/` changed, and the
whole test suite passed against the new version unedited.
### Changed
- **BREAKING (dependency): `ocpp-types` updated to 0.3.0.** Upstream's own release is purely
additive - no message type, field type or action changed shape, and all 39 / 64 / 91 actions
are the same ones - so **no code in this crate needed editing and no call site here does
either**. It is called out as breaking only because `ocpp-types` is a *public* dependency
(`pub use ocpp_types;`): a consumer that also names `ocpp-types` in its own `Cargo.toml` must
move from `0.2` to `0.3` in the same step, or the two copies are distinct types to the
compiler. A consumer that reaches the types only through `ocpp_client::ocpp_types::..` - the
spelling `src/lib.rs` recommends precisely so this stays a non-event - needs to change nothing.
### Added
- **A `validate` feature**, forwarding `ocpp-types`' own. That adds a `Validate` trait to every
message type covering the spec constraints the types cannot carry: `maxLength` on fields too
large to inline as a `heapless::String` (certificates, CSRs, OCSP results), plus every
`minItems`, `minimum`, `maximum` and `multipleOf` in the schemas. Off by default.
- **`From<ValidationError>` for `OCPP1_6Error`, `OCPP2_0_1Error` and `OCPP2_1Error`** (same
feature). `ocpp-types` classifies a violation as a property- or occurrence-class breach but
leaves the wire code to the caller's version, and the versions disagree: OCPP 1.6J's RPC error
table spells it `OccurenceConstraintViolation`, missing an `r` that 2.0.1 and 2.1 restored.
These impls pick the right one, use upstream's rendering (which names the failing field) as the
CALLERROR description, and put the JSON path in `errorDetails` under `"path"` so a peer can
match on it without parsing prose. A handler can now reject a bad payload with
`request.validate()?` and get the correct code on the wire.
See `tests/validation_error_mapping.rs`.
**Validation is never automatic.** It is deliberately not wired into `Client::call`: that would
require a `Validate` bound on `Action::Request`, and a trait bound that appears only when a
feature is enabled is not additive - one crate in the graph enabling `validate` would break an
unrelated crate's custom `Action` impl, which `src/action.rs` documents as a supported
extension point. Callers validate explicitly, which is one line and composes with their own
error type. The rationale is recorded in PRODUCTION_READINESS.md item 5.
## 0.4.0 - 2026-08-08
Tracks `ocpp-types` 0.2.0. Everything below follows from that release; the engine itself
(`src/client.rs`, `envelope.rs`, `transport.rs`) needed no changes at all.
### Added
- **The eleven OCPP 1.6 security-whitepaper actions**, which `ocpp-types` 0.2.0 was the first
release to define: `CertificateSigned`, `DeleteCertificate`, `ExtendedTriggerMessage`,
`GetInstalledCertificateIds`, `GetLog`, `InstallCertificate`, `LogStatusNotification`,
`SecurityEventNotification`, `SignCertificate`, `SignedFirmwareStatusNotification` and
`SignedUpdateFirmware`. 1.6 now wires up 39 actions rather than 28; 2.0.1 (64) and 2.1 (91) are
unchanged. Each gets the usual `send_*`/`on_*`/`wait_for_*` trio, and
`tests/ocpp_1_6_security_actions.rs` covers one round trip apiece.
- **2.x action markers take the `customData` type as a parameter**: `Reset<AcmeExtension>`,
`Heartbeat<NoCustomData>`, and so on, defaulting to the specification's `CustomData`. A
deployment with a vendor extension richer than a bare `vendorId` can now read and write it
through `Client::call`/`Client::on` without hand-writing an `Action` impl. See
`tests/custom_data_generics.rs`.
- **A `chrono` feature**, forwarding to `ocpp-types`' own: `From`/`Into` between `OcppTimestamp`
and `chrono::DateTime` for callers that already keep time in chrono. Off by default, and chrono
never touches the wire path.
### Changed
- **BREAKING: every `dateTime` field is now `ocpp_types::OcppTimestamp` instead of `String`.**
Construct them with `OcppTimestamp::parse_rfc3339(..)` (or `From<chrono::DateTime>` under the
new `chrono` feature) and compare against a parsed value rather than a string literal. Beyond
the type, this changes behaviour twice over: the client now *validates* what the CSMS sends, so
a malformed `dateTime` surfaces as `ClientError::Decode` where it used to be handed through as a
string; and the value is an instant, so two timestamps naming the same moment in different UTC
offsets compare equal. Fractional seconds and non-UTC offsets both survive the round trip -
`tests/ocpp_1_6_timestamps.rs` pins all of it.
- **BREAKING: 2.0.1 and 2.1 message types carry a `customData` type parameter.** The generated
`send_*`/`on_*`/`wait_for_*` methods stay concrete at `CustomData`, so ordinary call sites are
unaffected, but a 2.x request or response built in a `let` binding with no expected type now
needs an annotation (`let request: ResetRequest = ResetRequest { .. }`) - a defaulted type
parameter does not participate in inference. The methods were deliberately left non-generic for
exactly this reason; the marker types carry the parameter instead.
- **BREAKING: `ocpp_types::v16::common::RequestedMessage` is now
`TriggerMessageRequestRequestedMessage`**, renamed upstream to make room for
`ExtendedTriggerMessageRequestRequestedMessage`. A pure rename, no variants changed.
- **BREAKING: 48 2.x string fields whose length the specification leaves to a configuration
variable are now `String` instead of `heapless::String<N>`** - certificates, certificate
chains, CSRs, OCSP results, `MessageContent.content` and the like. Construction drops from
`"...".try_into().unwrap()` to `"...".into()`. In the other direction, 89 `dateTime` fields and
8 more 2.1 date/time-of-day fields (`TariffConditions.start_time_of_day` and friends) left
`String` for `OcppTimestamp`/`OcppDate`/`OcppTimeOfDay`.
- `ocpp-types` updated to 0.2.0. Its `alloc` feature is now upstream's default; this crate still
names its features explicitly, so nothing about the build changes.
## 0.3.0 - 2026-08-08
### Added
- **WebSocket keepalive.** The client can now ping the CSMS on a schedule and, when it stops
answering, force the connection to be redialled. Previously the client only ever *replied* to
pings the server sent, so a half-open connection - a dropped NAT entry, a mobile link that went
away without a FIN - was invisible: the read loop stayed parked in `recv` until the OS TCP
timeout, and the reconnect logic added in 0.2.0 never got a chance to fire.
- `KeepalivePolicy { interval, timeout, max_missed }` and `KeepaliveBehavior::{Enabled,
Disabled}`, shaped after the existing `ReconnectPolicy`/`ReconnectBehavior` pair.
- `ConnectOptions::keepalive`, **defaulting to enabled** at a 60-second interval tolerating one
missed pong. See "Changed" below.
- `Client::ping_interval()` and `Client::set_ping_interval()` - the read/write path for OCPP's
`OCPPCommCtrlr.WebSocketPingInterval` (2.0.1/2.1) and the 1.6 security whitepaper's
`WebSocketPingInterval` configuration key. Both are non-`async` so a `GetVariables` handler can
call them directly, and `set_ping_interval` takes effect immediately rather than after the
interval already being waited out expires. This is what previously forced consumers to report a
hardcoded `0`: the library owned no interval to report.
- `Client::force_reconnect()` - abandon the current transport and redial now, for callers with
their own liveness signal. This is what keepalive escalates to after `max_missed` unanswered
pings. No-op without a configured reconnector.
- `ClientConfig` and `Client::from_transport_with_config`, so options live in a struct instead of
a positional parameter list. `from_transport_with_reconnect` had already reached seven
arguments; keepalive would have made it eight.
- `Client::pending_request_count()` / `Client::pending_ping_count()` behind the `test` feature -
instrumentation for the bookkeeping tables, since a leak in either is otherwise invisible from
outside.
- `Client::is_closed()` - whether `disconnect()` has been called. Deliberately does *not* report
transient connection state: that would be stale the moment it returned, and `on_reconnect` is the
reliable way to observe reconnection.
- **`tests/action_coverage.rs`** fails the build if `ocpp-types` defines an action that no
`ocpp_*_action!` invocation wires up. This is the class of gap that shipped in 0.2.0 (see below)
and was invisible at compile time.
### Fixed
- **Every timed-out request leaked an entry in the pending-response table.** `do_send_request`
registered a waiter keyed by message id, and the only code that removed it was the read loop on an
arriving CALLRESULT/CALLERROR - so a request that timed out, or that never made it onto the wire,
left its entry behind permanently. On a charge point running for weeks against an intermittent
CSMS the map grew without bound. Each request now removes its own waiter on every exit path, the
same fix the ping table already got.
In-flight requests are still not failed early by `disconnect()`; they wait out their timeout. That
is a latency wart rather than a leak, and is left as-is.
- **Replacing a handler leaked its task.** `Client::on` (and `on_notification`) overwrote the map
entry for an action, which made the previous task unreachable but not finished - it stayed parked
forever on a channel nothing could deliver to, one leaked task per re-registration. The internal
channel is now closable, and the superseded handler is retired: it drains whatever was already
dispatched to it, answers those calls, and then exits.
- **`wait_for` left the action registered** (`test` feature). After it returned, the action stayed
bound to a channel with no reader, so any later CALL for it was queued and silently forgotten -
the peer got no CALLRESULT and no CALLERROR, which is indistinguishable from the client hanging.
It now unregisters on the way out, and only if the registration is still its own.
- **A peer that accepted the connection and then closed it immediately was redialled in a hot loop
with no delay at all.** `ReconnectPolicy` only delayed *failed* dials, and the attempt counter
reset on every successful one - so a dial that completed and then instantly dropped never waited,
unboundedly. Measured against a local server that completes the WebSocket handshake and drops:
**~9,900 connections in 2 seconds**, roughly 5k/s from a single charge point. The triggers are
ordinary - a CSMS rejecting the charge point at the application layer, an overloaded endpoint, a
load balancer with no live backend - and a fleet doing this at once is a self-inflicted DoS.
Two changes fix it. The backoff delay is now applied *before* every dial rather than only after a
failed one, and the attempt counter is reset by evidence that a connection actually **works** -
any inbound traffic on it - instead of by the mere fact that a dial completed. So a genuine
transient drop still reconnects after one `initial_delay`, while a connection that never carries
anything escalates to `max_delay` as intended.
`disconnect()` during a long backoff now also takes effect immediately instead of waiting out the
remaining delay (up to `max_delay`, 60s by default).
- **Reconnect delays are now jittered** (`ReconnectPolicy::jitter`, default `true`): each delay is
drawn uniformly from `[delay / 2, delay]`. Without it, every charge point that lost the same CSMS
retried in lockstep, so the endpoint coming back got the whole fleet at once, repeatedly. Half the
delay is left un-jittered so a randomly tiny value can't defeat the rate bound. Randomness comes
from `uuid`, already a dependency and already working on this crate's bare-metal target, so no new
RNG dependency or embedded plumbing is involved.
One behavior change worth noting: the first redial after a drop now waits `initial_delay`
(jittered) where it previously went out immediately.
- **`disconnect()` was undone by the reconnector.** Closing the transport is indistinguishable from
a dropped connection from the read loop's side, so with reconnect enabled - the default on
`ConnectOptions` - an explicit `disconnect()` produced an EOF that got dutifully redialled. On
default options there was no way to stop a client at all.
`disconnect()` now marks the client closed before closing the transport, and that flag is sticky
and outranks every automatic recovery path: the read loop exits instead of redialling (whether it
was parked in `recv` or saw the EOF), the keepalive task stops, `set_ping_interval` cannot restart
it, `force_reconnect()` becomes a no-op, and further `call`/`send_*`/`send_ping` return
`ClientError::Closed` immediately instead of writing to a dead transport and waiting out the
request timeout. `disconnect()` is also idempotent now. An *unrequested* drop is still redialled
exactly as before.
This also gives `ClientError::Closed` its first construction site - the variant existed but was
never produced by anything.
### Changed
- **Ping/pong now carry payloads, and pongs are matched by correlation token.** `send_ping` writes
an 8-byte token as the ping's application data and only a pong echoing that exact payload
resolves it, per RFC 6455 ยง5.5.2-3. Matching was previously positional, which had two failure
modes that a scheduled keepalive would have hit constantly:
- A ping that timed out left its waiter queued forever, permanently offsetting every later
ping's pong by one - so *every* subsequent `send_ping` on that client timed out. Reachable
before this release via reconnect, since the waiter table was not cleared on redial and stale
waiters ate the first pong of the new connection.
- An unsolicited pong (which RFC 6455 permits) caused the same one-off desync.
Outstanding pings are now also cleared when the read loop swaps in a reconnected transport.
- **`ConnectOptions::default()` enables keepalive**, where the previous behavior sent no
unsolicited traffic. Same reasoning as `reconnect` defaulting to enabled: without keepalive a
charge point cannot detect a half-open link at all. Set `keepalive:
KeepaliveBehavior::Disabled` to restore the old behavior. The lower-level
`Client::from_transport*` constructors still default to **disabled** - a caller assembling a
client from raw transport halves has said nothing about wanting background traffic on it.
### Breaking
- **`rust-version = "1.87"` is now declared.** Not a change in what the crate needs, but if you
were building it on an older toolchain it will now fail with a clear MSRV error rather than a
confusing parse error. Contributors need 1.88 for the test suite (dev-dependencies).
- `ReconnectPolicy` gained a `jitter: bool` field, which breaks struct-literal construction
(`ReconnectPolicy { initial_delay, max_delay, multiplier }`). Add `jitter: true` for the new
default behavior, `jitter: false` for exact delays, or switch to
`..ReconnectPolicy::default()`.
The remaining changes affect implementors of the transport traits only - not callers of
`connect_*`/`Client`. The in-tree `ocpp-transport-embassy-net` is updated accordingly.
- `TransportSink::ping` and `TransportSink::pong` now take a `Vec<u8>` payload, which must be
transmitted verbatim (`pong` echoes the triggering ping's payload).
- `TransportEvent::Ping` and `TransportEvent::Pong` are now `Ping(Vec<u8>)` / `Pong(Vec<u8>)`.
- `TransportStream::recv` is now documented as having to be **cancel-safe**: its future may be
dropped mid-poll without losing an event. The read loop races it against the force-reconnect
signal, which is how a stalled `recv` gets abandoned instead of parking the loop. Both in-tree
implementations already satisfied this (`futures::StreamExt::next`, an `embassy-net` socket
read); a third-party transport buffering partial state across an `.await` in a local needs to
move that state into `self`.
## 0.2.2 - 2026-08-08
### Added
- `ConnectOptions::reconnector` - lets callers decide where a dropped connection is redialled, for
a charge point that must move to a different CSMS address (an OCPP 2.x network connection
profile, a failover endpoint) without tearing down its `Client` and losing its handlers,
in-flight requests and queued messages.
- Criterion benchmarks (`benches/`) plus a CI job that compiles them.
- Community/repo health files: code of conduct, security policy, PR and issue templates.
## 0.2.1 - 2026-08-06
### Added
- **Five actions whose types existed in `ocpp-types` but which no macro invocation had wired up**,
so callers could not send or receive them. If you are on 0.2.0 and believe one of these is
missing, upgrade - this is the release that added them:
- OCPP 2.0.1: `SecurityEventNotification`
- OCPP 2.1: `TriggerMessage`, `SetDisplayMessage`, `GetDERControl`, `SetDERControl`,
`UpdateDynamicSchedule`
Each has a fake-transport round-trip test. `tests/action_coverage.rs` (0.3.0, above) now
prevents this class of gap from recurring.
### Changed
- `ocpp-types` updated to 0.1.3.
## 0.2.0 - 2026-08-04
First release of the rewritten crate: one generic engine (`Client<E>`) shared by every OCPP
version, replacing the previous per-version, tokio-hardwired design.
### Added
- OCPP 1.6, 2.0.1 and 2.1, all in `default` features.
- Automatic reconnection with bounded exponential backoff (`ReconnectPolicy`,
`ReconnectBehavior`), plus `Client::on_reconnect` for post-redial session resync.
- `no_std` + `alloc` support: the engine no longer hard-depends on tokio or `async-trait`. Task
spawning and timeouts go through the `Executor`/`Timer` traits; `tokio-runtime` provides the std
implementations.
- Custom TLS trust config via `ConnectOptions::tls_config`, including client certificates for
OCPP Security Profile 3 (mutual TLS).
- `connect()` for protocol negotiation across every compiled-in OCPP version.
- Structured logging through `tracing` (no global subscriber is installed by this crate).
- `SEND` (OCPP-J 2.1 message type 6) support, and `NotifyPeriodicEventStream` modeled as a
fire-and-forget notification rather than a call/response pair.
- Embedded satellite crates: `ocpp-transport-embassy-net` and
`ocpp-board-stm32h723-nucleo`. Neither has been run against real hardware or a real CSMS yet.
### Changed
- Message types migrated from a `rust-ocpp` fork to `ocpp-types` on crates.io. See
`MIGRATION_OCPP_TYPES.md`.