ocpp_client/keepalive.rs
1//! Client-initiated WebSocket keepalive: ping the peer on a fixed interval and, when it stops
2//! answering, force the connection to be redialled.
3//!
4//! This lives in the engine rather than in the WebSocket transport because
5//! `TransportSink::ping`/`TransportEvent::Pong` are already part of the transport abstraction -
6//! so `ocpp-transport-embassy-net`, or any future framed transport, gets keepalive for free
7//! without reimplementing the timing or the dead-peer logic.
8//!
9//! Shaped after `src/reconnect.rs`: a policy struct plus an `Enabled`/`Disabled` behavior enum,
10//! so `ConnectOptions` reads the same way for both.
11//!
12//! The OCPP mapping is `OCPPCommCtrlr.WebSocketPingInterval` in 2.0.1/2.1 and the
13//! `WebSocketPingInterval` configuration key in the 1.6 security whitepaper. This crate does not
14//! implement a device model; it owns the timer and exposes the value through
15//! `Client::ping_interval`/`Client::set_ping_interval` so the layer that does own the device
16//! model can report and update it.
17
18use core::time::Duration;
19
20/// How often to ping, how long to wait for each pong, and how many consecutive misses mean the
21/// connection is dead.
22#[derive(Debug, Clone, Copy, PartialEq, Eq)]
23pub struct KeepalivePolicy {
24 /// Delay between pings. `Duration::ZERO` disables pinging, matching how OCPP's
25 /// `WebSocketPingInterval` reads a 0 value.
26 pub interval: Duration,
27 /// How long to wait for each pong before counting it as missed. `None` uses the client's
28 /// own request timeout, which is what almost every caller wants - a pong that takes longer
29 /// than a CALL is already a broken link.
30 pub timeout: Option<Duration>,
31 /// Consecutive missed pongs before the connection is treated as dead and redialled. `0` is
32 /// treated as `1`. Defaults to `2` so a single dropped frame - or a peer that answers one
33 /// ping oddly - doesn't cost a reconnect.
34 pub max_missed: u32,
35}
36
37impl Default for KeepalivePolicy {
38 fn default() -> Self {
39 Self {
40 interval: Duration::from_secs(60),
41 timeout: None,
42 max_missed: 2,
43 }
44 }
45}
46
47impl KeepalivePolicy {
48 /// A policy pinging every `interval`, with default timeout and miss tolerance.
49 pub fn every(interval: Duration) -> Self {
50 Self {
51 interval,
52 ..Self::default()
53 }
54 }
55
56 /// `max_missed`, with `0` normalized to `1` - a policy that tolerated zero misses before
57 /// declaring the link dead would still have to act on the first one.
58 pub(crate) fn misses_allowed(&self) -> u32 {
59 self.max_missed.max(1)
60 }
61}
62
63/// Whether a client pings its peer on a schedule.
64///
65/// `ConnectOptions` defaults this to `Enabled(KeepalivePolicy::default())` - a charge point on a
66/// NAT'd or mobile link needs keepalive to notice a half-open connection at all, the same
67/// reasoning that makes `ReconnectBehavior` default to enabled. The lower-level
68/// `Client::from_transport*` constructors default to `Disabled`, since a caller assembling a
69/// client from raw transport halves has said nothing about wanting background traffic on it.
70///
71/// `Disabled` is not permanent: `Client::set_ping_interval` can turn pinging on later, which is
72/// what a CSMS writing `WebSocketPingInterval` via `SetVariables` needs to be able to do.
73#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
74pub enum KeepaliveBehavior {
75 Enabled(KeepalivePolicy),
76 #[default]
77 Disabled,
78}
79
80impl KeepaliveBehavior {
81 /// The starting interval, or `None` when keepalive is off. A `ZERO` interval collapses to
82 /// `None` so "disabled" has one representation inside the client.
83 pub(crate) fn initial_interval(&self) -> Option<Duration> {
84 match self {
85 KeepaliveBehavior::Enabled(policy) if !policy.interval.is_zero() => {
86 Some(policy.interval)
87 }
88 _ => None,
89 }
90 }
91
92 /// The policy to apply to pings, whether or not pinging starts out enabled - it still
93 /// governs pings that `Client::set_ping_interval` turns on later.
94 pub(crate) fn policy(&self) -> KeepalivePolicy {
95 match self {
96 KeepaliveBehavior::Enabled(policy) => *policy,
97 KeepaliveBehavior::Disabled => KeepalivePolicy::default(),
98 }
99 }
100}
101
102#[cfg(test)]
103mod tests {
104 use super::*;
105
106 #[test]
107 fn zero_interval_reads_as_disabled() {
108 let behavior = KeepaliveBehavior::Enabled(KeepalivePolicy::every(Duration::ZERO));
109 assert_eq!(behavior.initial_interval(), None);
110 }
111
112 #[test]
113 fn disabled_still_carries_a_policy_for_later_enabling() {
114 assert_eq!(
115 KeepaliveBehavior::Disabled.policy(),
116 KeepalivePolicy::default()
117 );
118 assert_eq!(KeepaliveBehavior::Disabled.initial_interval(), None);
119 }
120
121 #[test]
122 fn zero_misses_allowed_normalizes_to_one() {
123 let policy = KeepalivePolicy {
124 max_missed: 0,
125 ..KeepalivePolicy::default()
126 };
127 assert_eq!(policy.misses_allowed(), 1);
128 }
129
130 #[test]
131 fn default_tolerates_one_miss_before_redialling() {
132 assert_eq!(KeepalivePolicy::default().misses_allowed(), 2);
133 assert_eq!(KeepalivePolicy::default().interval, Duration::from_secs(60));
134 }
135}