Skip to main content

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