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::historical_list::Timestamped;
12use crate::util::pseudo_energy::EwmaSignalData;
13use async_trait::async_trait;
14use fidl_fuchsia_wlan_common as fidl_common;
15use fidl_fuchsia_wlan_ieee80211 as fidl_ieee80211;
16use fidl_fuchsia_wlan_internal as fidl_internal;
17use fuchsia_async as fasync;
18use futures::lock::Mutex;
19use log::{error, info};
20use std::sync::Arc;
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(NUM_MAX_ROAMS_PER_DAY)));
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(NUM_MAX_ROAMS_PER_DAY)));
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            });
323        let result =
324            run_handle_roam_trigger_data(&mut exec, &mut test_values.monitor, trigger_data.clone());
325
326        if should_roam_search {
327            assert_matches!(result, RoamTriggerDataOutcome::RoamSearch { .. });
328        } else {
329            assert_matches!(result, RoamTriggerDataOutcome::Noop);
330        }
331    }
332
333    #[fuchsia::test]
334    fn test_stationary_monitor_uses_active_scans() {
335        let mut exec = fasync::TestExecutor::new_with_fake_time();
336        exec.set_fake_time(fasync::MonotonicInstant::now());
337
338        // Setup monitor with connection data that would trigger a roam scan due to RSSI below
339        // threshold. Set the EWMA weights to 1 so the values can be easily changed later in tests.
340        let rssi = LOCAL_ROAM_THRESHOLD_RSSI_5G - 1.0;
341        let connection_data = RoamingConnectionData {
342            signal_data: EwmaSignalData::new(rssi, TEST_OK_SNR, 1),
343            ..generate_random_roaming_connection_data()
344        };
345        let mut test_values = setup_test_with_data(connection_data);
346
347        // Generate trigger data with same signal values as initial, which would trigger a roam
348        // search due to the below threshold RSSI.
349        let trigger_data =
350            RoamTriggerData::SignalReportInd(fidl_internal::SignalReportIndication {
351                rssi_dbm: rssi as i8,
352                snr_db: TEST_OK_SNR as i8,
353            });
354
355        // Advance the time so that we allow roam scanning
356        exec.set_fake_time(fasync::MonotonicInstant::after(fasync::MonotonicDuration::from_hours(
357            1,
358        )));
359
360        // Send trigger data, and verify that the roam scan type is Active.
361        assert_matches!(
362            run_handle_roam_trigger_data(&mut exec, &mut test_values.monitor, trigger_data.clone()),
363            RoamTriggerDataOutcome::RoamSearch { scan_type: fidl_common::ScanType::Active, .. }
364        );
365    }
366
367    #[fuchsia::test]
368    fn test_minimum_time_between_roam_scans() {
369        let mut exec = fasync::TestExecutor::new_with_fake_time();
370        exec.set_fake_time(fasync::MonotonicInstant::now());
371
372        // Setup monitor with connection data that would trigger a roam scan due to RSSI below
373        // threshold. Set the EWMA weights to 1 so the values can be easily changed later in tests.
374        let rssi = LOCAL_ROAM_THRESHOLD_RSSI_5G - 1.0;
375        let connection_data = RoamingConnectionData {
376            signal_data: EwmaSignalData::new(rssi, TEST_OK_SNR, 1),
377            ..generate_random_roaming_connection_data()
378        };
379        let mut test_values = setup_test_with_data(connection_data);
380        let trigger_data =
381            RoamTriggerData::SignalReportInd(fidl_internal::SignalReportIndication {
382                rssi_dbm: rssi as i8,
383                snr_db: TEST_OK_SNR as i8,
384            });
385
386        // Advance the time less than the minimum scan backoff time.
387        exec.set_fake_time(fasync::MonotonicInstant::after(
388            MIN_BACKOFF_BETWEEN_ROAM_SCANS - fasync::MonotonicDuration::from_seconds(1),
389        ));
390
391        // Send trigger data, and verify that we aren't told to roam search because the minimum wait
392        // time has not passed.
393        assert_matches!(
394            run_handle_roam_trigger_data(&mut exec, &mut test_values.monitor, trigger_data.clone()),
395            RoamTriggerDataOutcome::Noop
396        );
397
398        // Now advance past the minimum wait time.
399        exec.set_fake_time(fasync::MonotonicInstant::after(
400            fasync::MonotonicDuration::from_seconds(2),
401        ));
402
403        // Send trigger data, and verify that we are told to roam search.
404        assert_matches!(
405            run_handle_roam_trigger_data(&mut exec, &mut test_values.monitor, trigger_data.clone()),
406            RoamTriggerDataOutcome::RoamSearch { .. }
407        );
408    }
409
410    #[fuchsia::test]
411    fn test_roam_scans_backoff_exponentially() {
412        let mut exec = fasync::TestExecutor::new_with_fake_time();
413        exec.set_fake_time(fasync::MonotonicInstant::now());
414
415        // Setup monitor with connection data that would trigger a roam scan due to RSSI below
416        // threshold. Set the EWMA weights to 1 so the values can be easily changed later in tests.
417        let rssi = LOCAL_ROAM_THRESHOLD_RSSI_5G - 1.0;
418        let connection_data = RoamingConnectionData {
419            signal_data: EwmaSignalData::new(rssi, TEST_OK_SNR, 1),
420            ..generate_random_roaming_connection_data()
421        };
422        let trigger_data =
423            RoamTriggerData::SignalReportInd(fidl_internal::SignalReportIndication {
424                rssi_dbm: rssi as i8,
425                snr_db: TEST_OK_SNR as i8,
426            });
427        let mut test_values = setup_test_with_data(connection_data);
428
429        // Expected backoffs should start at the minimum value, and double up to the maximum value.
430        let mut expected_backoff = MIN_BACKOFF_BETWEEN_ROAM_SCANS;
431        while expected_backoff <= MAX_BACKOFF_BETWEEN_ROAM_SCANS {
432            // Advance time by less than the expected backoff.
433            exec.set_fake_time(fasync::MonotonicInstant::after(
434                expected_backoff - fasync::MonotonicDuration::from_seconds(1),
435            ));
436
437            // Send trigger data, and verify that we aren't told to roam search because the minimum wait
438            // time has not passed.
439            assert_matches!(
440                run_handle_roam_trigger_data(
441                    &mut exec,
442                    &mut test_values.monitor,
443                    trigger_data.clone()
444                ),
445                RoamTriggerDataOutcome::Noop
446            );
447
448            // Advance time past the expected backoff time.
449            exec.set_fake_time(fasync::MonotonicInstant::after(
450                fasync::MonotonicDuration::from_seconds(2),
451            ));
452
453            // Send trigger data, and verify that we are told to roam search.
454            assert_matches!(
455                run_handle_roam_trigger_data(
456                    &mut exec,
457                    &mut test_values.monitor,
458                    trigger_data.clone()
459                ),
460                RoamTriggerDataOutcome::RoamSearch { .. }
461            );
462
463            // Backoff exponentially
464            expected_backoff = expected_backoff * 2;
465        }
466        // Ensure the backoff has not extended past the maximum value by advancing past the maximum
467        // and verifying we can roam search.
468        exec.set_fake_time(fasync::MonotonicInstant::after(
469            MAX_BACKOFF_BETWEEN_ROAM_SCANS + fasync::MonotonicDuration::from_seconds(1),
470        ));
471        assert_matches!(
472            run_handle_roam_trigger_data(&mut exec, &mut test_values.monitor, trigger_data.clone()),
473            RoamTriggerDataOutcome::RoamSearch { .. }
474        );
475    }
476
477    #[fuchsia::test]
478    fn test_roam_attempt_resets_backoff() {
479        let mut exec = fasync::TestExecutor::new_with_fake_time();
480        exec.set_fake_time(fasync::MonotonicInstant::now());
481
482        // Setup monitor with connection data that would trigger a roam scan due to RSSI below
483        // threshold. Set the EWMA weights to 1 so the values can be easily changed later in tests.
484        let rssi = LOCAL_ROAM_THRESHOLD_RSSI_5G - 1.0;
485        let connection_data = RoamingConnectionData {
486            signal_data: EwmaSignalData::new(rssi, TEST_OK_SNR, 1),
487            ..generate_random_roaming_connection_data()
488        };
489        let trigger_data =
490            RoamTriggerData::SignalReportInd(fidl_internal::SignalReportIndication {
491                rssi_dbm: rssi as i8,
492                snr_db: TEST_OK_SNR as i8,
493            });
494        let mut test_values = setup_test_with_data(connection_data);
495
496        // Run time forward past the minimum backoff time.
497        exec.set_fake_time(
498            fasync::MonotonicInstant::after(MIN_BACKOFF_BETWEEN_ROAM_SCANS)
499                + zx::MonotonicDuration::from_seconds(1),
500        );
501
502        // Send trigger data, and verify that we are told to roam search.
503        assert_matches!(
504            run_handle_roam_trigger_data(&mut exec, &mut test_values.monitor, trigger_data.clone()),
505            RoamTriggerDataOutcome::RoamSearch { .. }
506        );
507
508        // Run time forward past the minimum backoff time.
509        exec.set_fake_time(
510            fasync::MonotonicInstant::after(MIN_BACKOFF_BETWEEN_ROAM_SCANS)
511                + zx::MonotonicDuration::from_seconds(1),
512        );
513
514        // Send trigger data, and verify that we do not roam search, because the backoff has grown.
515        assert_matches!(
516            run_handle_roam_trigger_data(&mut exec, &mut test_values.monitor, trigger_data.clone()),
517            RoamTriggerDataOutcome::Noop
518        );
519
520        // Receive notification of a roam attempt.
521        test_values.monitor.notify_of_roam_attempt();
522
523        // Send trigger data, and verify that we may now roam search, because the backoff has been
524        // reset.
525        assert_matches!(
526            run_handle_roam_trigger_data(&mut exec, &mut test_values.monitor, trigger_data.clone()),
527            RoamTriggerDataOutcome::RoamSearch { .. }
528        );
529    }
530
531    #[fuchsia::test]
532    fn test_rssi_drop_resets_backoff() {
533        let mut exec = fasync::TestExecutor::new_with_fake_time();
534        exec.set_fake_time(fasync::MonotonicInstant::now());
535
536        // Setup monitor with connection data that would trigger a roam scan due to RSSI below
537        // threshold. Set the EWMA weights to 1 so the values can be easily changed later in tests.
538        let rssi = LOCAL_ROAM_THRESHOLD_RSSI_5G - 1.0;
539        let connection_data = RoamingConnectionData {
540            signal_data: EwmaSignalData::new(rssi, TEST_OK_SNR, 1),
541            ..generate_random_roaming_connection_data()
542        };
543        let trigger_data =
544            RoamTriggerData::SignalReportInd(fidl_internal::SignalReportIndication {
545                rssi_dbm: rssi as i8,
546                snr_db: TEST_OK_SNR as i8,
547            });
548        let mut test_values = setup_test_with_data(connection_data);
549
550        // Run time forward past the minimum backoff time.
551        exec.set_fake_time(
552            fasync::MonotonicInstant::after(MIN_BACKOFF_BETWEEN_ROAM_SCANS)
553                + zx::MonotonicDuration::from_seconds(1),
554        );
555
556        // Send trigger data, and verify that we are told to roam search.
557        assert_matches!(
558            run_handle_roam_trigger_data(&mut exec, &mut test_values.monitor, trigger_data.clone()),
559            RoamTriggerDataOutcome::RoamSearch { .. }
560        );
561
562        // Run time forward past the minimum backoff time again.
563        exec.set_fake_time(
564            fasync::MonotonicInstant::after(MIN_BACKOFF_BETWEEN_ROAM_SCANS)
565                + zx::MonotonicDuration::from_seconds(1),
566        );
567
568        // Send trigger data, and verify that we do not roam scan, because the backoff has extended.
569        assert_matches!(
570            run_handle_roam_trigger_data(&mut exec, &mut test_values.monitor, trigger_data.clone()),
571            RoamTriggerDataOutcome::Noop
572        );
573
574        // Now send trigger data showing the RSSI has dropped significantly, and verify that we
575        // do now roam search because the backoff was reset to the minimum time.
576        let trigger_data =
577            RoamTriggerData::SignalReportInd(fidl_internal::SignalReportIndication {
578                rssi_dbm: (rssi - MIN_RSSI_DROP_TO_RESET_BACKOFF) as i8,
579                snr_db: TEST_OK_SNR as i8,
580            });
581        assert_matches!(
582            run_handle_roam_trigger_data(&mut exec, &mut test_values.monitor, trigger_data.clone()),
583            RoamTriggerDataOutcome::RoamSearch { .. }
584        );
585
586        // Run time forward time, but not past the absolute minimum backoff time .
587        exec.set_fake_time(
588            fasync::MonotonicInstant::after(MIN_BACKOFF_BETWEEN_ROAM_SCANS)
589                - zx::MonotonicDuration::from_seconds(1),
590        );
591        // Now send trigger data showing an _additional_ drop in RSSI, but verify that we do not
592        // not scan as the minimum backoff time has not passed.
593        assert_matches!(
594            run_handle_roam_trigger_data(&mut exec, &mut test_values.monitor, trigger_data.clone()),
595            RoamTriggerDataOutcome::Noop
596        );
597    }
598
599    #[fuchsia::test]
600    fn test_should_send_roam_request() {
601        let _exec = fasync::TestExecutor::new();
602        let test_values = setup_test();
603
604        // Get the randomized RSSI value.
605        let current_rssi = test_values.monitor.connection_data.signal_data.ewma_rssi.get();
606
607        // Verify that roam recommendations are blocked if RSSI is an insufficient improvement.
608        let candidate = types::ScannedCandidate {
609            bss: types::Bss {
610                signal: types::Signal {
611                    rssi_dbm: (current_rssi + MIN_RSSI_IMPROVEMENT_TO_ROAM - 1.0) as i8,
612                    snr_db: TEST_OK_SNR as i8,
613                },
614                ..generate_random_bss()
615            },
616            ..generate_random_scanned_candidate()
617        };
618        assert!(
619            !test_values
620                .monitor
621                .should_send_roam_request(PolicyRoamRequest { candidate, reasons: vec![] })
622                .expect("failed to check roam request")
623        );
624
625        // Verify that a roam recommendation is made if RSSI improvement exceeds threshold.
626        let candidate = types::ScannedCandidate {
627            bss: types::Bss {
628                signal: types::Signal {
629                    rssi_dbm: (current_rssi + MIN_RSSI_IMPROVEMENT_TO_ROAM) 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 roam recommendations are blocked if the selected candidate is the currently
644        // connected BSS. Set signal values high enough to isolate the dedupe function.
645        let candidate = types::ScannedCandidate {
646            bss: types::Bss {
647                signal: types::Signal {
648                    rssi_dbm: (current_rssi + MIN_RSSI_IMPROVEMENT_TO_ROAM + 1.0) as i8,
649                    snr_db: TEST_OK_SNR as i8,
650                },
651                bssid: test_values.monitor.connection_data.ap_state.original().bssid,
652                ..generate_random_bss()
653            },
654            credential: generate_random_password(),
655            ..generate_random_scanned_candidate()
656        };
657        assert!(
658            !test_values
659                .monitor
660                .should_send_roam_request(PolicyRoamRequest { candidate, reasons: vec![] })
661                .expect("failed to check roam reqeust")
662        );
663    }
664
665    #[fuchsia::test]
666    fn test_send_signal_velocity_metric_event() {
667        let mut exec = fasync::TestExecutor::new_with_fake_time();
668        exec.set_fake_time(fasync::MonotonicInstant::now());
669
670        let connection_data = RoamingConnectionData {
671            signal_data: EwmaSignalData::new(-40, 50, 1),
672            ..generate_random_roaming_connection_data()
673        };
674        let mut test_values = setup_test_with_data(connection_data);
675        test_values.saved_networks.set_is_single_bss_response(true);
676
677        let trigger_data =
678            RoamTriggerData::SignalReportInd(fidl_internal::SignalReportIndication {
679                rssi_dbm: -80,
680                snr_db: TEST_OK_SNR as i8,
681            });
682        let _ =
683            run_handle_roam_trigger_data(&mut exec, &mut test_values.monitor, trigger_data.clone());
684
685        assert_matches!(
686            test_values.telemetry_receiver.try_next(),
687            Ok(Some(TelemetryEvent::OnSignalVelocityUpdate { .. }))
688        );
689    }
690
691    #[fuchsia::test]
692    fn test_should_not_roam_scan_single_bss() {
693        let mut exec = fasync::TestExecutor::new_with_fake_time();
694        exec.set_fake_time(fasync::MonotonicInstant::now());
695
696        let rssi = -80;
697        let connection_data = RoamingConnectionData {
698            signal_data: EwmaSignalData::new(rssi, TEST_OK_SNR, 10),
699            ..generate_random_roaming_connection_data()
700        };
701        let mut test_values = setup_test_with_data(connection_data);
702
703        // Set the FakeSavedNetworks manager to report the network as single BSS
704        test_values.saved_networks.set_is_single_bss_response(true);
705
706        // Advance the time so that we allow roam scanning,
707        exec.set_fake_time(fasync::MonotonicInstant::after(fasync::MonotonicDuration::from_hours(
708            1,
709        )));
710
711        let trigger_data =
712            RoamTriggerData::SignalReportInd(fidl_internal::SignalReportIndication {
713                rssi_dbm: rssi,
714                snr_db: TEST_OK_SNR as i8,
715            });
716        let trigger_result =
717            run_handle_roam_trigger_data(&mut exec, &mut test_values.monitor, trigger_data.clone());
718
719        assert_eq!(trigger_result, RoamTriggerDataOutcome::Noop);
720    }
721
722    #[fuchsia::test]
723    fn test_roam_not_considered_if_attempted_too_many_times_today() {
724        let mut exec = fasync::TestExecutor::new_with_fake_time();
725        let first_roam_time = fasync::MonotonicInstant::now();
726        exec.set_fake_time(first_roam_time);
727
728        // Send a signal report that would trigger a roam scan if the limit were not hit.
729        let rssi = LOCAL_ROAM_THRESHOLD_RSSI_5G - 5.0;
730        let connection_data = RoamingConnectionData {
731            signal_data: EwmaSignalData::new(rssi, TEST_OK_SNR, 10),
732            ..generate_random_roaming_connection_data()
733        };
734        let mut test_values = setup_test_with_data(connection_data);
735
736        // Record enough roam attempts to prevent roaming for a while.
737        for _ in 0..NUM_MAX_ROAMS_PER_DAY {
738            test_values.past_roams.try_lock().unwrap().add(RoamEvent::new_roam_now());
739            exec.set_fake_time(fasync::MonotonicInstant::after(MAX_BACKOFF_BETWEEN_ROAM_SCANS));
740        }
741
742        let trigger_data =
743            RoamTriggerData::SignalReportInd(fidl_internal::SignalReportIndication {
744                rssi_dbm: rssi as i8,
745                snr_db: TEST_OK_SNR as i8,
746            });
747
748        exec.set_fake_time(fasync::MonotonicInstant::after(
749            MAX_BACKOFF_BETWEEN_ROAM_SCANS + zx::MonotonicDuration::from_seconds(1),
750        ));
751
752        // The limit on roams per day has been hit, no roam scan should be recommended.
753        let should_roam_scan_result =
754            run_handle_roam_trigger_data(&mut exec, &mut test_values.monitor, trigger_data.clone());
755        assert_eq!(should_roam_scan_result, RoamTriggerDataOutcome::Noop);
756
757        // Advance time to 24 hours past the first roam time. Roam scanning should now be allowed
758        // again.
759        exec.set_fake_time(
760            first_roam_time
761                + zx::MonotonicDuration::from_hours(24)
762                + zx::MonotonicDuration::from_seconds(1),
763        );
764        let should_roam_scan_result =
765            run_handle_roam_trigger_data(&mut exec, &mut test_values.monitor, trigger_data.clone());
766        assert_matches!(should_roam_scan_result, RoamTriggerDataOutcome::RoamSearch { .. });
767    }
768
769    #[fuchsia::test]
770    fn test_roaming_disabled_timer_is_set_and_respected() {
771        let mut exec = fasync::TestExecutor::new_with_fake_time();
772        let first_roam_time = fasync::MonotonicInstant::now();
773        exec.set_fake_time(first_roam_time);
774
775        // Setup with RSSIthat would trigger a roam scan if the limit were not hit.
776        let rssi = LOCAL_ROAM_THRESHOLD_RSSI_5G - 5.0;
777        let connection_data = RoamingConnectionData {
778            signal_data: EwmaSignalData::new(rssi, TEST_OK_SNR, 10),
779            ..generate_random_roaming_connection_data()
780        };
781        let mut test_values = setup_test_with_data(connection_data);
782
783        // Record enough roam attempts to prevent roaming for a while. The first roam attempt is
784        // at time 0, the second at 1 min, etc.
785        for i in 0..NUM_MAX_ROAMS_PER_DAY {
786            let time_of_roam = first_roam_time + zx::MonotonicDuration::from_minutes(i as i64);
787            exec.set_fake_time(time_of_roam);
788            test_values.past_roams.try_lock().unwrap().add(RoamEvent::new(time_of_roam));
789        }
790
791        let trigger_data =
792            RoamTriggerData::SignalReportInd(fidl_internal::SignalReportIndication {
793                rssi_dbm: rssi as i8,
794                snr_db: TEST_OK_SNR as i8,
795            });
796
797        // Advance time just enough to be after the last roam event.
798        exec.set_fake_time(fasync::MonotonicInstant::after(zx::MonotonicDuration::from_minutes(
799            NUM_MAX_ROAMS_PER_DAY as i64,
800        )));
801
802        // The limit on roams per day has been hit, no roam scan should be recommended.
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        // Check that the `next_roaming_enabled_time` was set correctly. It should be 24 hours
808        // after the *first* roam attempt.
809        let expected_reenabled_time = first_roam_time + TIMESPAN_TO_LIMIT_SCANS;
810        assert_eq!(test_values.monitor.next_roaming_enabled_time, expected_reenabled_time);
811
812        // Advance time to just BEFORE the re-enable time. Roaming should still be disabled.
813        exec.set_fake_time(expected_reenabled_time - zx::MonotonicDuration::from_seconds(1));
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        // Advance time to just AFTER the re-enable time. Roaming should now be allowed.
819        exec.set_fake_time(expected_reenabled_time + zx::MonotonicDuration::from_seconds(1));
820        let should_roam_scan_result =
821            run_handle_roam_trigger_data(&mut exec, &mut test_values.monitor, trigger_data.clone());
822        assert_matches!(should_roam_scan_result, RoamTriggerDataOutcome::RoamSearch { .. });
823    }
824}