wlancfg_lib/client/connection_selection/
bss_selection.rs

1// Copyright 2023 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::connection_selection::scoring_functions;
6use crate::client::types;
7use crate::telemetry::{TelemetryEvent, TelemetrySender};
8use fuchsia_inspect_auto_persist::AutoPersist;
9use fuchsia_inspect_contrib::inspect_log;
10use fuchsia_inspect_contrib::log::InspectList;
11use fuchsia_inspect_contrib::nodes::BoundedListNode as InspectBoundedListNode;
12use futures::lock::Mutex;
13use log::{error, info};
14use std::cmp::Reverse;
15use std::sync::Arc;
16
17/// BSS selection. Selects the best from a list of candidates that are available for
18/// connection.
19pub async fn select_bss(
20    allowed_candidate_list: Vec<types::ScannedCandidate>,
21    reason: types::ConnectReason,
22    inspect_node: Arc<Mutex<AutoPersist<InspectBoundedListNode>>>,
23    telemetry_sender: TelemetrySender,
24) -> Option<types::ScannedCandidate> {
25    if allowed_candidate_list.is_empty() {
26        info!("No BSSs available to select from.");
27    } else {
28        info!("Selecting from {} BSSs found for allowed networks", allowed_candidate_list.len());
29    }
30
31    let mut inspect_node = inspect_node.lock().await;
32
33    let mut scored_candidates = allowed_candidate_list
34        .iter()
35        .inspect(|&candidate| {
36            info!("{}", candidate.to_string_without_pii());
37        })
38        .filter(|&candidate| {
39            // Note: this check is redundant, because all ScannedCandidates have an authenticator,
40            // and only BSSs with compatibility can have an authenticator. It would be unexpected
41            // to ever hit this branch, unless logic changes elsewhere.
42            if !candidate.bss.is_compatible() {
43                error!("BSS is unexpectedly incompatible: {}", candidate.to_string_without_pii());
44                false
45            } else {
46                true
47            }
48        })
49        .map(|candidate| {
50            (candidate.clone(), scoring_functions::score_bss_scanned_candidate(candidate.clone()))
51        })
52        .collect::<Vec<(types::ScannedCandidate, i16)>>();
53
54    scored_candidates.sort_by_key(|(_, score)| Reverse(*score));
55    let selected_candidate = scored_candidates.first();
56
57    // Log the candidates into Inspect
58    inspect_log!(
59        inspect_node.get_mut(),
60        candidates: InspectList(&allowed_candidate_list),
61        selected?: selected_candidate.map(|(candidate, _)| candidate)
62    );
63
64    telemetry_sender.send(TelemetryEvent::BssSelectionResult {
65        reason,
66        scored_candidates: scored_candidates.clone(),
67        selected_candidate: selected_candidate.cloned(),
68    });
69
70    if let Some((candidate, _)) = selected_candidate {
71        info!("Selected BSS:");
72        info!("{}", candidate.to_string_without_pii());
73        Some(candidate.clone())
74    } else {
75        None
76    }
77}
78
79#[cfg(test)]
80mod test {
81    use super::*;
82    use crate::config_management::{ConnectFailure, FailureReason};
83    use crate::util::testing::{
84        create_inspect_persistence_channel, generate_channel,
85        generate_random_bss_with_compatibility, generate_random_connect_reason,
86        generate_random_scanned_candidate,
87    };
88    use assert_matches::assert_matches;
89    use diagnostics_assertions::{
90        AnyBoolProperty, AnyNumericProperty, AnyProperty, AnyStringProperty, assert_data_tree,
91    };
92    use futures::channel::mpsc;
93    use ieee80211_testutils::{BSSID_REGEX, SSID_REGEX};
94    use rand::Rng;
95    use wlan_common::random_fidl_bss_description;
96    use wlan_common::scan::Incompatible;
97    use {
98        fidl_fuchsia_wlan_common as fidl_common, fuchsia_async as fasync,
99        fuchsia_inspect as inspect,
100    };
101
102    struct TestValues {
103        inspector: inspect::Inspector,
104        inspect_node: Arc<Mutex<AutoPersist<InspectBoundedListNode>>>,
105        telemetry_sender: TelemetrySender,
106        telemetry_receiver: mpsc::Receiver<TelemetryEvent>,
107    }
108
109    fn test_setup() -> TestValues {
110        let inspector = inspect::Inspector::default();
111        let inspect_node =
112            InspectBoundedListNode::new(inspector.root().create_child("bss_select_test"), 10);
113        let (persistence_req_sender, _persistence_stream) = create_inspect_persistence_channel();
114        let inspect_node = AutoPersist::new(inspect_node, "test", persistence_req_sender);
115        let (telemetry_sender, telemetry_receiver) = mpsc::channel::<TelemetryEvent>(100);
116
117        TestValues {
118            inspector,
119            inspect_node: Arc::new(Mutex::new(inspect_node)),
120            telemetry_sender: TelemetrySender::new(telemetry_sender),
121            telemetry_receiver,
122        }
123    }
124
125    fn generate_candidate_for_scoring(
126        rssi: i8,
127        snr_db: i8,
128        channel: types::WlanChan,
129    ) -> types::ScannedCandidate {
130        let bss = types::Bss {
131            signal: types::Signal { rssi_dbm: rssi, snr_db },
132            channel,
133            bss_description: fidl_common::BssDescription {
134                rssi_dbm: rssi,
135                snr_db,
136                channel: channel.into(),
137                ..random_fidl_bss_description!()
138            }
139            .into(),
140            ..generate_random_bss_with_compatibility()
141        };
142        types::ScannedCandidate { bss, ..generate_random_scanned_candidate() }
143    }
144
145    fn connect_failure_with_bssid(bssid: types::Bssid) -> ConnectFailure {
146        ConnectFailure {
147            reason: FailureReason::GeneralFailure,
148            time: fasync::MonotonicInstant::INFINITE,
149            bssid,
150        }
151    }
152
153    #[allow(clippy::vec_init_then_push, reason = "mass allow for https://fxbug.dev/381896734")]
154    #[fuchsia::test]
155    fn select_bss_sorts_by_score() {
156        let mut exec = fasync::TestExecutor::new();
157        let test_values = test_setup();
158        let mut candidates = vec![];
159
160        candidates.push(generate_candidate_for_scoring(-35, 30, generate_channel(36)));
161        candidates.push(generate_candidate_for_scoring(-30, 30, generate_channel(1)));
162
163        // there's a network on 5G, it should get a boost and be selected
164        let reason = generate_random_connect_reason();
165        assert_eq!(
166            exec.run_singlethreaded(select_bss(
167                candidates.clone(),
168                reason,
169                test_values.inspect_node.clone(),
170                test_values.telemetry_sender.clone()
171            )),
172            Some(candidates[0].clone())
173        );
174
175        // make the 5GHz network into a 2.4GHz network
176        let mut modified_network = candidates[0].clone();
177        let modified_bss =
178            types::Bss { channel: generate_channel(6), ..modified_network.bss.clone() };
179        modified_network.bss = modified_bss;
180        candidates[0] = modified_network;
181
182        // all networks are 2.4GHz, strongest RSSI network returned
183        assert_eq!(
184            exec.run_singlethreaded(select_bss(
185                candidates.clone(),
186                reason,
187                test_values.inspect_node.clone(),
188                test_values.telemetry_sender.clone()
189            )),
190            Some(candidates[1].clone())
191        );
192    }
193
194    #[allow(clippy::vec_init_then_push, reason = "mass allow for https://fxbug.dev/381896734")]
195    #[fuchsia::test]
196    fn select_bss_sorts_by_failure_count() {
197        let mut exec = fasync::TestExecutor::new();
198        let test_values = test_setup();
199        let mut candidates = vec![];
200
201        candidates.push(generate_candidate_for_scoring(-30, 30, generate_channel(1)));
202        candidates.push(generate_candidate_for_scoring(-35, 30, generate_channel(1)));
203
204        // stronger network returned
205        assert_eq!(
206            exec.run_singlethreaded(select_bss(
207                candidates.clone(),
208                generate_random_connect_reason(),
209                test_values.inspect_node.clone(),
210                test_values.telemetry_sender.clone()
211            )),
212            Some(candidates[0].clone()),
213        );
214
215        // mark the stronger network as having some failures
216        let num_failures = 4;
217        candidates[0].saved_network_info.recent_failures =
218            vec![connect_failure_with_bssid(candidates[0].bss.bssid); num_failures];
219
220        // weaker network (with no failures) returned
221        assert_eq!(
222            exec.run_singlethreaded(select_bss(
223                candidates.clone(),
224                generate_random_connect_reason(),
225                test_values.inspect_node.clone(),
226                test_values.telemetry_sender.clone()
227            )),
228            Some(candidates[1].clone())
229        );
230
231        // give them both the same number of failures
232        candidates[1].saved_network_info.recent_failures =
233            vec![connect_failure_with_bssid(candidates[1].bss.bssid); num_failures];
234
235        // stronger network returned
236        assert_eq!(
237            exec.run_singlethreaded(select_bss(
238                candidates.clone(),
239                generate_random_connect_reason(),
240                test_values.inspect_node.clone(),
241                test_values.telemetry_sender.clone()
242            )),
243            Some(candidates[0].clone())
244        );
245    }
246
247    #[allow(clippy::vec_init_then_push, reason = "mass allow for https://fxbug.dev/381896734")]
248    #[fuchsia::test]
249    fn select_bss_ignore_incompatible() {
250        let mut exec = fasync::TestExecutor::new();
251        let test_values = test_setup();
252        let mut candidates = vec![];
253
254        // Add two BSSs, both compatible to start.
255        candidates.push(generate_candidate_for_scoring(-14, 30, generate_channel(1)));
256        candidates.push(generate_candidate_for_scoring(-90, 30, generate_channel(1)));
257
258        // The stronger BSS is selected initially.
259        assert_eq!(
260            exec.run_singlethreaded(select_bss(
261                candidates.clone(),
262                generate_random_connect_reason(),
263                test_values.inspect_node.clone(),
264                test_values.telemetry_sender.clone()
265            )),
266            Some(candidates[0].clone())
267        );
268
269        // Make the stronger BSS incompatible.
270        candidates[0].bss.compatibility = Incompatible::unknown();
271
272        // The weaker, but still compatible, BSS is selected.
273        assert_eq!(
274            exec.run_singlethreaded(select_bss(
275                candidates.clone(),
276                generate_random_connect_reason(),
277                test_values.inspect_node.clone(),
278                test_values.telemetry_sender.clone()
279            )),
280            Some(candidates[1].clone())
281        );
282
283        // TODO(https://fxbug.dev/42071595): After `select_bss` filters out incompatible BSSs, this None
284        // compatibility should change to a Some, to test that logic.
285        // Make both BSSs incompatible.
286        candidates[1].bss.compatibility = Incompatible::unknown();
287
288        // No BSS is selected.
289        assert_eq!(
290            exec.run_singlethreaded(select_bss(
291                candidates.clone(),
292                generate_random_connect_reason(),
293                test_values.inspect_node.clone(),
294                test_values.telemetry_sender.clone()
295            )),
296            None
297        );
298    }
299
300    #[allow(clippy::vec_init_then_push, reason = "mass allow for https://fxbug.dev/381896734")]
301    #[fuchsia::test]
302    fn select_bss_logs_to_inspect() {
303        let mut exec = fasync::TestExecutor::new();
304        let test_values = test_setup();
305        let mut candidates = vec![];
306
307        candidates.push(generate_candidate_for_scoring(-50, 30, generate_channel(1)));
308        candidates.push(generate_candidate_for_scoring(-60, 30, generate_channel(3)));
309        candidates.push(generate_candidate_for_scoring(-30, 30, generate_channel(6)));
310
311        // stronger network returned
312        assert_eq!(
313            exec.run_singlethreaded(select_bss(
314                candidates.clone(),
315                generate_random_connect_reason(),
316                test_values.inspect_node.clone(),
317                test_values.telemetry_sender.clone()
318            )),
319            Some(candidates[2].clone())
320        );
321
322        assert_data_tree!(@executor exec, test_values.inspector, root: {
323            bss_select_test: {
324                "0": {
325                    "@time": AnyNumericProperty,
326                    "candidates": {
327                        "0": contains {
328                            score: AnyNumericProperty,
329                            bssid: &*BSSID_REGEX,
330                            ssid: &*SSID_REGEX,
331                            rssi: AnyNumericProperty,
332                            security_type_saved: AnyStringProperty,
333                            security_type_scanned: AnyStringProperty,
334                            channel: AnyStringProperty,
335                            compatible: AnyBoolProperty,
336                            incompatibility: AnyStringProperty,
337                            recent_failure_count: AnyNumericProperty,
338                            saved_network_has_ever_connected: AnyBoolProperty,
339                        },
340                        "1": contains {
341                            score: AnyProperty,
342                        },
343                        "2": contains {
344                            score: AnyProperty,
345                        },
346                    },
347                    "selected": {
348                        ssid: candidates[2].network.ssid.to_string(),
349                        bssid: candidates[2].bss.bssid.to_string(),
350                        rssi: i64::from(candidates[2].bss.signal.rssi_dbm),
351                        score: i64::from(scoring_functions::score_bss_scanned_candidate(candidates[2].clone())),
352                        security_type_saved: candidates[2].saved_security_type_to_string(),
353                        security_type_scanned: format!("{}", wlan_common::bss::Protection::from(candidates[2].security_type_detailed)),
354                        channel: AnyStringProperty,
355                        compatible: candidates[2].bss.is_compatible(),
356                        incompatibility: AnyStringProperty,
357                        recent_failure_count: candidates[2].recent_failure_count(),
358                        saved_network_has_ever_connected: candidates[2].saved_network_info.has_ever_connected,
359                    },
360                }
361            },
362        });
363    }
364
365    #[fuchsia::test]
366    fn select_bss_empty_list_logs_to_inspect() {
367        let mut exec = fasync::TestExecutor::new();
368        let test_values = test_setup();
369        assert_eq!(
370            exec.run_singlethreaded(select_bss(
371                vec![],
372                generate_random_connect_reason(),
373                test_values.inspect_node.clone(),
374                test_values.telemetry_sender.clone()
375            )),
376            None
377        );
378
379        // Verify that an empty list of candidates is still logged to inspect.
380        assert_data_tree!(@executor exec, test_values.inspector, root: {
381            bss_select_test: {
382                "0": {
383                    "@time": AnyProperty,
384                    "candidates": {},
385                }
386            },
387        });
388    }
389
390    #[fuchsia::test]
391    fn select_bss_logs_cobalt_metrics() {
392        let mut exec = fasync::TestExecutor::new();
393        let mut test_values = test_setup();
394
395        let reason_code = generate_random_connect_reason();
396        let candidates =
397            vec![generate_random_scanned_candidate(), generate_random_scanned_candidate()];
398        assert!(
399            exec.run_singlethreaded(select_bss(
400                candidates.clone(),
401                reason_code,
402                test_values.inspect_node.clone(),
403                test_values.telemetry_sender.clone()
404            ))
405            .is_some()
406        );
407
408        assert_matches!(test_values.telemetry_receiver.try_next(), Ok(Some(event)) => {
409            assert_matches!(event, TelemetryEvent::BssSelectionResult {
410                reason,
411                scored_candidates,
412                selected_candidate: _,
413            } => {
414                assert_eq!(reason, reason_code);
415                let mut prior_score = i16::MAX;
416                for (candidate, score) in scored_candidates {
417                    assert!(candidates.contains(&candidate));
418                    assert!(prior_score >= score);
419                    prior_score = score;
420                }
421            })
422        });
423    }
424}