Skip to main content

hive_btle/
reconnect.rs

1// Copyright (c) 2025-2026 (r)evolve - Revolve Team LLC
2// SPDX-License-Identifier: Apache-2.0
3//
4// Licensed under the Apache License, Version 2.0 (the "License");
5// you may not use this file except in compliance with the License.
6// You may obtain a copy of the License at
7//
8//     http://www.apache.org/licenses/LICENSE-2.0
9//
10// Unless required by applicable law or agreed to in writing, software
11// distributed under the License is distributed on an "AS IS" BASIS,
12// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13// See the License for the specific language governing permissions and
14// limitations under the License.
15
16//! Auto-reconnection manager with exponential backoff
17//!
18//! BLE connections can be lost due to range, interference, or device issues.
19//! This module provides automatic reconnection with configurable exponential
20//! backoff, matching the behavior of the Android implementation.
21//!
22//! # Example
23//!
24//! ```ignore
25//! use hive_btle::reconnect::{ReconnectionManager, ReconnectionConfig};
26//!
27//! let config = ReconnectionConfig::default();
28//! let mut manager = ReconnectionManager::new(config);
29//!
30//! // When a peer disconnects
31//! manager.track_disconnection(peer_address.clone());
32//!
33//! // Periodically check for peers to reconnect
34//! for peer in manager.get_peers_to_reconnect() {
35//!     if try_connect(&peer).is_ok() {
36//!         manager.on_connection_success(&peer);
37//!     }
38//! }
39//! ```
40
41use std::collections::HashMap;
42use std::time::{Duration, Instant};
43
44/// Configuration for reconnection behavior
45#[derive(Debug, Clone)]
46pub struct ReconnectionConfig {
47    /// Base delay between reconnection attempts (default: 2 seconds)
48    pub base_delay: Duration,
49    /// Maximum delay between attempts (default: 60 seconds)
50    pub max_delay: Duration,
51    /// Maximum number of reconnection attempts before giving up (default: 10)
52    pub max_attempts: u32,
53    /// Interval for checking which peers need reconnection (default: 5 seconds)
54    pub check_interval: Duration,
55    /// Use flat delay instead of exponential backoff (default: false)
56    /// When true, every attempt uses `base_delay` with no exponential increase.
57    pub use_flat_delay: bool,
58    /// Auto-reset attempt counter when max_attempts is exhausted (default: false)
59    /// When true, reaching max_attempts resets the counter instead of abandoning the peer.
60    pub reset_on_exhaustion: bool,
61}
62
63impl Default for ReconnectionConfig {
64    fn default() -> Self {
65        Self {
66            base_delay: Duration::from_secs(2),
67            max_delay: Duration::from_secs(60),
68            max_attempts: 10,
69            check_interval: Duration::from_secs(5),
70            use_flat_delay: false,
71            reset_on_exhaustion: false,
72        }
73    }
74}
75
76impl ReconnectionConfig {
77    /// Create a new configuration with custom values
78    pub fn new(
79        base_delay: Duration,
80        max_delay: Duration,
81        max_attempts: u32,
82        check_interval: Duration,
83    ) -> Self {
84        Self {
85            base_delay,
86            max_delay,
87            max_attempts,
88            check_interval,
89            use_flat_delay: false,
90            reset_on_exhaustion: false,
91        }
92    }
93
94    /// Create a fast reconnection config for testing
95    pub fn fast() -> Self {
96        Self {
97            base_delay: Duration::from_millis(500),
98            max_delay: Duration::from_secs(5),
99            max_attempts: 5,
100            check_interval: Duration::from_secs(1),
101            use_flat_delay: false,
102            reset_on_exhaustion: false,
103        }
104    }
105
106    /// Create a conservative config for battery-constrained devices
107    pub fn conservative() -> Self {
108        Self {
109            base_delay: Duration::from_secs(5),
110            max_delay: Duration::from_secs(120),
111            max_attempts: 5,
112            check_interval: Duration::from_secs(10),
113            use_flat_delay: false,
114            reset_on_exhaustion: false,
115        }
116    }
117
118    /// Kotlin Android normal mode: base=1s, max=15s, 20 attempts, exponential backoff
119    pub fn kotlin_normal() -> Self {
120        Self {
121            base_delay: Duration::from_millis(1000),
122            max_delay: Duration::from_millis(15000),
123            max_attempts: 20,
124            check_interval: Duration::from_secs(5),
125            use_flat_delay: false,
126            reset_on_exhaustion: false,
127        }
128    }
129
130    /// Kotlin Android high-priority mode: base=1s, flat delay, auto-reset on exhaustion
131    pub fn kotlin_high_priority() -> Self {
132        Self {
133            base_delay: Duration::from_millis(1000),
134            max_delay: Duration::from_millis(15000),
135            max_attempts: 20,
136            check_interval: Duration::from_secs(5),
137            use_flat_delay: true,
138            reset_on_exhaustion: true,
139        }
140    }
141}
142
143/// State for tracking a single peer's reconnection
144#[derive(Debug, Clone)]
145struct PeerReconnectionState {
146    /// Number of reconnection attempts made
147    attempts: u32,
148    /// When the last attempt was made
149    last_attempt: Instant,
150    /// When the peer was first marked for reconnection
151    disconnected_at: Instant,
152}
153
154impl PeerReconnectionState {
155    fn new() -> Self {
156        let now = Instant::now();
157        Self {
158            attempts: 0,
159            last_attempt: now,
160            disconnected_at: now,
161        }
162    }
163}
164
165/// Result of checking if a peer should be reconnected
166#[derive(Debug, Clone, PartialEq, Eq)]
167pub enum ReconnectionStatus {
168    /// Ready to attempt reconnection
169    Ready,
170    /// Waiting for backoff delay to expire
171    Waiting {
172        /// Time remaining until next attempt is allowed
173        remaining: Duration,
174    },
175    /// Maximum attempts exceeded, peer is abandoned
176    Exhausted {
177        /// Number of attempts that were made
178        attempts: u32,
179    },
180    /// Peer is not being tracked for reconnection
181    NotTracked,
182}
183
184/// Manager for auto-reconnection with exponential backoff
185///
186/// Tracks disconnected peers and determines when to attempt reconnection
187/// based on exponential backoff.
188#[derive(Debug)]
189pub struct ReconnectionManager {
190    /// Configuration
191    config: ReconnectionConfig,
192    /// Per-peer reconnection state
193    peers: HashMap<String, PeerReconnectionState>,
194}
195
196impl ReconnectionManager {
197    /// Create a new reconnection manager with the given configuration
198    pub fn new(config: ReconnectionConfig) -> Self {
199        Self {
200            config,
201            peers: HashMap::new(),
202        }
203    }
204
205    /// Create a manager with default configuration
206    pub fn with_defaults() -> Self {
207        Self::new(ReconnectionConfig::default())
208    }
209
210    /// Track a peer for reconnection after disconnection
211    ///
212    /// Call this when a peer disconnects unexpectedly.
213    pub fn track_disconnection(&mut self, address: String) {
214        use std::collections::hash_map::Entry;
215
216        if let Entry::Vacant(entry) = self.peers.entry(address.clone()) {
217            log::debug!("Tracking {} for reconnection", address);
218            entry.insert(PeerReconnectionState::new());
219        }
220    }
221
222    /// Check if a peer is being tracked for reconnection
223    pub fn is_tracked(&self, address: &str) -> bool {
224        self.peers.contains_key(address)
225    }
226
227    /// Get the reconnection status for a peer
228    pub fn get_status(&self, address: &str) -> ReconnectionStatus {
229        match self.peers.get(address) {
230            None => ReconnectionStatus::NotTracked,
231            Some(state) => {
232                if state.attempts >= self.config.max_attempts {
233                    if self.config.reset_on_exhaustion {
234                        // Will be reset on next get_peers_to_reconnect() call
235                        return ReconnectionStatus::Ready;
236                    }
237                    return ReconnectionStatus::Exhausted {
238                        attempts: state.attempts,
239                    };
240                }
241
242                // First attempt should be immediate (no delay)
243                if state.attempts == 0 {
244                    return ReconnectionStatus::Ready;
245                }
246
247                // Subsequent attempts use exponential backoff
248                let delay = self.calculate_delay(state.attempts);
249                let elapsed = state.last_attempt.elapsed();
250
251                if elapsed >= delay {
252                    ReconnectionStatus::Ready
253                } else {
254                    ReconnectionStatus::Waiting {
255                        remaining: delay - elapsed,
256                    }
257                }
258            }
259        }
260    }
261
262    /// Calculate the backoff delay for a given attempt number
263    ///
264    /// Uses exponential backoff: delay = min(base * 2^attempts, max)
265    /// In flat delay mode, always returns base_delay.
266    fn calculate_delay(&self, attempts: u32) -> Duration {
267        if self.config.use_flat_delay {
268            return self.config.base_delay;
269        }
270        let multiplier = 1u64 << attempts.min(30); // Prevent overflow
271        let delay_ms = self.config.base_delay.as_millis() as u64 * multiplier;
272        let max_ms = self.config.max_delay.as_millis() as u64;
273        Duration::from_millis(delay_ms.min(max_ms))
274    }
275
276    /// Get all peers that are ready for a reconnection attempt
277    ///
278    /// Returns addresses of peers that:
279    /// - Haven't exceeded max attempts (or will be auto-reset if `reset_on_exhaustion` is true)
280    /// - Have waited long enough since the last attempt (first attempt is immediate)
281    pub fn get_peers_to_reconnect(&mut self) -> Vec<String> {
282        // Auto-reset exhausted peers when configured
283        if self.config.reset_on_exhaustion {
284            let max = self.config.max_attempts;
285            for state in self.peers.values_mut() {
286                if state.attempts >= max {
287                    log::debug!("Auto-resetting exhausted peer (reset_on_exhaustion)");
288                    state.attempts = 0;
289                    state.last_attempt = Instant::now();
290                }
291            }
292        }
293
294        self.peers
295            .iter()
296            .filter_map(|(address, state)| {
297                if state.attempts >= self.config.max_attempts {
298                    return None;
299                }
300
301                // First attempt is immediate
302                if state.attempts == 0 {
303                    return Some(address.clone());
304                }
305
306                // Subsequent attempts use exponential backoff (or flat delay)
307                let delay = self.calculate_delay(state.attempts);
308                if state.last_attempt.elapsed() >= delay {
309                    Some(address.clone())
310                } else {
311                    None
312                }
313            })
314            .collect()
315    }
316
317    /// Record a reconnection attempt for a peer
318    ///
319    /// Call this when starting a reconnection attempt.
320    pub fn record_attempt(&mut self, address: &str) {
321        let attempts = if let Some(state) = self.peers.get_mut(address) {
322            state.attempts += 1;
323            state.last_attempt = Instant::now();
324            Some(state.attempts)
325        } else {
326            None
327        };
328
329        if let Some(attempts) = attempts {
330            let next_delay = self.calculate_delay(attempts);
331            log::debug!(
332                "Reconnection attempt {} for {} (next delay: {:?})",
333                attempts,
334                address,
335                next_delay
336            );
337        }
338    }
339
340    /// Called when a connection succeeds
341    ///
342    /// Removes the peer from reconnection tracking.
343    pub fn on_connection_success(&mut self, address: &str) {
344        if self.peers.remove(address).is_some() {
345            log::debug!(
346                "Connection succeeded for {}, removed from reconnection tracking",
347                address
348            );
349        }
350    }
351
352    /// Stop tracking a peer (e.g., peer was intentionally removed)
353    pub fn stop_tracking(&mut self, address: &str) {
354        if self.peers.remove(address).is_some() {
355            log::debug!("Stopped tracking {} for reconnection", address);
356        }
357    }
358
359    /// Clear all reconnection tracking
360    pub fn clear(&mut self) {
361        let count = self.peers.len();
362        self.peers.clear();
363        if count > 0 {
364            log::debug!("Cleared reconnection tracking for {} peers", count);
365        }
366    }
367
368    /// Get the number of peers being tracked
369    pub fn tracked_count(&self) -> usize {
370        self.peers.len()
371    }
372
373    /// Get statistics for a peer
374    pub fn get_peer_stats(&self, address: &str) -> Option<PeerReconnectionStats> {
375        self.peers.get(address).map(|state| PeerReconnectionStats {
376            attempts: state.attempts,
377            max_attempts: self.config.max_attempts,
378            disconnected_duration: state.disconnected_at.elapsed(),
379            next_attempt_delay: if state.attempts >= self.config.max_attempts {
380                Duration::MAX // Exhausted
381            } else if state.attempts == 0 {
382                Duration::ZERO // First attempt is immediate
383            } else {
384                self.calculate_delay(state.attempts)
385            },
386        })
387    }
388
389    /// Get the check interval from configuration
390    pub fn check_interval(&self) -> Duration {
391        self.config.check_interval
392    }
393}
394
395/// Statistics for a peer's reconnection state
396#[derive(Debug, Clone)]
397pub struct PeerReconnectionStats {
398    /// Number of attempts made
399    pub attempts: u32,
400    /// Maximum allowed attempts
401    pub max_attempts: u32,
402    /// How long since the peer disconnected
403    pub disconnected_duration: Duration,
404    /// Computed delay for the next reconnection attempt (ZERO if ready, MAX if exhausted).
405    /// This is the configured delay (flat or exponential), not the remaining wait time.
406    /// Use `get_status()` for remaining-time semantics.
407    pub next_attempt_delay: Duration,
408}
409
410#[cfg(test)]
411mod tests {
412    use super::*;
413
414    #[test]
415    fn test_exponential_backoff() {
416        let config = ReconnectionConfig {
417            base_delay: Duration::from_secs(2),
418            max_delay: Duration::from_secs(60),
419            max_attempts: 10,
420            check_interval: Duration::from_secs(5),
421            use_flat_delay: false,
422            reset_on_exhaustion: false,
423        };
424        let manager = ReconnectionManager::new(config);
425
426        // Check backoff delays
427        assert_eq!(manager.calculate_delay(0), Duration::from_secs(2));
428        assert_eq!(manager.calculate_delay(1), Duration::from_secs(4));
429        assert_eq!(manager.calculate_delay(2), Duration::from_secs(8));
430        assert_eq!(manager.calculate_delay(3), Duration::from_secs(16));
431        assert_eq!(manager.calculate_delay(4), Duration::from_secs(32));
432        assert_eq!(manager.calculate_delay(5), Duration::from_secs(60)); // Capped at max
433        assert_eq!(manager.calculate_delay(6), Duration::from_secs(60));
434    }
435
436    #[test]
437    fn test_track_and_status() {
438        let mut manager = ReconnectionManager::new(ReconnectionConfig::fast());
439
440        // Not tracked initially
441        assert_eq!(
442            manager.get_status("00:11:22:33:44:55"),
443            ReconnectionStatus::NotTracked
444        );
445
446        // Track a disconnection
447        manager.track_disconnection("00:11:22:33:44:55".to_string());
448        assert!(manager.is_tracked("00:11:22:33:44:55"));
449
450        // Should be ready immediately
451        assert_eq!(
452            manager.get_status("00:11:22:33:44:55"),
453            ReconnectionStatus::Ready
454        );
455    }
456
457    #[test]
458    fn test_connection_success_clears_tracking() {
459        let mut manager = ReconnectionManager::with_defaults();
460
461        manager.track_disconnection("00:11:22:33:44:55".to_string());
462        assert!(manager.is_tracked("00:11:22:33:44:55"));
463
464        manager.on_connection_success("00:11:22:33:44:55");
465        assert!(!manager.is_tracked("00:11:22:33:44:55"));
466        assert_eq!(
467            manager.get_status("00:11:22:33:44:55"),
468            ReconnectionStatus::NotTracked
469        );
470        assert_eq!(manager.tracked_count(), 0);
471    }
472
473    #[test]
474    fn test_max_attempts_exhaustion() {
475        let config = ReconnectionConfig {
476            base_delay: Duration::from_millis(1),
477            max_delay: Duration::from_millis(10),
478            max_attempts: 3,
479            check_interval: Duration::from_millis(1),
480            use_flat_delay: false,
481            reset_on_exhaustion: false,
482        };
483        let mut manager = ReconnectionManager::new(config);
484
485        manager.track_disconnection("test".to_string());
486
487        // Record 3 attempts
488        for _ in 0..3 {
489            manager.record_attempt("test");
490        }
491
492        // Should be exhausted
493        assert_eq!(
494            manager.get_status("test"),
495            ReconnectionStatus::Exhausted { attempts: 3 }
496        );
497    }
498
499    // === Kotlin parity tests ===
500
501    #[test]
502    fn test_kotlin_normal_config_backoff() {
503        // Verify delay sequence matches Kotlin normal mode:
504        // base=1000ms, max=15000ms, 20 attempts, exponential backoff
505        let config = ReconnectionConfig::kotlin_normal();
506        assert_eq!(config.base_delay, Duration::from_millis(1000));
507        assert_eq!(config.max_delay, Duration::from_millis(15000));
508        assert_eq!(config.max_attempts, 20);
509        assert!(!config.use_flat_delay);
510        assert!(!config.reset_on_exhaustion);
511
512        let manager = ReconnectionManager::new(config);
513
514        // Kotlin backoff: min(base * 2^attempts, max)
515        // attempt 0: 1000 * 1 = 1000ms
516        assert_eq!(manager.calculate_delay(0), Duration::from_millis(1000));
517        // attempt 1: 1000 * 2 = 2000ms
518        assert_eq!(manager.calculate_delay(1), Duration::from_millis(2000));
519        // attempt 2: 1000 * 4 = 4000ms
520        assert_eq!(manager.calculate_delay(2), Duration::from_millis(4000));
521        // attempt 3: 1000 * 8 = 8000ms
522        assert_eq!(manager.calculate_delay(3), Duration::from_millis(8000));
523        // attempt 4: 1000 * 16 = 15000ms (capped at max)
524        assert_eq!(manager.calculate_delay(4), Duration::from_millis(15000));
525        // attempt 5: still capped
526        assert_eq!(manager.calculate_delay(5), Duration::from_millis(15000));
527    }
528
529    #[test]
530    fn test_flat_delay_mode() {
531        // High-priority mode: flat delay (no exponential backoff)
532        let config = ReconnectionConfig::kotlin_high_priority();
533        assert!(config.use_flat_delay);
534
535        let manager = ReconnectionManager::new(config);
536
537        // Every attempt should use base_delay = 1000ms
538        assert_eq!(manager.calculate_delay(0), Duration::from_millis(1000));
539        assert_eq!(manager.calculate_delay(1), Duration::from_millis(1000));
540        assert_eq!(manager.calculate_delay(5), Duration::from_millis(1000));
541        assert_eq!(manager.calculate_delay(19), Duration::from_millis(1000));
542    }
543
544    #[test]
545    fn test_reset_on_exhaustion() {
546        let config = ReconnectionConfig {
547            base_delay: Duration::from_millis(1),
548            max_delay: Duration::from_millis(10),
549            max_attempts: 3,
550            check_interval: Duration::from_millis(1),
551            use_flat_delay: true,
552            reset_on_exhaustion: true,
553        };
554        let mut manager = ReconnectionManager::new(config);
555
556        manager.track_disconnection("test".to_string());
557
558        // Exhaust all 3 attempts
559        for _ in 0..3 {
560            manager.record_attempt("test");
561        }
562
563        // With reset_on_exhaustion, status should show Ready (not Exhausted)
564        assert_eq!(manager.get_status("test"), ReconnectionStatus::Ready);
565
566        // get_peers_to_reconnect should reset and return the peer
567        std::thread::sleep(Duration::from_millis(5));
568        let peers = manager.get_peers_to_reconnect();
569        assert!(peers.contains(&"test".to_string()));
570
571        // After reset, attempts should be back to 0
572        let stats = manager.get_peer_stats("test").unwrap();
573        assert_eq!(stats.attempts, 0);
574    }
575
576    #[test]
577    fn test_stop_tracking_matches_reset() {
578        // Verify stop_tracking() has same effect as on_connection_success():
579        // complete removal from tracking
580        let mut manager = ReconnectionManager::with_defaults();
581
582        manager.track_disconnection("peer1".to_string());
583        manager.track_disconnection("peer2".to_string());
584        assert_eq!(manager.tracked_count(), 2);
585
586        manager.stop_tracking("peer1");
587        assert!(!manager.is_tracked("peer1"));
588        assert_eq!(manager.get_status("peer1"), ReconnectionStatus::NotTracked);
589        assert_eq!(manager.tracked_count(), 1);
590
591        // on_connection_success has same effect
592        manager.on_connection_success("peer2");
593        assert!(!manager.is_tracked("peer2"));
594        assert_eq!(manager.get_status("peer2"), ReconnectionStatus::NotTracked);
595        assert_eq!(manager.tracked_count(), 0);
596    }
597}