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