Skip to main content

hive_btle/phy/
controller.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 Controller
17//!
18//! Manages PHY selection and switching for BLE connections,
19//! handling the state machine and platform-specific operations.
20
21#[cfg(not(feature = "std"))]
22use alloc::vec::Vec;
23
24use super::strategy::{evaluate_phy_switch, PhyStrategy, PhySwitchDecision};
25use super::types::{BlePhy, PhyCapabilities, PhyPreference};
26
27/// PHY controller state
28#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
29pub enum PhyControllerState {
30    /// Not initialized or no connection
31    #[default]
32    Idle,
33    /// Negotiating PHY capabilities
34    Negotiating,
35    /// Operating with current PHY
36    Active,
37    /// Switching to a new PHY
38    Switching,
39    /// Error state
40    Error,
41}
42
43/// PHY update result
44#[derive(Debug, Clone, Copy, PartialEq, Eq)]
45pub enum PhyUpdateResult {
46    /// PHY update succeeded
47    Success {
48        /// New TX PHY
49        tx_phy: BlePhy,
50        /// New RX PHY
51        rx_phy: BlePhy,
52    },
53    /// PHY update rejected by peer
54    Rejected,
55    /// PHY update not supported
56    NotSupported,
57    /// PHY update timed out
58    Timeout,
59    /// PHY update failed
60    Failed,
61}
62
63/// Event from the PHY controller
64#[derive(Debug, Clone, Copy, PartialEq, Eq)]
65pub enum PhyControllerEvent {
66    /// PHY negotiation complete
67    NegotiationComplete {
68        /// Local capabilities
69        local: PhyCapabilities,
70        /// Peer capabilities
71        peer: PhyCapabilities,
72    },
73    /// PHY switch recommended
74    SwitchRecommended {
75        /// Current PHY
76        from: BlePhy,
77        /// Recommended PHY
78        to: BlePhy,
79        /// Current RSSI that triggered recommendation
80        rssi: i8,
81    },
82    /// PHY update completed
83    UpdateComplete(PhyUpdateResult),
84    /// RSSI measurement received
85    RssiUpdate(i8),
86}
87
88/// PHY controller statistics
89#[derive(Debug, Clone, Default)]
90pub struct PhyStats {
91    /// Number of PHY switches
92    pub switches: u64,
93    /// Successful switches
94    pub successful_switches: u64,
95    /// Failed switches
96    pub failed_switches: u64,
97    /// RSSI samples collected
98    pub rssi_samples: u64,
99    /// Time spent in each PHY (arbitrary units)
100    pub time_in_le1m: u64,
101    /// Time in LE 2M
102    pub time_in_le2m: u64,
103    /// Time in LE Coded
104    pub time_in_coded: u64,
105}
106
107impl PhyStats {
108    /// Get switch success rate
109    pub fn success_rate(&self) -> f32 {
110        if self.switches == 0 {
111            1.0
112        } else {
113            self.successful_switches as f32 / self.switches as f32
114        }
115    }
116
117    /// Record time in current PHY
118    pub fn record_time(&mut self, phy: BlePhy, time_units: u64) {
119        match phy {
120            BlePhy::Le1M => self.time_in_le1m += time_units,
121            BlePhy::Le2M => self.time_in_le2m += time_units,
122            BlePhy::LeCodedS2 | BlePhy::LeCodedS8 => self.time_in_coded += time_units,
123        }
124    }
125}
126
127/// PHY controller configuration
128#[derive(Debug, Clone)]
129pub struct PhyControllerConfig {
130    /// PHY selection strategy
131    pub strategy: PhyStrategy,
132    /// Minimum RSSI samples before considering switch
133    pub min_samples_for_switch: usize,
134    /// RSSI averaging window size
135    pub rssi_window_size: usize,
136    /// Minimum time between switches (milliseconds)
137    pub switch_cooldown_ms: u64,
138    /// Enable automatic PHY switching
139    pub auto_switch: bool,
140}
141
142impl Default for PhyControllerConfig {
143    fn default() -> Self {
144        Self {
145            strategy: PhyStrategy::default(),
146            min_samples_for_switch: 5,
147            rssi_window_size: 10,
148            switch_cooldown_ms: 5000,
149            auto_switch: true,
150        }
151    }
152}
153
154/// PHY Controller
155///
156/// Manages PHY selection, switching, and monitoring for a BLE connection.
157#[derive(Debug)]
158pub struct PhyController {
159    /// Configuration
160    config: PhyControllerConfig,
161    /// Current state
162    state: PhyControllerState,
163    /// Current TX PHY
164    tx_phy: BlePhy,
165    /// Current RX PHY
166    rx_phy: BlePhy,
167    /// Local PHY capabilities
168    local_caps: PhyCapabilities,
169    /// Peer PHY capabilities
170    peer_caps: PhyCapabilities,
171    /// RSSI samples
172    rssi_samples: Vec<i8>,
173    /// Last switch time (ms timestamp)
174    last_switch_time: u64,
175    /// Statistics
176    stats: PhyStats,
177}
178
179impl PhyController {
180    /// Create a new PHY controller
181    pub fn new(config: PhyControllerConfig, local_caps: PhyCapabilities) -> Self {
182        Self {
183            config,
184            state: PhyControllerState::Idle,
185            tx_phy: BlePhy::Le1M,
186            rx_phy: BlePhy::Le1M,
187            local_caps,
188            peer_caps: PhyCapabilities::default(),
189            rssi_samples: Vec::new(),
190            last_switch_time: 0,
191            stats: PhyStats::default(),
192        }
193    }
194
195    /// Create with default config
196    pub fn with_defaults(local_caps: PhyCapabilities) -> Self {
197        Self::new(PhyControllerConfig::default(), local_caps)
198    }
199
200    /// Get current state
201    pub fn state(&self) -> PhyControllerState {
202        self.state
203    }
204
205    /// Get current TX PHY
206    pub fn tx_phy(&self) -> BlePhy {
207        self.tx_phy
208    }
209
210    /// Get current RX PHY
211    pub fn rx_phy(&self) -> BlePhy {
212        self.rx_phy
213    }
214
215    /// Get current PHY preference
216    pub fn current_preference(&self) -> PhyPreference {
217        PhyPreference {
218            tx: self.tx_phy,
219            rx: self.rx_phy,
220        }
221    }
222
223    /// Get effective capabilities (intersection of local and peer)
224    pub fn effective_capabilities(&self) -> PhyCapabilities {
225        PhyCapabilities {
226            le_2m: self.local_caps.le_2m && self.peer_caps.le_2m,
227            le_coded: self.local_caps.le_coded && self.peer_caps.le_coded,
228        }
229    }
230
231    /// Get statistics
232    pub fn stats(&self) -> &PhyStats {
233        &self.stats
234    }
235
236    /// Get config
237    pub fn config(&self) -> &PhyControllerConfig {
238        &self.config
239    }
240
241    /// Start PHY negotiation for a new connection
242    pub fn start_negotiation(&mut self) {
243        self.state = PhyControllerState::Negotiating;
244        self.rssi_samples.clear();
245    }
246
247    /// Complete negotiation with peer capabilities
248    pub fn complete_negotiation(&mut self, peer_caps: PhyCapabilities) -> PhyControllerEvent {
249        self.peer_caps = peer_caps;
250        self.state = PhyControllerState::Active;
251
252        PhyControllerEvent::NegotiationComplete {
253            local: self.local_caps,
254            peer: peer_caps,
255        }
256    }
257
258    /// Record an RSSI measurement
259    pub fn record_rssi(&mut self, rssi: i8, current_time: u64) -> Option<PhyControllerEvent> {
260        self.rssi_samples.push(rssi);
261        self.stats.rssi_samples += 1;
262
263        // Keep only recent samples
264        if self.rssi_samples.len() > self.config.rssi_window_size {
265            self.rssi_samples.remove(0);
266        }
267
268        // Check for PHY switch if enabled and have enough samples
269        if self.config.auto_switch
270            && self.state == PhyControllerState::Active
271            && self.rssi_samples.len() >= self.config.min_samples_for_switch
272            && current_time >= self.last_switch_time + self.config.switch_cooldown_ms
273        {
274            let avg_rssi = self.average_rssi();
275            let decision = self.evaluate_switch(avg_rssi);
276
277            if let PhySwitchDecision::Switch(to_phy) = decision {
278                return Some(PhyControllerEvent::SwitchRecommended {
279                    from: self.tx_phy,
280                    to: to_phy,
281                    rssi: avg_rssi,
282                });
283            }
284        }
285
286        None
287    }
288
289    /// Get average RSSI from samples
290    pub fn average_rssi(&self) -> i8 {
291        if self.rssi_samples.is_empty() {
292            return -100;
293        }
294        let sum: i32 = self.rssi_samples.iter().map(|&r| r as i32).sum();
295        (sum / self.rssi_samples.len() as i32) as i8
296    }
297
298    /// Evaluate whether to switch PHY
299    pub fn evaluate_switch(&self, rssi: i8) -> PhySwitchDecision {
300        let effective_caps = self.effective_capabilities();
301        evaluate_phy_switch(&self.config.strategy, self.tx_phy, rssi, &effective_caps)
302    }
303
304    /// Request a PHY update
305    pub fn request_switch(&mut self, to_phy: BlePhy) -> Option<PhyPreference> {
306        if self.state != PhyControllerState::Active {
307            return None;
308        }
309
310        let effective_caps = self.effective_capabilities();
311        if !effective_caps.supports(to_phy) {
312            return None;
313        }
314
315        self.state = PhyControllerState::Switching;
316        self.stats.switches += 1;
317
318        Some(PhyPreference::symmetric(to_phy))
319    }
320
321    /// Handle PHY update result from stack
322    pub fn handle_update_result(
323        &mut self,
324        result: PhyUpdateResult,
325        current_time: u64,
326    ) -> PhyControllerEvent {
327        match result {
328            PhyUpdateResult::Success { tx_phy, rx_phy } => {
329                self.tx_phy = tx_phy;
330                self.rx_phy = rx_phy;
331                self.last_switch_time = current_time;
332                self.state = PhyControllerState::Active;
333                self.stats.successful_switches += 1;
334            }
335            PhyUpdateResult::Rejected
336            | PhyUpdateResult::NotSupported
337            | PhyUpdateResult::Timeout
338            | PhyUpdateResult::Failed => {
339                self.state = PhyControllerState::Active;
340                self.stats.failed_switches += 1;
341            }
342        }
343
344        PhyControllerEvent::UpdateComplete(result)
345    }
346
347    /// Reset controller state
348    pub fn reset(&mut self) {
349        self.state = PhyControllerState::Idle;
350        self.tx_phy = BlePhy::Le1M;
351        self.rx_phy = BlePhy::Le1M;
352        self.peer_caps = PhyCapabilities::default();
353        self.rssi_samples.clear();
354        self.last_switch_time = 0;
355    }
356
357    /// Set PHY strategy
358    pub fn set_strategy(&mut self, strategy: PhyStrategy) {
359        self.config.strategy = strategy;
360    }
361
362    /// Enable/disable auto switching
363    pub fn set_auto_switch(&mut self, enabled: bool) {
364        self.config.auto_switch = enabled;
365    }
366}
367
368#[cfg(test)]
369mod tests {
370    use super::*;
371
372    fn make_controller() -> PhyController {
373        let caps = PhyCapabilities::ble5_full();
374        PhyController::with_defaults(caps)
375    }
376
377    #[test]
378    fn test_controller_creation() {
379        let ctrl = make_controller();
380        assert_eq!(ctrl.state(), PhyControllerState::Idle);
381        assert_eq!(ctrl.tx_phy(), BlePhy::Le1M);
382        assert_eq!(ctrl.rx_phy(), BlePhy::Le1M);
383    }
384
385    #[test]
386    fn test_negotiation_flow() {
387        let mut ctrl = make_controller();
388
389        ctrl.start_negotiation();
390        assert_eq!(ctrl.state(), PhyControllerState::Negotiating);
391
392        let event = ctrl.complete_negotiation(PhyCapabilities::ble5_full());
393        assert_eq!(ctrl.state(), PhyControllerState::Active);
394
395        if let PhyControllerEvent::NegotiationComplete { local, peer } = event {
396            assert!(local.le_2m);
397            assert!(peer.le_coded);
398        } else {
399            panic!("Expected NegotiationComplete event");
400        }
401    }
402
403    #[test]
404    fn test_effective_capabilities() {
405        let mut ctrl = make_controller();
406        ctrl.complete_negotiation(PhyCapabilities::ble5_no_coded());
407
408        let effective = ctrl.effective_capabilities();
409        assert!(effective.le_2m);
410        assert!(!effective.le_coded); // Peer doesn't support
411    }
412
413    #[test]
414    fn test_rssi_recording() {
415        let mut ctrl = make_controller();
416        ctrl.complete_negotiation(PhyCapabilities::ble5_full());
417
418        for i in 0..5 {
419            ctrl.record_rssi(-50 - i, 1000 + i as u64 * 100);
420        }
421
422        let avg = ctrl.average_rssi();
423        assert!((-55..=-50).contains(&avg));
424    }
425
426    #[test]
427    fn test_rssi_window_limit() {
428        let mut ctrl = make_controller();
429        ctrl.complete_negotiation(PhyCapabilities::ble5_full());
430
431        // Add more samples than window size
432        for i in 0..20 {
433            ctrl.record_rssi(-50, i * 100);
434        }
435
436        assert_eq!(ctrl.rssi_samples.len(), ctrl.config.rssi_window_size);
437    }
438
439    #[test]
440    fn test_switch_request() {
441        let mut ctrl = make_controller();
442        ctrl.complete_negotiation(PhyCapabilities::ble5_full());
443
444        let pref = ctrl.request_switch(BlePhy::Le2M);
445        assert!(pref.is_some());
446        assert_eq!(ctrl.state(), PhyControllerState::Switching);
447    }
448
449    #[test]
450    fn test_switch_request_unsupported() {
451        let mut ctrl = make_controller();
452        ctrl.complete_negotiation(PhyCapabilities::le_1m_only());
453
454        let pref = ctrl.request_switch(BlePhy::LeCodedS8);
455        assert!(pref.is_none()); // Peer doesn't support
456    }
457
458    #[test]
459    fn test_update_result_success() {
460        let mut ctrl = make_controller();
461        ctrl.complete_negotiation(PhyCapabilities::ble5_full());
462        ctrl.request_switch(BlePhy::Le2M);
463
464        let result = PhyUpdateResult::Success {
465            tx_phy: BlePhy::Le2M,
466            rx_phy: BlePhy::Le2M,
467        };
468        ctrl.handle_update_result(result, 5000);
469
470        assert_eq!(ctrl.state(), PhyControllerState::Active);
471        assert_eq!(ctrl.tx_phy(), BlePhy::Le2M);
472        assert_eq!(ctrl.rx_phy(), BlePhy::Le2M);
473        assert_eq!(ctrl.stats().successful_switches, 1);
474    }
475
476    #[test]
477    fn test_update_result_rejected() {
478        let mut ctrl = make_controller();
479        ctrl.complete_negotiation(PhyCapabilities::ble5_full());
480        ctrl.request_switch(BlePhy::Le2M);
481
482        ctrl.handle_update_result(PhyUpdateResult::Rejected, 5000);
483
484        assert_eq!(ctrl.state(), PhyControllerState::Active);
485        assert_eq!(ctrl.tx_phy(), BlePhy::Le1M); // Unchanged
486        assert_eq!(ctrl.stats().failed_switches, 1);
487    }
488
489    #[test]
490    fn test_auto_switch_recommendation() {
491        let config = PhyControllerConfig {
492            min_samples_for_switch: 3,
493            switch_cooldown_ms: 0, // No cooldown for test
494            ..Default::default()
495        };
496        let caps = PhyCapabilities::ble5_full();
497        let mut ctrl = PhyController::new(config, caps);
498        ctrl.complete_negotiation(PhyCapabilities::ble5_full());
499
500        // Record strong RSSI samples
501        for i in 0..5 {
502            let event = ctrl.record_rssi(-40, i * 100);
503            if i >= 2 {
504                // After min_samples_for_switch
505                if let Some(PhyControllerEvent::SwitchRecommended { to, .. }) = event {
506                    assert_eq!(to, BlePhy::Le2M);
507                    return; // Test passed
508                }
509            }
510        }
511
512        panic!("Expected switch recommendation for strong signal");
513    }
514
515    #[test]
516    fn test_switch_cooldown() {
517        let config = PhyControllerConfig {
518            min_samples_for_switch: 2,
519            switch_cooldown_ms: 5000,
520            ..Default::default()
521        };
522        let caps = PhyCapabilities::ble5_full();
523        let mut ctrl = PhyController::new(config, caps);
524        ctrl.complete_negotiation(PhyCapabilities::ble5_full());
525
526        // Simulate a recent switch
527        ctrl.last_switch_time = 1000;
528
529        // Record samples at time 2000 (within cooldown)
530        let event = ctrl.record_rssi(-40, 2000);
531        assert!(event.is_none()); // Cooldown not expired
532
533        let event = ctrl.record_rssi(-40, 2100);
534        assert!(event.is_none()); // Still in cooldown
535    }
536
537    #[test]
538    fn test_reset() {
539        let mut ctrl = make_controller();
540        ctrl.complete_negotiation(PhyCapabilities::ble5_full());
541        ctrl.record_rssi(-50, 1000);
542
543        ctrl.reset();
544
545        assert_eq!(ctrl.state(), PhyControllerState::Idle);
546        assert_eq!(ctrl.tx_phy(), BlePhy::Le1M);
547        assert!(ctrl.rssi_samples.is_empty());
548    }
549
550    #[test]
551    fn test_stats_success_rate() {
552        let mut stats = PhyStats::default();
553        assert_eq!(stats.success_rate(), 1.0);
554
555        stats.switches = 10;
556        stats.successful_switches = 8;
557        stats.failed_switches = 2;
558        assert!((stats.success_rate() - 0.8).abs() < 0.01);
559    }
560
561    #[test]
562    fn test_stats_record_time() {
563        let mut stats = PhyStats::default();
564
565        stats.record_time(BlePhy::Le1M, 100);
566        stats.record_time(BlePhy::Le2M, 50);
567        stats.record_time(BlePhy::LeCodedS8, 200);
568
569        assert_eq!(stats.time_in_le1m, 100);
570        assert_eq!(stats.time_in_le2m, 50);
571        assert_eq!(stats.time_in_coded, 200);
572    }
573}