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                primary: 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(
155            -35,
156            30,
157            generate_channel(36, fidl_fuchsia_wlan_ieee80211::WlanBand::FiveGhz),
158        ));
159        candidates.push(generate_candidate_for_scoring(
160            -30,
161            30,
162            generate_channel(1, fidl_fuchsia_wlan_ieee80211::WlanBand::TwoGhz),
163        ));
164
165        // there's a network on 5G, it should get a boost and be selected
166        let reason = generate_random_connect_reason();
167        assert_eq!(
168            exec.run_singlethreaded(select_bss(
169                candidates.clone(),
170                reason,
171                test_values.inspect_node.clone(),
172                test_values.telemetry_sender.clone()
173            )),
174            Some(candidates[0].clone())
175        );
176
177        // make the 5GHz network into a 2.4GHz network
178        let mut modified_network = candidates[0].clone();
179        let modified_bss = types::Bss {
180            channel: generate_channel(6, fidl_fuchsia_wlan_ieee80211::WlanBand::TwoGhz),
181            ..modified_network.bss.clone()
182        };
183        modified_network.bss = modified_bss;
184        candidates[0] = modified_network;
185
186        // all networks are 2.4GHz, strongest RSSI network returned
187        assert_eq!(
188            exec.run_singlethreaded(select_bss(
189                candidates.clone(),
190                reason,
191                test_values.inspect_node.clone(),
192                test_values.telemetry_sender.clone()
193            )),
194            Some(candidates[1].clone())
195        );
196    }
197
198    #[allow(clippy::vec_init_then_push, reason = "mass allow for https://fxbug.dev/381896734")]
199    #[fuchsia::test]
200    fn select_bss_sorts_by_failure_count() {
201        let mut exec = fasync::TestExecutor::new();
202        let test_values = test_setup();
203        let mut candidates = vec![];
204
205        candidates.push(generate_candidate_for_scoring(
206            -30,
207            30,
208            generate_channel(1, fidl_fuchsia_wlan_ieee80211::WlanBand::TwoGhz),
209        ));
210        candidates.push(generate_candidate_for_scoring(
211            -35,
212            30,
213            generate_channel(1, fidl_fuchsia_wlan_ieee80211::WlanBand::TwoGhz),
214        ));
215
216        // stronger network 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[0].clone()),
225        );
226
227        // mark the stronger network as having some failures
228        let num_failures = 4;
229        candidates[0].saved_network_info.recent_failures =
230            vec![connect_failure_with_bssid(candidates[0].bss.bssid); num_failures];
231
232        // weaker network (with no failures) returned
233        assert_eq!(
234            exec.run_singlethreaded(select_bss(
235                candidates.clone(),
236                generate_random_connect_reason(),
237                test_values.inspect_node.clone(),
238                test_values.telemetry_sender.clone()
239            )),
240            Some(candidates[1].clone())
241        );
242
243        // give them both the same number of failures
244        candidates[1].saved_network_info.recent_failures =
245            vec![connect_failure_with_bssid(candidates[1].bss.bssid); num_failures];
246
247        // stronger network returned
248        assert_eq!(
249            exec.run_singlethreaded(select_bss(
250                candidates.clone(),
251                generate_random_connect_reason(),
252                test_values.inspect_node.clone(),
253                test_values.telemetry_sender.clone()
254            )),
255            Some(candidates[0].clone())
256        );
257    }
258
259    #[allow(clippy::vec_init_then_push, reason = "mass allow for https://fxbug.dev/381896734")]
260    #[fuchsia::test]
261    fn select_bss_ignore_incompatible() {
262        let mut exec = fasync::TestExecutor::new();
263        let test_values = test_setup();
264        let mut candidates = vec![];
265
266        // Add two BSSs, both compatible to start.
267        candidates.push(generate_candidate_for_scoring(
268            -14,
269            30,
270            generate_channel(1, fidl_fuchsia_wlan_ieee80211::WlanBand::TwoGhz),
271        ));
272        candidates.push(generate_candidate_for_scoring(
273            -90,
274            30,
275            generate_channel(1, fidl_fuchsia_wlan_ieee80211::WlanBand::TwoGhz),
276        ));
277
278        // The stronger BSS is selected initially.
279        assert_eq!(
280            exec.run_singlethreaded(select_bss(
281                candidates.clone(),
282                generate_random_connect_reason(),
283                test_values.inspect_node.clone(),
284                test_values.telemetry_sender.clone()
285            )),
286            Some(candidates[0].clone())
287        );
288
289        // Make the stronger BSS incompatible.
290        candidates[0].bss.compatibility = Incompatible::unknown();
291
292        // The weaker, but still compatible, BSS is selected.
293        assert_eq!(
294            exec.run_singlethreaded(select_bss(
295                candidates.clone(),
296                generate_random_connect_reason(),
297                test_values.inspect_node.clone(),
298                test_values.telemetry_sender.clone()
299            )),
300            Some(candidates[1].clone())
301        );
302
303        // TODO(https://fxbug.dev/42071595): After `select_bss` filters out incompatible BSSs, this None
304        // compatibility should change to a Some, to test that logic.
305        // Make both BSSs incompatible.
306        candidates[1].bss.compatibility = Incompatible::unknown();
307
308        // No BSS is selected.
309        assert_eq!(
310            exec.run_singlethreaded(select_bss(
311                candidates.clone(),
312                generate_random_connect_reason(),
313                test_values.inspect_node.clone(),
314                test_values.telemetry_sender.clone()
315            )),
316            None
317        );
318    }
319
320    #[allow(clippy::vec_init_then_push, reason = "mass allow for https://fxbug.dev/381896734")]
321    #[fuchsia::test]
322    fn select_bss_logs_to_inspect() {
323        let mut exec = fasync::TestExecutor::new();
324        let test_values = test_setup();
325        let mut candidates = vec![];
326
327        candidates.push(generate_candidate_for_scoring(
328            -50,
329            30,
330            generate_channel(1, fidl_fuchsia_wlan_ieee80211::WlanBand::TwoGhz),
331        ));
332        candidates.push(generate_candidate_for_scoring(
333            -60,
334            30,
335            generate_channel(3, fidl_fuchsia_wlan_ieee80211::WlanBand::TwoGhz),
336        ));
337        candidates.push(generate_candidate_for_scoring(
338            -30,
339            30,
340            generate_channel(6, fidl_fuchsia_wlan_ieee80211::WlanBand::TwoGhz),
341        ));
342
343        // stronger network returned
344        assert_eq!(
345            exec.run_singlethreaded(select_bss(
346                candidates.clone(),
347                generate_random_connect_reason(),
348                test_values.inspect_node.clone(),
349                test_values.telemetry_sender.clone()
350            )),
351            Some(candidates[2].clone())
352        );
353
354        assert_data_tree!(@executor exec, test_values.inspector, root: {
355            bss_select_test: {
356                "0": {
357                    "@time": AnyNumericProperty,
358                    "candidates": {
359                        "0": contains {
360                            score: AnyNumericProperty,
361                            bssid: &*BSSID_REGEX,
362                            ssid: &*SSID_REGEX,
363                            rssi: AnyNumericProperty,
364                            security_type_saved: AnyStringProperty,
365                            security_type_scanned: AnyStringProperty,
366                            channel: AnyStringProperty,
367                            compatible: AnyBoolProperty,
368                            incompatibility: AnyStringProperty,
369                            recent_failure_count: AnyNumericProperty,
370                            saved_network_has_ever_connected: AnyBoolProperty,
371                        },
372                        "1": contains {
373                            score: AnyProperty,
374                        },
375                        "2": contains {
376                            score: AnyProperty,
377                        },
378                    },
379                    "selected": {
380                        ssid: candidates[2].network.ssid.to_string(),
381                        bssid: candidates[2].bss.bssid.to_string(),
382                        rssi: i64::from(candidates[2].bss.signal.rssi_dbm),
383                        score: i64::from(scoring_functions::score_bss_scanned_candidate(candidates[2].clone())),
384                        security_type_saved: candidates[2].saved_security_type_to_string(),
385                        security_type_scanned: format!("{}", wlan_common::bss::Protection::from(candidates[2].security_type_detailed)),
386                        channel: AnyStringProperty,
387                        compatible: candidates[2].bss.is_compatible(),
388                        incompatibility: AnyStringProperty,
389                        recent_failure_count: candidates[2].recent_failure_count(),
390                        saved_network_has_ever_connected: candidates[2].saved_network_info.has_ever_connected,
391                    },
392                }
393            },
394        });
395    }
396
397    #[fuchsia::test]
398    fn select_bss_empty_list_logs_to_inspect() {
399        let mut exec = fasync::TestExecutor::new();
400        let test_values = test_setup();
401        assert_eq!(
402            exec.run_singlethreaded(select_bss(
403                vec![],
404                generate_random_connect_reason(),
405                test_values.inspect_node.clone(),
406                test_values.telemetry_sender.clone()
407            )),
408            None
409        );
410
411        // Verify that an empty list of candidates is still logged to inspect.
412        assert_data_tree!(@executor exec, test_values.inspector, root: {
413            bss_select_test: {
414                "0": {
415                    "@time": AnyProperty,
416                    "candidates": {},
417                }
418            },
419        });
420    }
421
422    #[fuchsia::test]
423    fn select_bss_logs_cobalt_metrics() {
424        let mut exec = fasync::TestExecutor::new();
425        let mut test_values = test_setup();
426
427        let reason_code = generate_random_connect_reason();
428        let candidates =
429            vec![generate_random_scanned_candidate(), generate_random_scanned_candidate()];
430        assert!(
431            exec.run_singlethreaded(select_bss(
432                candidates.clone(),
433                reason_code,
434                test_values.inspect_node.clone(),
435                test_values.telemetry_sender.clone()
436            ))
437            .is_some()
438        );
439
440        assert_matches!(test_values.telemetry_receiver.try_next(), Ok(Some(event)) => {
441            assert_matches!(event, TelemetryEvent::BssSelectionResult {
442                reason,
443                scored_candidates,
444                selected_candidate: _,
445            } => {
446                assert_eq!(reason, reason_code);
447                let mut prior_score = i16::MAX;
448                for (candidate, score) in scored_candidates {
449                    assert!(candidates.contains(&candidate));
450                    assert!(prior_score >= score);
451                    prior_score = score;
452                }
453            })
454        });
455    }
456}