Skip to main content

wlancfg_lib/client/roaming/roam_monitor/
stationary_monitor.rs

1// Copyright 2024 The Fuchsia Authors. All rights reserved.
2// Use of this source code is governed by a BSD-style license that can be
3// found in the LICENSE file.
4
5use crate::client::config_management::Credential;
6use crate::client::roaming::lib::*;
7use crate::client::roaming::roam_monitor::RoamMonitorApi;
8use crate::client::types;
9use crate::config_management::SavedNetworksManagerApi;
10use crate::telemetry::{TelemetryEvent, TelemetrySender};
11use crate::util::pseudo_energy::EwmaSignalData;
12use async_trait::async_trait;
13use fidl_fuchsia_wlan_common as fidl_common;
14use fidl_fuchsia_wlan_ieee80211 as fidl_ieee80211;
15use fidl_fuchsia_wlan_internal as fidl_internal;
16use fuchsia_async as fasync;
17use futures::lock::Mutex;
18use log::{error, info};
19use std::sync::Arc;
20use wlan_common::historical_list::Timestamped;
21
22pub const MIN_BACKOFF_BETWEEN_ROAM_SCANS: zx::MonotonicDuration =
23    zx::MonotonicDuration::from_minutes(1);
24pub const MAX_BACKOFF_BETWEEN_ROAM_SCANS: zx::MonotonicDuration =
25    zx::MonotonicDuration::from_minutes(32);
26
27const LOCAL_ROAM_THRESHOLD_RSSI_2G: f64 = -72.0;
28const LOCAL_ROAM_THRESHOLD_RSSI_5G: f64 = -75.0;
29
30const MIN_RSSI_IMPROVEMENT_TO_ROAM: f64 = 3.0;
31const MIN_RSSI_DROP_TO_RESET_BACKOFF: f64 = 3.0;
32
33/// Number of previous RSSI measurements to exponentially weigh into average.
34/// TODO(https://fxbug.dev/42165706): Tune smoothing factor.
35pub const STATIONARY_ROAMING_EWMA_SMOOTHING_FACTOR: usize = 10;
36
37// Roams will not be considered if more than this many roams have been attempted in the last day.
38pub const NUM_MAX_ROAMS_PER_DAY: usize = 5;
39
40pub struct StationaryMonitor {
41    pub connection_data: RoamingConnectionData,
42    pub telemetry_sender: TelemetrySender,
43    saved_networks: Arc<dyn SavedNetworksManagerApi>,
44    scan_backoff: zx::MonotonicDuration,
45    /// To be used to limit how often roams can happen to avoid thrashing between APs.
46    past_roams: Arc<Mutex<PastRoamList>>,
47    next_roaming_enabled_time: fasync::MonotonicInstant,
48}
49
50impl StationaryMonitor {
51    pub fn new(
52        ap_state: types::ApState,
53        network_identifier: types::NetworkIdentifier,
54        credential: Credential,
55        telemetry_sender: TelemetrySender,
56        saved_networks: Arc<dyn SavedNetworksManagerApi>,
57        past_roams: Arc<Mutex<PastRoamList>>,
58    ) -> Self {
59        let connection_data = RoamingConnectionData::new(
60            ap_state.clone(),
61            network_identifier,
62            credential,
63            EwmaSignalData::new(
64                ap_state.tracked.signal.rssi_dbm,
65                ap_state.tracked.signal.snr_db,
66                STATIONARY_ROAMING_EWMA_SMOOTHING_FACTOR,
67            ),
68        );
69        Self {
70            connection_data,
71            telemetry_sender,
72            saved_networks,
73            scan_backoff: MIN_BACKOFF_BETWEEN_ROAM_SCANS,
74            past_roams,
75            next_roaming_enabled_time: fasync::MonotonicInstant::now(),
76        }
77    }
78
79    // Handle signal report indiciations. Update internal connection data, if necessary. Returns
80    // true if a roam search should be initiated.
81    #[allow(clippy::needless_return, reason = "mass allow for https://fxbug.dev/381896734")]
82    async fn handle_signal_report(
83        &mut self,
84        stats: fidl_internal::SignalReportIndication,
85    ) -> Result<RoamTriggerDataOutcome, anyhow::Error> {
86        if let (Some(rssi), Some(snr)) = (stats.rssi_dbm, stats.snr_db) {
87            self.connection_data.signal_data.update_with_new_measurement(rssi, snr);
88
89            // Update velocity with EWMA signal, to smooth out noise.
90            self.connection_data
91                .rssi_velocity
92                .update(self.connection_data.signal_data.ewma_rssi.get());
93
94            self.telemetry_sender.send(TelemetryEvent::OnSignalVelocityUpdate {
95                rssi_velocity: self.connection_data.rssi_velocity.get(),
96            });
97        }
98
99        // If the network likely has 1 BSS, don't scan for another BSS to roam to.
100        match self
101            .saved_networks
102            .is_network_single_bss(
103                &self.connection_data.network_identifier,
104                &self.connection_data.credential,
105            )
106            .await
107        {
108            Ok(true) => return Ok(RoamTriggerDataOutcome::Noop),
109            _ => {
110                // There could be an error if the config is not found. If there was an error, treat
111                // that as the network could be multi BSS and consider a roam scan.
112                return Ok(self.should_roam_scan_after_signal_report());
113            }
114        }
115    }
116
117    fn should_roam_scan_after_signal_report(&mut self) -> RoamTriggerDataOutcome {
118        // Exit early if roaming has been disabled.
119        if fasync::MonotonicInstant::now() <= self.next_roaming_enabled_time {
120            return RoamTriggerDataOutcome::Noop;
121        }
122        // If we have exceeded the maximum number of roam attempts per day, do not attempt roaming
123        // and record the next time at which roaming will be enabled.
124        if let Some(past_roams) = self.past_roams.try_lock() {
125            let recent_roams =
126                past_roams.get_recent(fasync::MonotonicInstant::now() - TIMESPAN_TO_LIMIT_SCANS);
127            if recent_roams.len() >= NUM_MAX_ROAMS_PER_DAY {
128                if let Some(oldest_roam_attempt) = recent_roams.first() {
129                    self.next_roaming_enabled_time =
130                        oldest_roam_attempt.time() + TIMESPAN_TO_LIMIT_SCANS;
131                    info!(
132                        "Maximum number of roam attempts per day ({}) has been reached. Roam scanning is disabled until a reboot, or until fewer than {} roam attempts have occured in the past 24 hours.",
133                        NUM_MAX_ROAMS_PER_DAY, NUM_MAX_ROAMS_PER_DAY
134                    );
135                } else {
136                    error!("Unexpectedly failed to get the oldest roam attempt.");
137                }
138                return RoamTriggerDataOutcome::Noop;
139            }
140        } else {
141            error!("Unexpectedly failed to get lock on recent roam attempts data");
142        }
143        let mut roam_reasons: Vec<RoamReason> = vec![];
144
145        // Check RSSI threshold
146        let rssi_threshold = if self.connection_data.ap_state.tracked.channel.band
147            == fidl_ieee80211::WlanBand::FiveGhz
148        {
149            LOCAL_ROAM_THRESHOLD_RSSI_5G
150        } else {
151            LOCAL_ROAM_THRESHOLD_RSSI_2G
152        };
153        let rssi = self.connection_data.signal_data.ewma_rssi.get();
154        if rssi <= rssi_threshold {
155            roam_reasons.push(RoamReason::RssiBelowThreshold);
156
157            // Reset scan backoff as RSSI has dropped notably since the last roam scan.
158            if rssi
159                <= self.connection_data.previous_roam_scan_data.rssi
160                    - MIN_RSSI_DROP_TO_RESET_BACKOFF
161            {
162                self.scan_backoff = MIN_BACKOFF_BETWEEN_ROAM_SCANS;
163            }
164        }
165
166        // Do not scan if there are no roam reasons, or if the scan backoff has not yet passed.f
167        let now = fasync::MonotonicInstant::now();
168        if roam_reasons.is_empty()
169            || now < self.connection_data.previous_roam_scan_data.time + self.scan_backoff
170        {
171            return RoamTriggerDataOutcome::Noop;
172        }
173
174        // Exponentially extend backoff between roam scans
175        self.scan_backoff =
176            std::cmp::min(self.scan_backoff * 2_i64, MAX_BACKOFF_BETWEEN_ROAM_SCANS);
177
178        // Updated fields for tracking roam scan decisions and initiated roam search.
179        self.connection_data.previous_roam_scan_data.time = fasync::MonotonicInstant::now();
180        self.connection_data.previous_roam_scan_data.rssi = rssi;
181        info!("Initiating roam search for roam reasons: {:?}", roam_reasons);
182
183        RoamTriggerDataOutcome::RoamSearch {
184            // Stationary monitor uses active roam scans to prioritize shorter scan times over power
185            // consumption.
186            scan_type: fidl_common::ScanType::Active,
187            network_identifier: self.connection_data.network_identifier.clone(),
188            credential: self.connection_data.credential.clone(),
189            current_security: self.connection_data.ap_state.original().protection().into(),
190            reasons: roam_reasons,
191        }
192    }
193}
194
195#[async_trait(?Send)]
196impl RoamMonitorApi for StationaryMonitor {
197    async fn handle_roam_trigger_data(
198        &mut self,
199        data: RoamTriggerData,
200    ) -> Result<RoamTriggerDataOutcome, anyhow::Error> {
201        match data {
202            RoamTriggerData::SignalReportInd(stats) => self.handle_signal_report(stats).await,
203        }
204    }
205
206    fn should_send_roam_request(&self, request: PolicyRoamRequest) -> Result<bool, anyhow::Error> {
207        if request.candidate.bss.bssid == self.connection_data.ap_state.original().bssid {
208            info!("Selected roam candidate is the currently connected candidate, ignoring");
209            return Ok(false);
210        }
211        // Only send roam scan if the selected candidate shows a significant signal improvement,
212        // compared to the most up-to-date roaming connection data
213        let latest_rssi = self.connection_data.signal_data.ewma_rssi.get();
214        if (request.candidate.bss.signal.rssi_dbm as f64)
215            < latest_rssi + MIN_RSSI_IMPROVEMENT_TO_ROAM
216        {
217            info!(
218                "Selected roam candidate ({:?}) is not enough of an improvement. Ignoring.",
219                request.candidate.to_string_without_pii()
220            );
221            return Ok(false);
222        }
223        Ok(true)
224    }
225
226    fn notify_of_roam_attempt(&mut self) {
227        // Reset scan backoff when a roam is attempted, because we will now be on a new BSS (or
228        // will get disconnected, and this will be irrelevant).
229        self.scan_backoff = MIN_BACKOFF_BETWEEN_ROAM_SCANS;
230    }
231}
232
233#[cfg(test)]
234mod test {
235    use super::*;
236    use crate::util::testing::{
237        FakeSavedNetworksManager, generate_random_bss, generate_random_password,
238        generate_random_roaming_connection_data, generate_random_scanned_candidate,
239    };
240    use assert_matches::assert_matches;
241    use fidl_fuchsia_wlan_internal as fidl_internal;
242    use futures::channel::mpsc;
243    use futures::task::Poll;
244    use test_case::test_case;
245
246    struct TestValues {
247        monitor: StationaryMonitor,
248        telemetry_receiver: mpsc::Receiver<TelemetryEvent>,
249        saved_networks: Arc<FakeSavedNetworksManager>,
250        past_roams: Arc<Mutex<PastRoamList>>,
251    }
252
253    const TEST_OK_SNR: f64 = 40.0;
254
255    fn setup_test() -> TestValues {
256        let connection_data = generate_random_roaming_connection_data();
257        let (telemetry_sender, telemetry_receiver) = mpsc::channel::<TelemetryEvent>(100);
258        let telemetry_sender = TelemetrySender::new(telemetry_sender);
259        let saved_networks = Arc::new(FakeSavedNetworksManager::new());
260        let past_roams = Arc::new(Mutex::new(PastRoamList::new()));
261        // Set the fake saved networks manager to respond that the network is not single BSS by
262        // default since most tests are for cases where roaming should be considered.
263        saved_networks.set_is_single_bss_response(false);
264        let monitor = StationaryMonitor {
265            connection_data,
266            telemetry_sender,
267            saved_networks: saved_networks.clone(),
268            scan_backoff: MIN_BACKOFF_BETWEEN_ROAM_SCANS,
269            past_roams: past_roams.clone(),
270            next_roaming_enabled_time: fasync::MonotonicInstant::now(),
271        };
272        TestValues { monitor, telemetry_receiver, saved_networks, past_roams }
273    }
274
275    fn setup_test_with_data(connection_data: RoamingConnectionData) -> TestValues {
276        let (telemetry_sender, telemetry_receiver) = mpsc::channel::<TelemetryEvent>(100);
277        let telemetry_sender = TelemetrySender::new(telemetry_sender);
278        let saved_networks = Arc::new(FakeSavedNetworksManager::new());
279        let past_roams = Arc::new(Mutex::new(PastRoamList::new()));
280        let monitor = StationaryMonitor {
281            connection_data,
282            telemetry_sender,
283            saved_networks: saved_networks.clone(),
284            scan_backoff: MIN_BACKOFF_BETWEEN_ROAM_SCANS,
285            past_roams: past_roams.clone(),
286            next_roaming_enabled_time: fasync::MonotonicInstant::now(),
287        };
288        TestValues { monitor, telemetry_receiver, saved_networks, past_roams }
289    }
290
291    /// This runs handle_roam_trigger_data with run_until_stalled and expects it to finish.
292    /// run_single_threaded cannot be used with fake time.
293    fn run_handle_roam_trigger_data(
294        exec: &mut fasync::TestExecutor,
295        monitor: &mut StationaryMonitor,
296        trigger_data: RoamTriggerData,
297    ) -> RoamTriggerDataOutcome {
298        return assert_matches!(exec.run_until_stalled(&mut monitor.handle_roam_trigger_data(trigger_data)), Poll::Ready(Ok(should_roam)) => {should_roam});
299    }
300
301    #[test_case(-80, true; "bad rssi")]
302    #[test_case(-40, false; "good rssi")]
303    #[fuchsia::test(add_test_attr = false)]
304    fn test_handle_signal_report_trigger_data(rssi: i8, should_roam_search: bool) {
305        let mut exec = fasync::TestExecutor::new_with_fake_time();
306        exec.set_fake_time(fasync::MonotonicInstant::now());
307
308        // Generate initial connection data based on test case.
309        let connection_data = RoamingConnectionData {
310            signal_data: EwmaSignalData::new(rssi, TEST_OK_SNR, 10),
311            ..generate_random_roaming_connection_data()
312        };
313        let mut test_values = setup_test_with_data(connection_data);
314
315        // Advance the time so that we allow roam scanning,
316        exec.set_fake_time(fasync::MonotonicInstant::after(fasync::MonotonicDuration::from_hours(
317            1,
318        )));
319
320        // Generate trigger data that won't change the above values, and send to handle_roam_trigger_data
321        // method.
322        let trigger_data =
323            RoamTriggerData::SignalReportInd(fidl_internal::SignalReportIndication {
324                rssi_dbm: Some(rssi),
325                snr_db: Some(TEST_OK_SNR as i8),
326                tx_rate_500kbps: Some(0),
327                ..Default::default()
328            });
329        let result =
330            run_handle_roam_trigger_data(&mut exec, &mut test_values.monitor, trigger_data.clone());
331
332        if should_roam_search {
333            assert_matches!(result, RoamTriggerDataOutcome::RoamSearch { .. });
334        } else {
335            assert_matches!(result, RoamTriggerDataOutcome::Noop);
336        }
337    }
338
339    #[fuchsia::test]
340    fn test_stationary_monitor_uses_active_scans() {
341        let mut exec = fasync::TestExecutor::new_with_fake_time();
342        exec.set_fake_time(fasync::MonotonicInstant::now());
343
344        // Setup monitor with connection data that would trigger a roam scan due to RSSI below
345        // threshold. Set the EWMA weights to 1 so the values can be easily changed later in tests.
346        let rssi = LOCAL_ROAM_THRESHOLD_RSSI_5G - 1.0;
347        let connection_data = RoamingConnectionData {
348            signal_data: EwmaSignalData::new(rssi, TEST_OK_SNR, 1),
349            ..generate_random_roaming_connection_data()
350        };
351        let mut test_values = setup_test_with_data(connection_data);
352
353        // Generate trigger data with same signal values as initial, which would trigger a roam
354        // search due to the below threshold RSSI.
355        let trigger_data =
356            RoamTriggerData::SignalReportInd(fidl_internal::SignalReportIndication {
357                rssi_dbm: Some(rssi as i8),
358                snr_db: Some(TEST_OK_SNR as i8),
359                tx_rate_500kbps: Some(0),
360                ..Default::default()
361            });
362
363        // Advance the time so that we allow roam scanning
364        exec.set_fake_time(fasync::MonotonicInstant::after(fasync::MonotonicDuration::from_hours(
365            1,
366        )));
367
368        // Send trigger data, and verify that the roam scan type is Active.
369        assert_matches!(
370            run_handle_roam_trigger_data(&mut exec, &mut test_values.monitor, trigger_data.clone()),
371            RoamTriggerDataOutcome::RoamSearch { scan_type: fidl_common::ScanType::Active, .. }
372        );
373    }
374
375    #[fuchsia::test]
376    fn test_minimum_time_between_roam_scans() {
377        let mut exec = fasync::TestExecutor::new_with_fake_time();
378        exec.set_fake_time(fasync::MonotonicInstant::now());
379
380        // Setup monitor with connection data that would trigger a roam scan due to RSSI below
381        // threshold. Set the EWMA weights to 1 so the values can be easily changed later in tests.
382        let rssi = LOCAL_ROAM_THRESHOLD_RSSI_5G - 1.0;
383        let connection_data = RoamingConnectionData {
384            signal_data: EwmaSignalData::new(rssi, TEST_OK_SNR, 1),
385            ..generate_random_roaming_connection_data()
386        };
387        let mut test_values = setup_test_with_data(connection_data);
388        let trigger_data =
389            RoamTriggerData::SignalReportInd(fidl_internal::SignalReportIndication {
390                rssi_dbm: Some(rssi as i8),
391                snr_db: Some(TEST_OK_SNR as i8),
392                tx_rate_500kbps: Some(0),
393                ..Default::default()
394            });
395
396        // Advance the time less than the minimum scan backoff time.
397        exec.set_fake_time(fasync::MonotonicInstant::after(
398            MIN_BACKOFF_BETWEEN_ROAM_SCANS - fasync::MonotonicDuration::from_seconds(1),
399        ));
400
401        // Send trigger data, and verify that we aren't told to roam search because the minimum wait
402        // time has not passed.
403        assert_matches!(
404            run_handle_roam_trigger_data(&mut exec, &mut test_values.monitor, trigger_data.clone()),
405            RoamTriggerDataOutcome::Noop
406        );
407
408        // Now advance past the minimum wait time.
409        exec.set_fake_time(fasync::MonotonicInstant::after(
410            fasync::MonotonicDuration::from_seconds(2),
411        ));
412
413        // Send trigger data, and verify that we are told to roam search.
414        assert_matches!(
415            run_handle_roam_trigger_data(&mut exec, &mut test_values.monitor, trigger_data.clone()),
416            RoamTriggerDataOutcome::RoamSearch { .. }
417        );
418    }
419
420    #[fuchsia::test]
421    fn test_roam_scans_backoff_exponentially() {
422        let mut exec = fasync::TestExecutor::new_with_fake_time();
423        exec.set_fake_time(fasync::MonotonicInstant::now());
424
425        // Setup monitor with connection data that would trigger a roam scan due to RSSI below
426        // threshold. Set the EWMA weights to 1 so the values can be easily changed later in tests.
427        let rssi = LOCAL_ROAM_THRESHOLD_RSSI_5G - 1.0;
428        let connection_data = RoamingConnectionData {
429            signal_data: EwmaSignalData::new(rssi, TEST_OK_SNR, 1),
430            ..generate_random_roaming_connection_data()
431        };
432        let trigger_data =
433            RoamTriggerData::SignalReportInd(fidl_internal::SignalReportIndication {
434                rssi_dbm: Some(rssi as i8),
435                snr_db: Some(TEST_OK_SNR as i8),
436                tx_rate_500kbps: Some(0),
437                ..Default::default()
438            });
439        let mut test_values = setup_test_with_data(connection_data);
440
441        // Expected backoffs should start at the minimum value, and double up to the maximum value.
442        let mut expected_backoff = MIN_BACKOFF_BETWEEN_ROAM_SCANS;
443        while expected_backoff <= MAX_BACKOFF_BETWEEN_ROAM_SCANS {
444            // Advance time by less than the expected backoff.
445            exec.set_fake_time(fasync::MonotonicInstant::after(
446                expected_backoff - fasync::MonotonicDuration::from_seconds(1),
447            ));
448
449            // Send trigger data, and verify that we aren't told to roam search because the minimum wait
450            // time has not passed.
451            assert_matches!(
452                run_handle_roam_trigger_data(
453                    &mut exec,
454                    &mut test_values.monitor,
455                    trigger_data.clone()
456                ),
457                RoamTriggerDataOutcome::Noop
458            );
459
460            // Advance time past the expected backoff time.
461            exec.set_fake_time(fasync::MonotonicInstant::after(
462                fasync::MonotonicDuration::from_seconds(2),
463            ));
464
465            // Send trigger data, and verify that we are told to roam search.
466            assert_matches!(
467                run_handle_roam_trigger_data(
468                    &mut exec,
469                    &mut test_values.monitor,
470                    trigger_data.clone()
471                ),
472                RoamTriggerDataOutcome::RoamSearch { .. }
473            );
474
475            // Backoff exponentially
476            expected_backoff = expected_backoff * 2;
477        }
478        // Ensure the backoff has not extended past the maximum value by advancing past the maximum
479        // and verifying we can roam search.
480        exec.set_fake_time(fasync::MonotonicInstant::after(
481            MAX_BACKOFF_BETWEEN_ROAM_SCANS + fasync::MonotonicDuration::from_seconds(1),
482        ));
483        assert_matches!(
484            run_handle_roam_trigger_data(&mut exec, &mut test_values.monitor, trigger_data.clone()),
485            RoamTriggerDataOutcome::RoamSearch { .. }
486        );
487    }
488
489    #[fuchsia::test]
490    fn test_roam_attempt_resets_backoff() {
491        let mut exec = fasync::TestExecutor::new_with_fake_time();
492        exec.set_fake_time(fasync::MonotonicInstant::now());
493
494        // Setup monitor with connection data that would trigger a roam scan due to RSSI below
495        // threshold. Set the EWMA weights to 1 so the values can be easily changed later in tests.
496        let rssi = LOCAL_ROAM_THRESHOLD_RSSI_5G - 1.0;
497        let connection_data = RoamingConnectionData {
498            signal_data: EwmaSignalData::new(rssi, TEST_OK_SNR, 1),
499            ..generate_random_roaming_connection_data()
500        };
501        let trigger_data =
502            RoamTriggerData::SignalReportInd(fidl_internal::SignalReportIndication {
503                rssi_dbm: Some(rssi as i8),
504                snr_db: Some(TEST_OK_SNR as i8),
505                tx_rate_500kbps: Some(0),
506                ..Default::default()
507            });
508        let mut test_values = setup_test_with_data(connection_data);
509
510        // Run time forward past the minimum backoff time.
511        exec.set_fake_time(
512            fasync::MonotonicInstant::after(MIN_BACKOFF_BETWEEN_ROAM_SCANS)
513                + zx::MonotonicDuration::from_seconds(1),
514        );
515
516        // Send trigger data, and verify that we are told to roam search.
517        assert_matches!(
518            run_handle_roam_trigger_data(&mut exec, &mut test_values.monitor, trigger_data.clone()),
519            RoamTriggerDataOutcome::RoamSearch { .. }
520        );
521
522        // Run time forward past the minimum backoff time.
523        exec.set_fake_time(
524            fasync::MonotonicInstant::after(MIN_BACKOFF_BETWEEN_ROAM_SCANS)
525                + zx::MonotonicDuration::from_seconds(1),
526        );
527
528        // Send trigger data, and verify that we do not roam search, because the backoff has grown.
529        assert_matches!(
530            run_handle_roam_trigger_data(&mut exec, &mut test_values.monitor, trigger_data.clone()),
531            RoamTriggerDataOutcome::Noop
532        );
533
534        // Receive notification of a roam attempt.
535        test_values.monitor.notify_of_roam_attempt();
536
537        // Send trigger data, and verify that we may now roam search, because the backoff has been
538        // reset.
539        assert_matches!(
540            run_handle_roam_trigger_data(&mut exec, &mut test_values.monitor, trigger_data.clone()),
541            RoamTriggerDataOutcome::RoamSearch { .. }
542        );
543    }
544
545    #[fuchsia::test]
546    fn test_rssi_drop_resets_backoff() {
547        let mut exec = fasync::TestExecutor::new_with_fake_time();
548        exec.set_fake_time(fasync::MonotonicInstant::now());
549
550        // Setup monitor with connection data that would trigger a roam scan due to RSSI below
551        // threshold. Set the EWMA weights to 1 so the values can be easily changed later in tests.
552        let rssi = LOCAL_ROAM_THRESHOLD_RSSI_5G - 1.0;
553        let connection_data = RoamingConnectionData {
554            signal_data: EwmaSignalData::new(rssi, TEST_OK_SNR, 1),
555            ..generate_random_roaming_connection_data()
556        };
557        let trigger_data =
558            RoamTriggerData::SignalReportInd(fidl_internal::SignalReportIndication {
559                rssi_dbm: Some(rssi as i8),
560                snr_db: Some(TEST_OK_SNR as i8),
561                tx_rate_500kbps: Some(0),
562                ..Default::default()
563            });
564        let mut test_values = setup_test_with_data(connection_data);
565
566        // Run time forward past the minimum backoff time.
567        exec.set_fake_time(
568            fasync::MonotonicInstant::after(MIN_BACKOFF_BETWEEN_ROAM_SCANS)
569                + zx::MonotonicDuration::from_seconds(1),
570        );
571
572        // Send trigger data, and verify that we are told to roam search.
573        assert_matches!(
574            run_handle_roam_trigger_data(&mut exec, &mut test_values.monitor, trigger_data.clone()),
575            RoamTriggerDataOutcome::RoamSearch { .. }
576        );
577
578        // Run time forward past the minimum backoff time again.
579        exec.set_fake_time(
580            fasync::MonotonicInstant::after(MIN_BACKOFF_BETWEEN_ROAM_SCANS)
581                + zx::MonotonicDuration::from_seconds(1),
582        );
583
584        // Send trigger data, and verify that we do not roam scan, because the backoff has extended.
585        assert_matches!(
586            run_handle_roam_trigger_data(&mut exec, &mut test_values.monitor, trigger_data.clone()),
587            RoamTriggerDataOutcome::Noop
588        );
589
590        // Now send trigger data showing the RSSI has dropped significantly, and verify that we
591        // do now roam search because the backoff was reset to the minimum time.
592        let trigger_data =
593            RoamTriggerData::SignalReportInd(fidl_internal::SignalReportIndication {
594                rssi_dbm: Some((rssi - MIN_RSSI_DROP_TO_RESET_BACKOFF) as i8),
595                snr_db: Some(TEST_OK_SNR as i8),
596                tx_rate_500kbps: Some(0),
597                ..Default::default()
598            });
599        assert_matches!(
600            run_handle_roam_trigger_data(&mut exec, &mut test_values.monitor, trigger_data.clone()),
601            RoamTriggerDataOutcome::RoamSearch { .. }
602        );
603
604        // Run time forward time, but not past the absolute minimum backoff time .
605        exec.set_fake_time(
606            fasync::MonotonicInstant::after(MIN_BACKOFF_BETWEEN_ROAM_SCANS)
607                - zx::MonotonicDuration::from_seconds(1),
608        );
609        // Now send trigger data showing an _additional_ drop in RSSI, but verify that we do not
610        // not scan as the minimum backoff time has not passed.
611        assert_matches!(
612            run_handle_roam_trigger_data(&mut exec, &mut test_values.monitor, trigger_data.clone()),
613            RoamTriggerDataOutcome::Noop
614        );
615    }
616
617    #[fuchsia::test]
618    fn test_should_send_roam_request() {
619        let _exec = fasync::TestExecutor::new();
620        let test_values = setup_test();
621
622        // Get the randomized RSSI value.
623        let current_rssi = test_values.monitor.connection_data.signal_data.ewma_rssi.get();
624
625        // Verify that roam recommendations are blocked if RSSI is an insufficient improvement.
626        let candidate = types::ScannedCandidate {
627            bss: types::Bss {
628                signal: types::Signal {
629                    rssi_dbm: (current_rssi + MIN_RSSI_IMPROVEMENT_TO_ROAM - 1.0) as i8,
630                    snr_db: TEST_OK_SNR as i8,
631                },
632                ..generate_random_bss()
633            },
634            ..generate_random_scanned_candidate()
635        };
636        assert!(
637            !test_values
638                .monitor
639                .should_send_roam_request(PolicyRoamRequest { candidate, reasons: vec![] })
640                .expect("failed to check roam request")
641        );
642
643        // Verify that a roam recommendation is made if RSSI improvement exceeds threshold.
644        let candidate = types::ScannedCandidate {
645            bss: types::Bss {
646                signal: types::Signal {
647                    rssi_dbm: (current_rssi + MIN_RSSI_IMPROVEMENT_TO_ROAM) as i8,
648                    snr_db: TEST_OK_SNR as i8,
649                },
650                ..generate_random_bss()
651            },
652            ..generate_random_scanned_candidate()
653        };
654        assert!(
655            test_values
656                .monitor
657                .should_send_roam_request(PolicyRoamRequest { candidate, reasons: vec![] })
658                .expect("failed to check roam request")
659        );
660
661        // Verify that roam recommendations are blocked if the selected candidate is the currently
662        // connected BSS. Set signal values high enough to isolate the dedupe function.
663        let candidate = types::ScannedCandidate {
664            bss: types::Bss {
665                signal: types::Signal {
666                    rssi_dbm: (current_rssi + MIN_RSSI_IMPROVEMENT_TO_ROAM + 1.0) as i8,
667                    snr_db: TEST_OK_SNR as i8,
668                },
669                bssid: test_values.monitor.connection_data.ap_state.original().bssid,
670                ..generate_random_bss()
671            },
672            credential: generate_random_password(),
673            ..generate_random_scanned_candidate()
674        };
675        assert!(
676            !test_values
677                .monitor
678                .should_send_roam_request(PolicyRoamRequest { candidate, reasons: vec![] })
679                .expect("failed to check roam reqeust")
680        );
681    }
682
683    #[fuchsia::test]
684    fn test_send_signal_velocity_metric_event() {
685        let mut exec = fasync::TestExecutor::new_with_fake_time();
686        exec.set_fake_time(fasync::MonotonicInstant::now());
687
688        let connection_data = RoamingConnectionData {
689            signal_data: EwmaSignalData::new(-40, 50, 1),
690            ..generate_random_roaming_connection_data()
691        };
692        let mut test_values = setup_test_with_data(connection_data);
693        test_values.saved_networks.set_is_single_bss_response(true);
694
695        let trigger_data =
696            RoamTriggerData::SignalReportInd(fidl_internal::SignalReportIndication {
697                rssi_dbm: Some(-80),
698                snr_db: Some(TEST_OK_SNR as i8),
699                tx_rate_500kbps: Some(0),
700                ..Default::default()
701            });
702        let _ =
703            run_handle_roam_trigger_data(&mut exec, &mut test_values.monitor, trigger_data.clone());
704
705        assert_matches!(
706            test_values.telemetry_receiver.try_recv(),
707            Ok(TelemetryEvent::OnSignalVelocityUpdate { .. })
708        );
709    }
710
711    #[fuchsia::test]
712    fn test_should_not_roam_scan_single_bss() {
713        let mut exec = fasync::TestExecutor::new_with_fake_time();
714        exec.set_fake_time(fasync::MonotonicInstant::now());
715
716        let rssi = -80;
717        let connection_data = RoamingConnectionData {
718            signal_data: EwmaSignalData::new(rssi, TEST_OK_SNR, 10),
719            ..generate_random_roaming_connection_data()
720        };
721        let mut test_values = setup_test_with_data(connection_data);
722
723        // Set the FakeSavedNetworks manager to report the network as single BSS
724        test_values.saved_networks.set_is_single_bss_response(true);
725
726        // Advance the time so that we allow roam scanning,
727        exec.set_fake_time(fasync::MonotonicInstant::after(fasync::MonotonicDuration::from_hours(
728            1,
729        )));
730
731        let trigger_data =
732            RoamTriggerData::SignalReportInd(fidl_internal::SignalReportIndication {
733                rssi_dbm: Some(rssi),
734                snr_db: Some(TEST_OK_SNR as i8),
735                tx_rate_500kbps: Some(0),
736                ..Default::default()
737            });
738        let trigger_result =
739            run_handle_roam_trigger_data(&mut exec, &mut test_values.monitor, trigger_data.clone());
740
741        assert_eq!(trigger_result, RoamTriggerDataOutcome::Noop);
742    }
743
744    #[fuchsia::test]
745    fn test_roam_not_considered_if_attempted_too_many_times_today() {
746        let mut exec = fasync::TestExecutor::new_with_fake_time();
747        let first_roam_time = fasync::MonotonicInstant::now();
748        exec.set_fake_time(first_roam_time);
749
750        // Send a signal report that would trigger a roam scan if the limit were not hit.
751        let rssi = LOCAL_ROAM_THRESHOLD_RSSI_5G - 5.0;
752        let connection_data = RoamingConnectionData {
753            signal_data: EwmaSignalData::new(rssi, TEST_OK_SNR, 10),
754            ..generate_random_roaming_connection_data()
755        };
756        let mut test_values = setup_test_with_data(connection_data);
757
758        // Record enough roam attempts to prevent roaming for a while.
759        for _ in 0..NUM_MAX_ROAMS_PER_DAY {
760            test_values.past_roams.try_lock().unwrap().add(RoamEvent::new_roam_now());
761            exec.set_fake_time(fasync::MonotonicInstant::after(MAX_BACKOFF_BETWEEN_ROAM_SCANS));
762        }
763
764        let trigger_data =
765            RoamTriggerData::SignalReportInd(fidl_internal::SignalReportIndication {
766                rssi_dbm: Some(rssi as i8),
767                snr_db: Some(TEST_OK_SNR as i8),
768                tx_rate_500kbps: Some(0),
769                ..Default::default()
770            });
771
772        exec.set_fake_time(fasync::MonotonicInstant::after(
773            MAX_BACKOFF_BETWEEN_ROAM_SCANS + zx::MonotonicDuration::from_seconds(1),
774        ));
775
776        // The limit on roams per day has been hit, no roam scan should be recommended.
777        let should_roam_scan_result =
778            run_handle_roam_trigger_data(&mut exec, &mut test_values.monitor, trigger_data.clone());
779        assert_eq!(should_roam_scan_result, RoamTriggerDataOutcome::Noop);
780
781        // Advance time to 24 hours past the first roam time. Roam scanning should now be allowed
782        // again.
783        exec.set_fake_time(
784            first_roam_time
785                + zx::MonotonicDuration::from_hours(24)
786                + zx::MonotonicDuration::from_seconds(1),
787        );
788        let should_roam_scan_result =
789            run_handle_roam_trigger_data(&mut exec, &mut test_values.monitor, trigger_data.clone());
790        assert_matches!(should_roam_scan_result, RoamTriggerDataOutcome::RoamSearch { .. });
791    }
792
793    #[fuchsia::test]
794    fn test_roaming_disabled_timer_is_set_and_respected() {
795        let mut exec = fasync::TestExecutor::new_with_fake_time();
796        let first_roam_time = fasync::MonotonicInstant::now();
797        exec.set_fake_time(first_roam_time);
798
799        // Setup with RSSIthat would trigger a roam scan if the limit were not hit.
800        let rssi = LOCAL_ROAM_THRESHOLD_RSSI_5G - 5.0;
801        let connection_data = RoamingConnectionData {
802            signal_data: EwmaSignalData::new(rssi, TEST_OK_SNR, 10),
803            ..generate_random_roaming_connection_data()
804        };
805        let mut test_values = setup_test_with_data(connection_data);
806
807        // Record enough roam attempts to prevent roaming for a while. The first roam attempt is
808        // at time 0, the second at 1 min, etc.
809        for i in 0..NUM_MAX_ROAMS_PER_DAY {
810            let time_of_roam = first_roam_time + zx::MonotonicDuration::from_minutes(i as i64);
811            exec.set_fake_time(time_of_roam);
812            test_values.past_roams.try_lock().unwrap().add(RoamEvent::new(time_of_roam));
813        }
814
815        let trigger_data =
816            RoamTriggerData::SignalReportInd(fidl_internal::SignalReportIndication {
817                rssi_dbm: Some(rssi as i8),
818                snr_db: Some(TEST_OK_SNR as i8),
819                tx_rate_500kbps: Some(0),
820                ..Default::default()
821            });
822
823        // Advance time just enough to be after the last roam event.
824        exec.set_fake_time(fasync::MonotonicInstant::after(zx::MonotonicDuration::from_minutes(
825            NUM_MAX_ROAMS_PER_DAY as i64,
826        )));
827
828        // The limit on roams per day has been hit, no roam scan should be recommended.
829        let should_roam_scan_result =
830            run_handle_roam_trigger_data(&mut exec, &mut test_values.monitor, trigger_data.clone());
831        assert_eq!(should_roam_scan_result, RoamTriggerDataOutcome::Noop);
832
833        // Check that the `next_roaming_enabled_time` was set correctly. It should be 24 hours
834        // after the *first* roam attempt.
835        let expected_reenabled_time = first_roam_time + TIMESPAN_TO_LIMIT_SCANS;
836        assert_eq!(test_values.monitor.next_roaming_enabled_time, expected_reenabled_time);
837
838        // Advance time to just BEFORE the re-enable time. Roaming should still be disabled.
839        exec.set_fake_time(expected_reenabled_time - zx::MonotonicDuration::from_seconds(1));
840        let should_roam_scan_result =
841            run_handle_roam_trigger_data(&mut exec, &mut test_values.monitor, trigger_data.clone());
842        assert_eq!(should_roam_scan_result, RoamTriggerDataOutcome::Noop);
843
844        // Advance time to just AFTER the re-enable time. Roaming should now be allowed.
845        exec.set_fake_time(expected_reenabled_time + zx::MonotonicDuration::from_seconds(1));
846        let should_roam_scan_result =
847            run_handle_roam_trigger_data(&mut exec, &mut test_values.monitor, trigger_data.clone());
848        assert_matches!(should_roam_scan_result, RoamTriggerDataOutcome::RoamSearch { .. });
849    }
850
851    #[fuchsia::test]
852    fn test_stationary_monitor_ignores_missing_signal_fields() {
853        let mut exec = fasync::TestExecutor::new_with_fake_time();
854        exec.set_fake_time(fasync::MonotonicInstant::now());
855
856        let initial_rssi = -60.0;
857        let initial_snr = 25.0;
858        let connection_data = RoamingConnectionData {
859            signal_data: EwmaSignalData::new(initial_rssi, initial_snr, 1),
860            ..generate_random_roaming_connection_data()
861        };
862        let mut test_values = setup_test_with_data(connection_data);
863
864        // Send a signal report with None for rssi and snr.
865        let trigger_data =
866            RoamTriggerData::SignalReportInd(fidl_internal::SignalReportIndication {
867                rssi_dbm: None,
868                snr_db: None,
869                tx_rate_500kbps: Some(54),
870                ..Default::default()
871            });
872
873        let result =
874            run_handle_roam_trigger_data(&mut exec, &mut test_values.monitor, trigger_data);
875        assert_eq!(result, RoamTriggerDataOutcome::Noop);
876
877        // Verify EWMA values are unchanged (not overwritten with 0).
878        assert_eq!(test_values.monitor.connection_data.signal_data.ewma_rssi.get(), initial_rssi);
879        assert_eq!(test_values.monitor.connection_data.signal_data.ewma_snr.get(), initial_snr);
880    }
881}