hive_btle/phy/
strategy.rs

1//! PHY Selection Strategy
2//!
3//! Defines strategies for automatic PHY selection based on signal quality,
4//! distance estimation, and application requirements.
5
6use super::types::{BlePhy, PhyCapabilities};
7
8/// Strategy for automatic PHY selection
9#[derive(Debug, Clone, PartialEq)]
10pub enum PhyStrategy {
11    /// Use a fixed PHY regardless of conditions
12    Fixed(BlePhy),
13
14    /// Adaptively select PHY based on RSSI
15    Adaptive {
16        /// Switch to LE 2M above this RSSI (stronger signal)
17        rssi_threshold_high: i8,
18        /// Switch to Coded PHY below this RSSI (weaker signal)
19        rssi_threshold_low: i8,
20        /// RSSI difference required to trigger switch (prevent oscillation)
21        hysteresis_db: u8,
22        /// Preferred coded PHY when switching to long range
23        coded_phy: BlePhy,
24    },
25
26    /// Always use maximum range PHY
27    MaxRange,
28
29    /// Always use maximum throughput PHY
30    MaxThroughput,
31
32    /// Power-optimized: prefer faster PHYs when signal is strong
33    PowerOptimized {
34        /// Switch to 2M above this RSSI
35        rssi_threshold: i8,
36    },
37}
38
39impl Default for PhyStrategy {
40    fn default() -> Self {
41        PhyStrategy::Adaptive {
42            rssi_threshold_high: -50,
43            rssi_threshold_low: -75,
44            hysteresis_db: 5,
45            coded_phy: BlePhy::LeCodedS2,
46        }
47    }
48}
49
50impl PhyStrategy {
51    /// Create a fixed strategy using specified PHY
52    pub fn fixed(phy: BlePhy) -> Self {
53        PhyStrategy::Fixed(phy)
54    }
55
56    /// Create adaptive strategy with custom thresholds
57    pub fn adaptive(high_threshold: i8, low_threshold: i8, hysteresis: u8) -> Self {
58        PhyStrategy::Adaptive {
59            rssi_threshold_high: high_threshold,
60            rssi_threshold_low: low_threshold,
61            hysteresis_db: hysteresis,
62            coded_phy: BlePhy::LeCodedS2,
63        }
64    }
65
66    /// Create adaptive strategy for maximum range fallback
67    pub fn adaptive_max_range() -> Self {
68        PhyStrategy::Adaptive {
69            rssi_threshold_high: -50,
70            rssi_threshold_low: -70,
71            hysteresis_db: 5,
72            coded_phy: BlePhy::LeCodedS8,
73        }
74    }
75
76    /// Select appropriate PHY based on current conditions
77    pub fn select_phy(
78        &self,
79        current_phy: BlePhy,
80        rssi: i8,
81        capabilities: &PhyCapabilities,
82    ) -> BlePhy {
83        let selected = match self {
84            PhyStrategy::Fixed(phy) => *phy,
85            PhyStrategy::Adaptive {
86                rssi_threshold_high,
87                rssi_threshold_low,
88                hysteresis_db,
89                coded_phy,
90            } => {
91                // Apply hysteresis based on current PHY
92                let (high_thresh, low_thresh) = if current_phy == BlePhy::Le2M {
93                    // Currently on 2M, need stronger signal to stay
94                    (
95                        *rssi_threshold_high - *hysteresis_db as i8,
96                        *rssi_threshold_low,
97                    )
98                } else if current_phy.is_coded() {
99                    // Currently on coded, need weaker signal to stay
100                    (
101                        *rssi_threshold_high,
102                        *rssi_threshold_low + *hysteresis_db as i8,
103                    )
104                } else {
105                    (*rssi_threshold_high, *rssi_threshold_low)
106                };
107
108                if rssi > high_thresh {
109                    BlePhy::Le2M
110                } else if rssi < low_thresh {
111                    *coded_phy
112                } else {
113                    BlePhy::Le1M
114                }
115            }
116            PhyStrategy::MaxRange => {
117                if capabilities.le_coded {
118                    BlePhy::LeCodedS8
119                } else {
120                    BlePhy::Le1M
121                }
122            }
123            PhyStrategy::MaxThroughput => {
124                if capabilities.le_2m {
125                    BlePhy::Le2M
126                } else {
127                    BlePhy::Le1M
128                }
129            }
130            PhyStrategy::PowerOptimized { rssi_threshold } => {
131                if rssi > *rssi_threshold && capabilities.le_2m {
132                    BlePhy::Le2M // Faster = shorter airtime = less power
133                } else {
134                    BlePhy::Le1M
135                }
136            }
137        };
138
139        // Validate against capabilities
140        if capabilities.supports(selected) {
141            selected
142        } else {
143            BlePhy::Le1M // Fallback to always-supported PHY
144        }
145    }
146
147    /// Get strategy name
148    pub fn name(&self) -> &'static str {
149        match self {
150            PhyStrategy::Fixed(_) => "fixed",
151            PhyStrategy::Adaptive { .. } => "adaptive",
152            PhyStrategy::MaxRange => "max_range",
153            PhyStrategy::MaxThroughput => "max_throughput",
154            PhyStrategy::PowerOptimized { .. } => "power_optimized",
155        }
156    }
157
158    /// Check if strategy requires capability negotiation
159    pub fn requires_capability_check(&self) -> bool {
160        !matches!(self, PhyStrategy::Fixed(BlePhy::Le1M))
161    }
162}
163
164/// PHY switching decision
165#[derive(Debug, Clone, Copy, PartialEq, Eq)]
166pub enum PhySwitchDecision {
167    /// Keep current PHY
168    Keep,
169    /// Switch to new PHY
170    Switch(BlePhy),
171}
172
173impl PhySwitchDecision {
174    /// Check if a switch is recommended
175    pub fn should_switch(&self) -> bool {
176        matches!(self, PhySwitchDecision::Switch(_))
177    }
178
179    /// Get the target PHY if switching
180    pub fn target(&self) -> Option<BlePhy> {
181        match self {
182            PhySwitchDecision::Keep => None,
183            PhySwitchDecision::Switch(phy) => Some(*phy),
184        }
185    }
186}
187
188/// Evaluate whether to switch PHY based on strategy
189pub fn evaluate_phy_switch(
190    strategy: &PhyStrategy,
191    current_phy: BlePhy,
192    rssi: i8,
193    capabilities: &PhyCapabilities,
194) -> PhySwitchDecision {
195    let recommended = strategy.select_phy(current_phy, rssi, capabilities);
196    if recommended != current_phy {
197        PhySwitchDecision::Switch(recommended)
198    } else {
199        PhySwitchDecision::Keep
200    }
201}
202
203#[cfg(test)]
204mod tests {
205    use super::*;
206
207    #[test]
208    fn test_strategy_default() {
209        let strategy = PhyStrategy::default();
210        assert_eq!(strategy.name(), "adaptive");
211    }
212
213    #[test]
214    fn test_fixed_strategy() {
215        let strategy = PhyStrategy::fixed(BlePhy::LeCodedS8);
216        let caps = PhyCapabilities::ble5_full();
217
218        // Always returns the fixed PHY regardless of RSSI
219        assert_eq!(
220            strategy.select_phy(BlePhy::Le1M, -30, &caps),
221            BlePhy::LeCodedS8
222        );
223        assert_eq!(
224            strategy.select_phy(BlePhy::Le1M, -90, &caps),
225            BlePhy::LeCodedS8
226        );
227    }
228
229    #[test]
230    fn test_fixed_strategy_capability_fallback() {
231        let strategy = PhyStrategy::fixed(BlePhy::LeCodedS8);
232        let caps = PhyCapabilities::le_1m_only();
233
234        // Falls back to LE 1M if coded not supported
235        assert_eq!(strategy.select_phy(BlePhy::Le1M, -50, &caps), BlePhy::Le1M);
236    }
237
238    #[test]
239    fn test_adaptive_strong_signal() {
240        let strategy = PhyStrategy::default();
241        let caps = PhyCapabilities::ble5_full();
242
243        // Strong signal (-40 dBm) should use 2M
244        assert_eq!(strategy.select_phy(BlePhy::Le1M, -40, &caps), BlePhy::Le2M);
245    }
246
247    #[test]
248    fn test_adaptive_medium_signal() {
249        let strategy = PhyStrategy::default();
250        let caps = PhyCapabilities::ble5_full();
251
252        // Medium signal (-60 dBm) should use 1M
253        assert_eq!(strategy.select_phy(BlePhy::Le1M, -60, &caps), BlePhy::Le1M);
254    }
255
256    #[test]
257    fn test_adaptive_weak_signal() {
258        let strategy = PhyStrategy::default();
259        let caps = PhyCapabilities::ble5_full();
260
261        // Weak signal (-80 dBm) should use Coded
262        assert!(strategy.select_phy(BlePhy::Le1M, -80, &caps).is_coded());
263    }
264
265    #[test]
266    fn test_adaptive_hysteresis() {
267        let strategy = PhyStrategy::Adaptive {
268            rssi_threshold_high: -50,
269            rssi_threshold_low: -75,
270            hysteresis_db: 5,
271            coded_phy: BlePhy::LeCodedS2,
272        };
273        let caps = PhyCapabilities::ble5_full();
274
275        // Hysteresis prevents oscillation:
276        // - From 1M: threshold is -50, so -48 > -50 → switch to 2M
277        // - From 2M: threshold is -55 (with hysteresis), so -48 > -55 → stay on 2M
278        let from_1m = strategy.select_phy(BlePhy::Le1M, -48, &caps);
279        let from_2m = strategy.select_phy(BlePhy::Le2M, -48, &caps);
280
281        assert_eq!(from_1m, BlePhy::Le2M);
282        assert_eq!(from_2m, BlePhy::Le2M); // Hysteresis keeps it on 2M
283
284        // At -52 (below threshold -50 but above hysteresis -55):
285        // - From 1M: threshold is -50, so -52 < -50 → stay on 1M
286        // - From 2M: threshold is -55, so -52 > -55 → stay on 2M
287        let at_52_from_1m = strategy.select_phy(BlePhy::Le1M, -52, &caps);
288        let at_52_from_2m = strategy.select_phy(BlePhy::Le2M, -52, &caps);
289
290        assert_eq!(at_52_from_1m, BlePhy::Le1M);
291        assert_eq!(at_52_from_2m, BlePhy::Le2M);
292    }
293
294    #[test]
295    fn test_max_range() {
296        let strategy = PhyStrategy::MaxRange;
297        let caps = PhyCapabilities::ble5_full();
298
299        assert_eq!(
300            strategy.select_phy(BlePhy::Le1M, -30, &caps),
301            BlePhy::LeCodedS8
302        );
303    }
304
305    #[test]
306    fn test_max_range_no_coded() {
307        let strategy = PhyStrategy::MaxRange;
308        let caps = PhyCapabilities::ble5_no_coded();
309
310        assert_eq!(strategy.select_phy(BlePhy::Le1M, -30, &caps), BlePhy::Le1M);
311    }
312
313    #[test]
314    fn test_max_throughput() {
315        let strategy = PhyStrategy::MaxThroughput;
316        let caps = PhyCapabilities::ble5_full();
317
318        assert_eq!(strategy.select_phy(BlePhy::Le1M, -80, &caps), BlePhy::Le2M);
319    }
320
321    #[test]
322    fn test_power_optimized_strong() {
323        let strategy = PhyStrategy::PowerOptimized {
324            rssi_threshold: -55,
325        };
326        let caps = PhyCapabilities::ble5_full();
327
328        // Strong signal uses 2M for power savings
329        assert_eq!(strategy.select_phy(BlePhy::Le1M, -40, &caps), BlePhy::Le2M);
330    }
331
332    #[test]
333    fn test_power_optimized_weak() {
334        let strategy = PhyStrategy::PowerOptimized {
335            rssi_threshold: -55,
336        };
337        let caps = PhyCapabilities::ble5_full();
338
339        // Weak signal uses 1M (more reliable)
340        assert_eq!(strategy.select_phy(BlePhy::Le1M, -70, &caps), BlePhy::Le1M);
341    }
342
343    #[test]
344    fn test_switch_decision_keep() {
345        let strategy = PhyStrategy::fixed(BlePhy::Le1M);
346        let caps = PhyCapabilities::ble5_full();
347
348        let decision = evaluate_phy_switch(&strategy, BlePhy::Le1M, -50, &caps);
349        assert_eq!(decision, PhySwitchDecision::Keep);
350        assert!(!decision.should_switch());
351        assert!(decision.target().is_none());
352    }
353
354    #[test]
355    fn test_switch_decision_switch() {
356        let strategy = PhyStrategy::MaxThroughput;
357        let caps = PhyCapabilities::ble5_full();
358
359        let decision = evaluate_phy_switch(&strategy, BlePhy::Le1M, -50, &caps);
360        assert_eq!(decision, PhySwitchDecision::Switch(BlePhy::Le2M));
361        assert!(decision.should_switch());
362        assert_eq!(decision.target(), Some(BlePhy::Le2M));
363    }
364
365    #[test]
366    fn test_strategy_names() {
367        assert_eq!(PhyStrategy::fixed(BlePhy::Le1M).name(), "fixed");
368        assert_eq!(PhyStrategy::MaxRange.name(), "max_range");
369        assert_eq!(PhyStrategy::MaxThroughput.name(), "max_throughput");
370    }
371
372    #[test]
373    fn test_requires_capability_check() {
374        assert!(!PhyStrategy::fixed(BlePhy::Le1M).requires_capability_check());
375        assert!(PhyStrategy::fixed(BlePhy::Le2M).requires_capability_check());
376        assert!(PhyStrategy::MaxRange.requires_capability_check());
377    }
378}