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