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 futures::channel::mpsc;
91    use ieee80211_testutils::{BSSID_REGEX, SSID_REGEX};
92    use rand::Rng;
93    use wlan_common::random_fidl_bss_description;
94    use wlan_common::scan::Incompatible;
95    use {
96        fidl_fuchsia_wlan_common as fidl_common, fuchsia_async as fasync,
97        fuchsia_inspect as inspect,
98    };
99
100    struct TestValues {
101        inspector: inspect::Inspector,
102        inspect_node: Arc<Mutex<InspectBoundedListNode>>,
103        telemetry_sender: TelemetrySender,
104        telemetry_receiver: mpsc::Receiver<TelemetryEvent>,
105    }
106
107    fn test_setup() -> TestValues {
108        let inspector = inspect::Inspector::default();
109        let inspect_node =
110            InspectBoundedListNode::new(inspector.root().create_child("bss_select_test"), 10);
111        let (telemetry_sender, telemetry_receiver) = mpsc::channel::<TelemetryEvent>(100);
112
113        TestValues {
114            inspector,
115            inspect_node: Arc::new(Mutex::new(inspect_node)),
116            telemetry_sender: TelemetrySender::new(telemetry_sender),
117            telemetry_receiver,
118        }
119    }
120
121    fn generate_candidate_for_scoring(
122        rssi: i8,
123        snr_db: i8,
124        channel: types::WlanChan,
125    ) -> types::ScannedCandidate {
126        let bss = types::Bss {
127            signal: types::Signal { rssi_dbm: rssi, snr_db },
128            channel,
129            bss_description: fidl_common::BssDescription {
130                rssi_dbm: rssi,
131                snr_db,
132                channel: channel.into(),
133                ..random_fidl_bss_description!()
134            }
135            .into(),
136            ..generate_random_bss_with_compatibility()
137        };
138        types::ScannedCandidate { bss, ..generate_random_scanned_candidate() }
139    }
140
141    fn connect_failure_with_bssid(bssid: types::Bssid) -> ConnectFailure {
142        ConnectFailure {
143            reason: FailureReason::GeneralFailure,
144            time: fasync::MonotonicInstant::INFINITE,
145            bssid,
146        }
147    }
148
149    #[allow(clippy::vec_init_then_push, reason = "mass allow for https://fxbug.dev/381896734")]
150    #[fuchsia::test]
151    fn select_bss_sorts_by_score() {
152        let mut exec = fasync::TestExecutor::new();
153        let test_values = test_setup();
154        let mut candidates = vec![];
155
156        candidates.push(generate_candidate_for_scoring(-35, 30, generate_channel(36)));
157        candidates.push(generate_candidate_for_scoring(-30, 30, generate_channel(1)));
158
159        // there's a network on 5G, it should get a boost and be selected
160        let reason = generate_random_connect_reason();
161        assert_eq!(
162            exec.run_singlethreaded(select_bss(
163                candidates.clone(),
164                reason,
165                test_values.inspect_node.clone(),
166                test_values.telemetry_sender.clone()
167            )),
168            Some(candidates[0].clone())
169        );
170
171        // make the 5GHz network into a 2.4GHz network
172        let mut modified_network = candidates[0].clone();
173        let modified_bss =
174            types::Bss { channel: generate_channel(6), ..modified_network.bss.clone() };
175        modified_network.bss = modified_bss;
176        candidates[0] = modified_network;
177
178        // all networks are 2.4GHz, strongest RSSI network returned
179        assert_eq!(
180            exec.run_singlethreaded(select_bss(
181                candidates.clone(),
182                reason,
183                test_values.inspect_node.clone(),
184                test_values.telemetry_sender.clone()
185            )),
186            Some(candidates[1].clone())
187        );
188    }
189
190    #[allow(clippy::vec_init_then_push, reason = "mass allow for https://fxbug.dev/381896734")]
191    #[fuchsia::test]
192    fn select_bss_sorts_by_failure_count() {
193        let mut exec = fasync::TestExecutor::new();
194        let test_values = test_setup();
195        let mut candidates = vec![];
196
197        candidates.push(generate_candidate_for_scoring(-30, 30, generate_channel(1)));
198        candidates.push(generate_candidate_for_scoring(-35, 30, generate_channel(1)));
199
200        // stronger network returned
201        assert_eq!(
202            exec.run_singlethreaded(select_bss(
203                candidates.clone(),
204                generate_random_connect_reason(),
205                test_values.inspect_node.clone(),
206                test_values.telemetry_sender.clone()
207            )),
208            Some(candidates[0].clone()),
209        );
210
211        // mark the stronger network as having some failures
212        let num_failures = 4;
213        candidates[0].saved_network_info.recent_failures =
214            vec![connect_failure_with_bssid(candidates[0].bss.bssid); num_failures];
215
216        // weaker network (with no failures) returned
217        assert_eq!(
218            exec.run_singlethreaded(select_bss(
219                candidates.clone(),
220                generate_random_connect_reason(),
221                test_values.inspect_node.clone(),
222                test_values.telemetry_sender.clone()
223            )),
224            Some(candidates[1].clone())
225        );
226
227        // give them both the same number of failures
228        candidates[1].saved_network_info.recent_failures =
229            vec![connect_failure_with_bssid(candidates[1].bss.bssid); num_failures];
230
231        // stronger network returned
232        assert_eq!(
233            exec.run_singlethreaded(select_bss(
234                candidates.clone(),
235                generate_random_connect_reason(),
236                test_values.inspect_node.clone(),
237                test_values.telemetry_sender.clone()
238            )),
239            Some(candidates[0].clone())
240        );
241    }
242
243    #[allow(clippy::vec_init_then_push, reason = "mass allow for https://fxbug.dev/381896734")]
244    #[fuchsia::test]
245    fn select_bss_ignore_incompatible() {
246        let mut exec = fasync::TestExecutor::new();
247        let test_values = test_setup();
248        let mut candidates = vec![];
249
250        // Add two BSSs, both compatible to start.
251        candidates.push(generate_candidate_for_scoring(-14, 30, generate_channel(1)));
252        candidates.push(generate_candidate_for_scoring(-90, 30, generate_channel(1)));
253
254        // The stronger BSS is selected initially.
255        assert_eq!(
256            exec.run_singlethreaded(select_bss(
257                candidates.clone(),
258                generate_random_connect_reason(),
259                test_values.inspect_node.clone(),
260                test_values.telemetry_sender.clone()
261            )),
262            Some(candidates[0].clone())
263        );
264
265        // Make the stronger BSS incompatible.
266        candidates[0].bss.compatibility = Incompatible::unknown();
267
268        // The weaker, but still compatible, BSS is selected.
269        assert_eq!(
270            exec.run_singlethreaded(select_bss(
271                candidates.clone(),
272                generate_random_connect_reason(),
273                test_values.inspect_node.clone(),
274                test_values.telemetry_sender.clone()
275            )),
276            Some(candidates[1].clone())
277        );
278
279        // TODO(https://fxbug.dev/42071595): After `select_bss` filters out incompatible BSSs, this None
280        // compatibility should change to a Some, to test that logic.
281        // Make both BSSs incompatible.
282        candidates[1].bss.compatibility = Incompatible::unknown();
283
284        // No BSS is selected.
285        assert_eq!(
286            exec.run_singlethreaded(select_bss(
287                candidates.clone(),
288                generate_random_connect_reason(),
289                test_values.inspect_node.clone(),
290                test_values.telemetry_sender.clone()
291            )),
292            None
293        );
294    }
295
296    #[allow(clippy::vec_init_then_push, reason = "mass allow for https://fxbug.dev/381896734")]
297    #[fuchsia::test]
298    fn select_bss_logs_to_inspect() {
299        let mut exec = fasync::TestExecutor::new();
300        let test_values = test_setup();
301        let mut candidates = vec![];
302
303        candidates.push(generate_candidate_for_scoring(-50, 30, generate_channel(1)));
304        candidates.push(generate_candidate_for_scoring(-60, 30, generate_channel(3)));
305        candidates.push(generate_candidate_for_scoring(-30, 30, generate_channel(6)));
306
307        // stronger network returned
308        assert_eq!(
309            exec.run_singlethreaded(select_bss(
310                candidates.clone(),
311                generate_random_connect_reason(),
312                test_values.inspect_node.clone(),
313                test_values.telemetry_sender.clone()
314            )),
315            Some(candidates[2].clone())
316        );
317
318        assert_data_tree!(@executor exec, test_values.inspector, root: {
319            bss_select_test: {
320                "0": {
321                    "@time": AnyNumericProperty,
322                    "candidates": {
323                        "0": contains {
324                            score: AnyNumericProperty,
325                            bssid: &*BSSID_REGEX,
326                            ssid: &*SSID_REGEX,
327                            rssi: AnyNumericProperty,
328                            security_type_saved: AnyStringProperty,
329                            security_type_scanned: AnyStringProperty,
330                            channel: AnyStringProperty,
331                            compatible: AnyBoolProperty,
332                            incompatibility: AnyStringProperty,
333                            recent_failure_count: AnyNumericProperty,
334                            saved_network_has_ever_connected: AnyBoolProperty,
335                        },
336                        "1": contains {
337                            score: AnyProperty,
338                        },
339                        "2": contains {
340                            score: AnyProperty,
341                        },
342                    },
343                    "selected": {
344                        ssid: candidates[2].network.ssid.to_string(),
345                        bssid: candidates[2].bss.bssid.to_string(),
346                        rssi: i64::from(candidates[2].bss.signal.rssi_dbm),
347                        score: i64::from(scoring_functions::score_bss_scanned_candidate(candidates[2].clone())),
348                        security_type_saved: candidates[2].saved_security_type_to_string(),
349                        security_type_scanned: format!("{}", wlan_common::bss::Protection::from(candidates[2].security_type_detailed)),
350                        channel: AnyStringProperty,
351                        compatible: candidates[2].bss.is_compatible(),
352                        incompatibility: AnyStringProperty,
353                        recent_failure_count: candidates[2].recent_failure_count(),
354                        saved_network_has_ever_connected: candidates[2].saved_network_info.has_ever_connected,
355                    },
356                }
357            },
358        });
359    }
360
361    #[fuchsia::test]
362    fn select_bss_empty_list_logs_to_inspect() {
363        let mut exec = fasync::TestExecutor::new();
364        let test_values = test_setup();
365        assert_eq!(
366            exec.run_singlethreaded(select_bss(
367                vec![],
368                generate_random_connect_reason(),
369                test_values.inspect_node.clone(),
370                test_values.telemetry_sender.clone()
371            )),
372            None
373        );
374
375        // Verify that an empty list of candidates is still logged to inspect.
376        assert_data_tree!(@executor exec, test_values.inspector, root: {
377            bss_select_test: {
378                "0": {
379                    "@time": AnyProperty,
380                    "candidates": {},
381                }
382            },
383        });
384    }
385
386    #[fuchsia::test]
387    fn select_bss_logs_cobalt_metrics() {
388        let mut exec = fasync::TestExecutor::new();
389        let mut test_values = test_setup();
390
391        let reason_code = generate_random_connect_reason();
392        let candidates =
393            vec![generate_random_scanned_candidate(), generate_random_scanned_candidate()];
394        assert!(
395            exec.run_singlethreaded(select_bss(
396                candidates.clone(),
397                reason_code,
398                test_values.inspect_node.clone(),
399                test_values.telemetry_sender.clone()
400            ))
401            .is_some()
402        );
403
404        assert_matches!(test_values.telemetry_receiver.try_next(), Ok(Some(event)) => {
405            assert_matches!(event, TelemetryEvent::BssSelectionResult {
406                reason,
407                scored_candidates,
408                selected_candidate: _,
409            } => {
410                assert_eq!(reason, reason_code);
411                let mut prior_score = i16::MAX;
412                for (candidate, score) in scored_candidates {
413                    assert!(candidates.contains(&candidate));
414                    assert!(prior_score >= score);
415                    prior_score = score;
416                }
417            })
418        });
419    }
420}