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