Repository navigation
Releases: flowionab/ocpp-client
Release list
v0.5.0
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-typesupdated 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 becauseocpp-typesis a public dependency
(pub use ocpp_types;): a consumer that also namesocpp-typesin its ownCargo.tomlmust
move from0.2to0.3in the same step, or the two copies are distinct types to the
compiler. A consumer that reaches the types only throughocpp_client::ocpp_types::..- the
spellingsrc/lib.rsrecommends precisely so this stays a non-event - needs to change nothing.
Added
-
A
validatefeature, forwardingocpp-types' own. That adds aValidatetrait to every
message type covering the spec constraints the types cannot carry:maxLengthon fields too
large to inline as aheapless::String(certificates, CSRs, OCSP results), plus every
minItems,minimum,maximumandmultipleOfin the schemas. Off by default. -
From<ValidationError>forOCPP1_6Error,OCPP2_0_1ErrorandOCPP2_1Error(same
feature).ocpp-typesclassifies 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 itOccurenceConstraintViolation, missing anrthat 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 inerrorDetailsunder"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.
Seetests/validation_error_mapping.rs.Validation is never automatic. It is deliberately not wired into
Client::call: that would
require aValidatebound onAction::Request, and a trait bound that appears only when a
feature is enabled is not additive - one crate in the graph enablingvalidatewould break an
unrelated crate's customActionimpl, whichsrc/action.rsdocuments 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.
v0.4.0
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-types0.2.0 was the first
release to define:CertificateSigned,DeleteCertificate,ExtendedTriggerMessage,
GetInstalledCertificateIds,GetLog,InstallCertificate,LogStatusNotification,
SecurityEventNotification,SignCertificate,SignedFirmwareStatusNotificationand
SignedUpdateFirmware. 1.6 now wires up 39 actions rather than 28; 2.0.1 (64) and 2.1 (91) are
unchanged. Each gets the usualsend_*/on_*/wait_for_*trio, and
tests/ocpp_1_6_security_actions.rscovers one round trip apiece. - 2.x action markers take the
customDatatype as a parameter:Reset<AcmeExtension>,
Heartbeat<NoCustomData>, and so on, defaulting to the specification'sCustomData. A
deployment with a vendor extension richer than a barevendorIdcan now read and write it
throughClient::call/Client::onwithout hand-writing anActionimpl. See
tests/custom_data_generics.rs. - A
chronofeature, forwarding toocpp-types' own:From/IntobetweenOcppTimestamp
andchrono::DateTimefor callers that already keep time in chrono. Off by default, and chrono
never touches the wire path.
Changed
- BREAKING: every
dateTimefield is nowocpp_types::OcppTimestampinstead ofString.
Construct them withOcppTimestamp::parse_rfc3339(..)(orFrom<chrono::DateTime>under the
newchronofeature) 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 malformeddateTimesurfaces asClientError::Decodewhere 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.rspins all of it. - BREAKING: 2.0.1 and 2.1 message types carry a
customDatatype parameter. The generated
send_*/on_*/wait_for_*methods stay concrete atCustomData, so ordinary call sites are
unaffected, but a 2.x request or response built in aletbinding 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::RequestedMessageis 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 nowStringinstead ofheapless::String<N>- certificates, certificate
chains, CSRs, OCSP results,MessageContent.contentand the like. Construction drops from
"...".try_into().unwrap()to"...".into(). In the other direction, 89dateTimefields and
8 more 2.1 date/time-of-day fields (TariffConditions.start_time_of_dayand friends) left
StringforOcppTimestamp/OcppDate/OcppTimeOfDay. ocpp-typesupdated to 0.2.0. Itsallocfeature is now upstream's default; this crate still
names its features explicitly, so nothing about the build changes.
v0.3.0
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 inrecvuntil the OS TCP
timeout, and the reconnect logic added in 0.2.0 never got a chance to fire.KeepalivePolicy { interval, timeout, max_missed }andKeepaliveBehavior::{Enabled, Disabled}, shaped after the existingReconnectPolicy/ReconnectBehaviorpair.ConnectOptions::keepalive, defaulting to enabled at a 60-second interval tolerating one
missed pong. See "Changed" below.Client::ping_interval()andClient::set_ping_interval()- the read/write path for OCPP's
OCPPCommCtrlr.WebSocketPingInterval(2.0.1/2.1) and the 1.6 security whitepaper's
WebSocketPingIntervalconfiguration key. Both are non-asyncso aGetVariableshandler can
call them directly, andset_ping_intervaltakes effect immediately rather than after the
interval already being waited out expires. This is what previously forced consumers to report a
hardcoded0: 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 aftermax_missedunanswered
pings. No-op without a configured reconnector.ClientConfigandClient::from_transport_with_config, so options live in a struct instead of
a positional parameter list.from_transport_with_reconnecthad already reached seven
arguments; keepalive would have made it eight.
Client::pending_request_count()/Client::pending_ping_count()behind thetestfeature -
instrumentation for the bookkeeping tables, since a leak in either is otherwise invisible from
outside.Client::is_closed()- whetherdisconnect()has been called. Deliberately does not report
transient connection state: that would be stale the moment it returned, andon_reconnectis the
reliable way to observe reconnection.tests/action_coverage.rsfails the build ifocpp-typesdefines 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(andon_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_forleft the action registered (testfeature). 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.ReconnectPolicyonly 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 oneinitial_delay, while a connection that never carries
anything escalates tomax_delayas intended.disconnect()during a long backoff now also takes effect immediately instead of waiting out the
remaining delay (up tomax_delay, 60s by default). -
Reconnect delays are now jittered (
ReconnectPolicy::jitter, defaulttrue): 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
fromuuid, 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 explicitdisconnect()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 inrecvor saw the EOF), the keepalive task stops,set_ping_intervalcannot restart
it,force_reconnect()becomes a no-op, and furthercall/send_*/send_pingreturn
ClientError::Closedimmediately 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::Closedits 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_pingwrites
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 subsequentsend_pingon 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.
- A ping that timed out left its waiter queued forever, permanently offsetting every later
-
ConnectOptions::default()enables keepalive, where the previous behavior sent no
unsolicited traffic. Same reasoning asreconnectdefaulting to enabled: without keepalive a
charge point cannot detect a half-open link at all. Setkeepalive: KeepaliveBehavior::Disabledto 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).ReconnectPolicygained ajitter: boolfield, which breaks struct-literal construction
(ReconnectPolicy { initial_delay, max_delay, multiplier }). Addjitter: truefor the new
default behavior,jitter: falsefor 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::pingandTransportSink::pongnow take aVec<u8>payload, which must be
transmitted verbatim (pongechoes the triggering ping's payload).TransportEvent::PingandTransportEvent::Pongare nowPing(Vec<u8>)/Pong(Vec<u8>).TransportStream::recvis 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 stalledrecvgets abandoned instead of parking the loop. Both in-tree
implementations already satisfied this (futures::StreamExt::next, anembassy-netsocket
read); a third-party transport buffering partial state across an.awaitin a local needs to
move that state into `sel...