Skip to main content

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}