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