Skip to main content

wlan_telemetry/processors/
connect_disconnect.rs

1// Copyright 2024 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::processors::toggle_events::ClientConnectionsToggleEvent;
6use crate::util::cobalt_logger::log_cobalt_batch;
7use derivative::Derivative;
8use fidl_fuchsia_metrics::{MetricEvent, MetricEventPayload};
9use fidl_fuchsia_wlan_ieee80211 as fidl_ieee80211;
10use fidl_fuchsia_wlan_sme as fidl_sme;
11use fuchsia_async as fasync;
12use fuchsia_inspect::Node as InspectNode;
13use fuchsia_inspect_contrib::id_enum::IdEnum;
14use fuchsia_inspect_contrib::inspect_log;
15use fuchsia_inspect_contrib::nodes::{BoundedListNode, LruCacheNode};
16use fuchsia_inspect_derive::Unit;
17use fuchsia_sync::Mutex;
18use std::sync::Arc;
19use std::sync::atomic::{AtomicUsize, Ordering};
20use strum_macros::{Display, EnumIter};
21use windowed_stats::experimental::inspect::{InspectSender, InspectedTimeMatrix};
22use windowed_stats::experimental::series::interpolation::{ConstantSample, LastSample};
23use windowed_stats::experimental::series::metadata::{BitSetMap, BitSetNode};
24use windowed_stats::experimental::series::statistic::Union;
25use windowed_stats::experimental::series::{SamplingProfile, TimeMatrix};
26use wlan_common::bss::BssDescription;
27use wlan_common::channel::Channel;
28use wlan_legacy_metrics_registry as metrics;
29use zx;
30
31const INSPECT_CONNECT_EVENTS_LIMIT: usize = 10;
32const INSPECT_DISCONNECT_EVENTS_LIMIT: usize = 20;
33const INSPECT_CONNECT_ATTEMPT_RESULTS_LIMIT: usize = 50;
34const INSPECT_CONNECTED_NETWORKS_ID_LIMIT: usize = 16;
35const INSPECT_DISCONNECT_SOURCES_ID_LIMIT: usize = 32;
36const INSPECT_CONNECT_ATTEMPT_RESULTS_ID_LIMIT: usize = 32;
37const SUCCESSIVE_CONNECT_ATTEMPT_FAILURES_TIMEOUT: zx::BootDuration =
38    zx::BootDuration::from_minutes(2);
39
40#[derive(Clone, Debug, Display, EnumIter)]
41enum ConnectionState {
42    Idle(IdleState),
43    Connected(ConnectedState),
44    Disconnected(DisconnectedState),
45    ConnectFailed(ConnectFailedState),
46    FailedToStart(FailedToStartState),
47    FailedToStop(FailedToStopState),
48    PnoScanFailedIdle(PnoScanFailedIdleState),
49}
50
51// Update the ConnectDisconnectTimeSeries BitSetMap when making changes to this enum.
52impl IdEnum for ConnectionState {
53    type Id = u8;
54    fn to_id(&self) -> Self::Id {
55        match self {
56            Self::Idle(_) => 0,
57            Self::Disconnected(_) => 1,
58            Self::ConnectFailed(_) => 2,
59            Self::Connected(_) => 3,
60            Self::FailedToStart(_) => 4,
61            Self::FailedToStop(_) => 5,
62            Self::PnoScanFailedIdle(_) => 6,
63        }
64    }
65}
66
67#[derive(Clone, Debug, Default)]
68struct IdleState {}
69
70#[derive(Clone, Debug, Default)]
71struct ConnectedState {}
72
73#[derive(Clone, Debug, Default)]
74struct DisconnectedState {}
75
76#[derive(Clone, Debug, Default)]
77struct ConnectFailedState {}
78
79#[derive(Clone, Debug, Default)]
80struct FailedToStartState {}
81
82#[derive(Clone, Debug, Default)]
83struct FailedToStopState {}
84
85#[derive(Clone, Debug, Default)]
86struct PnoScanFailedIdleState {}
87
88#[derive(Derivative, Unit)]
89#[derivative(PartialEq, Eq, Hash)]
90struct InspectConnectedNetwork {
91    bssid: String,
92    ssid: String,
93    protection: String,
94    ht_cap: Option<Vec<u8>>,
95    vht_cap: Option<Vec<u8>>,
96    #[derivative(PartialEq = "ignore")]
97    #[derivative(Hash = "ignore")]
98    wsc: Option<InspectNetworkWsc>,
99    is_wmm_assoc: bool,
100    wmm_param: Option<Vec<u8>>,
101}
102
103impl From<&BssDescription> for InspectConnectedNetwork {
104    fn from(bss_description: &BssDescription) -> Self {
105        Self {
106            bssid: bss_description.bssid.to_string(),
107            ssid: bss_description.ssid.to_string(),
108            protection: format!("{:?}", bss_description.protection()),
109            ht_cap: bss_description.raw_ht_cap().map(|cap| cap.bytes.into()),
110            vht_cap: bss_description.raw_vht_cap().map(|cap| cap.bytes.into()),
111            wsc: bss_description.probe_resp_wsc().as_ref().map(InspectNetworkWsc::from),
112            is_wmm_assoc: bss_description.find_wmm_param().is_some(),
113            wmm_param: bss_description.find_wmm_param().map(|bytes| bytes.into()),
114        }
115    }
116}
117
118#[derive(PartialEq, Unit, Hash)]
119struct InspectNetworkWsc {
120    device_name: String,
121    manufacturer: String,
122    model_name: String,
123    model_number: String,
124}
125
126impl From<&wlan_common::ie::wsc::ProbeRespWsc> for InspectNetworkWsc {
127    fn from(wsc: &wlan_common::ie::wsc::ProbeRespWsc) -> Self {
128        Self {
129            device_name: String::from_utf8_lossy(&wsc.device_name[..]).to_string(),
130            manufacturer: String::from_utf8_lossy(&wsc.manufacturer[..]).to_string(),
131            model_name: String::from_utf8_lossy(&wsc.model_name[..]).to_string(),
132            model_number: String::from_utf8_lossy(&wsc.model_number[..]).to_string(),
133        }
134    }
135}
136
137#[derive(PartialEq, Eq, Unit, Hash)]
138struct InspectConnectAttemptResult {
139    status_code: u16,
140    result: String,
141}
142
143#[derive(PartialEq, Eq, Unit, Hash)]
144struct InspectDisconnectSource {
145    source: String,
146    reason: String,
147    mlme_event_name: Option<String>,
148}
149
150impl From<&fidl_sme::DisconnectSource> for InspectDisconnectSource {
151    fn from(disconnect_source: &fidl_sme::DisconnectSource) -> Self {
152        match disconnect_source {
153            fidl_sme::DisconnectSource::User(reason) => Self {
154                source: "user".to_string(),
155                reason: format!("{reason:?}"),
156                mlme_event_name: None,
157            },
158            fidl_sme::DisconnectSource::Ap(cause) => Self {
159                source: "ap".to_string(),
160                reason: format!("{:?}", cause.reason_code),
161                mlme_event_name: Some(format!("{:?}", cause.mlme_event_name)),
162            },
163            fidl_sme::DisconnectSource::Mlme(cause) => Self {
164                source: "mlme".to_string(),
165                reason: format!("{:?}", cause.reason_code),
166                mlme_event_name: Some(format!("{:?}", cause.mlme_event_name)),
167            },
168        }
169    }
170}
171
172#[derive(Clone, Debug, PartialEq)]
173pub struct DisconnectInfo {
174    pub iface_id: u16,
175    pub connected_duration: zx::BootDuration,
176    pub is_sme_reconnecting: bool,
177    pub disconnect_source: fidl_sme::DisconnectSource,
178    pub original_bss_desc: Box<BssDescription>,
179    pub current_rssi_dbm: i8,
180    pub current_snr_db: i8,
181    pub current_channel: Channel,
182}
183
184pub struct ConnectDisconnectLogger {
185    connection_state: Arc<Mutex<ConnectionState>>,
186    cobalt_proxy: fidl_fuchsia_metrics::MetricEventLoggerProxy,
187    connect_events_node: Mutex<BoundedListNode>,
188    disconnect_events_node: Mutex<BoundedListNode>,
189    connect_attempt_results_node: Mutex<BoundedListNode>,
190    inspect_metadata_node: Mutex<InspectMetadataNode>,
191    time_series_stats: ConnectDisconnectTimeSeries,
192    successive_connect_attempt_failures: AtomicUsize,
193    last_connect_failure_at: Arc<Mutex<Option<fasync::BootInstant>>>,
194    last_disconnect_at: Arc<Mutex<Option<fasync::MonotonicInstant>>>,
195}
196
197impl ConnectDisconnectLogger {
198    pub fn new<S: InspectSender>(
199        cobalt_proxy: fidl_fuchsia_metrics::MetricEventLoggerProxy,
200        inspect_node: &InspectNode,
201        inspect_metadata_node: &InspectNode,
202        inspect_metadata_path: &str,
203        time_matrix_client: &S,
204    ) -> Self {
205        let connect_events = inspect_node.create_child("connect_events");
206        let disconnect_events = inspect_node.create_child("disconnect_events");
207        let connect_attempt_results = inspect_node.create_child("connect_attempt_results");
208        let this = Self {
209            cobalt_proxy,
210            connection_state: Arc::new(Mutex::new(ConnectionState::Idle(IdleState {}))),
211            connect_events_node: Mutex::new(BoundedListNode::new(
212                connect_events,
213                INSPECT_CONNECT_EVENTS_LIMIT,
214            )),
215            disconnect_events_node: Mutex::new(BoundedListNode::new(
216                disconnect_events,
217                INSPECT_DISCONNECT_EVENTS_LIMIT,
218            )),
219            connect_attempt_results_node: Mutex::new(BoundedListNode::new(
220                connect_attempt_results,
221                INSPECT_CONNECT_ATTEMPT_RESULTS_LIMIT,
222            )),
223            inspect_metadata_node: Mutex::new(InspectMetadataNode::new(inspect_metadata_node)),
224            time_series_stats: ConnectDisconnectTimeSeries::new(
225                time_matrix_client,
226                inspect_metadata_path,
227            ),
228            successive_connect_attempt_failures: AtomicUsize::new(0),
229            last_connect_failure_at: Arc::new(Mutex::new(None)),
230            last_disconnect_at: Arc::new(Mutex::new(None)),
231        };
232        this.log_connection_state();
233        this
234    }
235
236    fn update_connection_state(&self, state: ConnectionState) {
237        *self.connection_state.lock() = state;
238        self.log_connection_state();
239    }
240
241    fn log_connection_state(&self) {
242        let wlan_connectivity_state_id = self.connection_state.lock().to_id() as u64;
243        self.time_series_stats.log_wlan_connectivity_state(1 << wlan_connectivity_state_id);
244    }
245
246    pub fn is_connected(&self) -> bool {
247        matches!(*self.connection_state.lock(), ConnectionState::Connected(_))
248    }
249
250    pub async fn handle_connect_attempt(
251        &self,
252        result: fidl_ieee80211::StatusCode,
253        bss: &BssDescription,
254        is_credential_rejected: bool,
255    ) {
256        let mut flushed_successive_failures = None;
257        let mut downtime_duration = None;
258        if result == fidl_ieee80211::StatusCode::Success {
259            self.update_connection_state(ConnectionState::Connected(ConnectedState {}));
260            flushed_successive_failures =
261                Some(self.successive_connect_attempt_failures.swap(0, Ordering::SeqCst));
262            downtime_duration =
263                self.last_disconnect_at.lock().map(|t| fasync::MonotonicInstant::now() - t);
264        } else if is_credential_rejected {
265            self.update_connection_state(ConnectionState::Idle(IdleState {}));
266            let _prev = self.successive_connect_attempt_failures.fetch_add(1, Ordering::SeqCst);
267            let _prev = self.last_connect_failure_at.lock().replace(fasync::BootInstant::now());
268        } else {
269            self.update_connection_state(ConnectionState::ConnectFailed(ConnectFailedState {}));
270            let _prev = self.successive_connect_attempt_failures.fetch_add(1, Ordering::SeqCst);
271            let _prev = self.last_connect_failure_at.lock().replace(fasync::BootInstant::now());
272        }
273
274        self.log_connect_attempt_inspect(result, bss);
275        self.log_connect_attempt_cobalt(result, flushed_successive_failures, downtime_duration)
276            .await;
277    }
278
279    fn log_connect_attempt_inspect(
280        &self,
281        result: fidl_ieee80211::StatusCode,
282        bss: &BssDescription,
283    ) {
284        let mut inspect_metadata_node = self.inspect_metadata_node.lock();
285        let connect_result_id =
286            inspect_metadata_node.connect_attempt_results.insert(InspectConnectAttemptResult {
287                status_code: result.into_primitive(),
288                result: format!("{:?}", result),
289            }) as u64;
290        self.time_series_stats.log_connect_attempt_results(1 << connect_result_id);
291
292        inspect_log!(self.connect_attempt_results_node.lock(), {
293            result: format!("{:?}", result),
294            ssid: bss.ssid.to_string(),
295            bssid: bss.bssid.to_string(),
296            protection: format!("{:?}", bss.protection()),
297        });
298
299        if result == fidl_ieee80211::StatusCode::Success {
300            let connected_network = InspectConnectedNetwork::from(bss);
301            let connected_network_id =
302                inspect_metadata_node.connected_networks.insert(connected_network) as u64;
303
304            self.time_series_stats.log_connected_networks(1 << connected_network_id);
305
306            inspect_log!(self.connect_events_node.lock(), {
307                network_id: connected_network_id,
308            });
309        }
310    }
311
312    #[allow(clippy::vec_init_then_push, reason = "mass allow for https://fxbug.dev/381896734")]
313    async fn log_connect_attempt_cobalt(
314        &self,
315        result: fidl_ieee80211::StatusCode,
316        flushed_successive_failures: Option<usize>,
317        downtime_duration: Option<zx::MonotonicDuration>,
318    ) {
319        let mut metric_events = vec![];
320        metric_events.push(MetricEvent {
321            metric_id: metrics::CONNECT_ATTEMPT_BREAKDOWN_BY_STATUS_CODE_METRIC_ID,
322            event_codes: vec![result.into_primitive() as u32],
323            payload: MetricEventPayload::Count(1),
324        });
325
326        if let Some(failures) = flushed_successive_failures {
327            metric_events.push(MetricEvent {
328                metric_id: metrics::SUCCESSIVE_CONNECT_ATTEMPT_FAILURES_METRIC_ID,
329                event_codes: vec![],
330                payload: MetricEventPayload::IntegerValue(failures as i64),
331            });
332        }
333
334        if let Some(duration) = downtime_duration {
335            metric_events.push(MetricEvent {
336                metric_id: metrics::DOWNTIME_POST_DISCONNECT_METRIC_ID,
337                event_codes: vec![],
338                payload: MetricEventPayload::IntegerValue(duration.into_millis()),
339            });
340        }
341
342        log_cobalt_batch!(self.cobalt_proxy, &metric_events, "log_connect_attempt_cobalt");
343    }
344
345    pub async fn log_disconnect(&self, info: &DisconnectInfo) {
346        // Mobile devices can be considered idle if they disconnect for reasons associated with
347        // going out of range or are commanded to disconnect by upper layers.
348        //
349        // TODO(500107852): Update this logic to account for non-mobile devices when such devices
350        // use the telemetry library.
351        if !info.disconnect_source.should_log_for_mobile_device() {
352            self.update_connection_state(ConnectionState::Idle(IdleState {}));
353        } else {
354            self.update_connection_state(ConnectionState::Disconnected(DisconnectedState {}));
355        }
356        let _prev = self.last_disconnect_at.lock().replace(fasync::MonotonicInstant::now());
357        self.log_disconnect_inspect(info);
358        self.log_disconnect_cobalt(info).await;
359    }
360
361    fn log_disconnect_inspect(&self, info: &DisconnectInfo) {
362        let mut inspect_metadata_node = self.inspect_metadata_node.lock();
363        let connected_network = InspectConnectedNetwork::from(&*info.original_bss_desc);
364        let connected_network_id =
365            inspect_metadata_node.connected_networks.insert(connected_network) as u64;
366        let disconnect_source = InspectDisconnectSource::from(&info.disconnect_source);
367        let disconnect_source_id =
368            inspect_metadata_node.disconnect_sources.insert(disconnect_source) as u64;
369        inspect_log!(self.disconnect_events_node.lock(), {
370            connected_duration: info.connected_duration.into_nanos(),
371            disconnect_source_id: disconnect_source_id,
372            network_id: connected_network_id,
373            rssi_dbm: info.current_rssi_dbm,
374            snr_db: info.current_snr_db,
375            channel: format!("{}", info.current_channel),
376        });
377
378        self.time_series_stats.log_disconnected_networks(1 << connected_network_id);
379        self.time_series_stats.log_disconnect_sources(1 << disconnect_source_id);
380    }
381
382    async fn log_disconnect_cobalt(&self, info: &DisconnectInfo) {
383        let mut metric_events = vec![];
384        metric_events.push(MetricEvent {
385            metric_id: metrics::TOTAL_DISCONNECT_COUNT_METRIC_ID,
386            event_codes: vec![],
387            payload: MetricEventPayload::Count(1),
388        });
389
390        if info.disconnect_source.should_log_for_mobile_device() {
391            metric_events.push(MetricEvent {
392                metric_id: metrics::DISCONNECT_OCCURRENCE_FOR_MOBILE_DEVICE_METRIC_ID,
393                event_codes: vec![],
394                payload: MetricEventPayload::Count(1),
395            });
396        }
397
398        metric_events.push(MetricEvent {
399            metric_id: metrics::CONNECTED_DURATION_ON_DISCONNECT_METRIC_ID,
400            event_codes: vec![],
401            payload: MetricEventPayload::IntegerValue(info.connected_duration.into_millis()),
402        });
403
404        metric_events.push(MetricEvent {
405            metric_id: metrics::DISCONNECT_BREAKDOWN_BY_REASON_CODE_METRIC_ID,
406            event_codes: vec![
407                u32::from(info.disconnect_source.cobalt_reason_code()),
408                info.disconnect_source.as_cobalt_disconnect_source() as u32,
409            ],
410            payload: MetricEventPayload::Count(1),
411        });
412
413        log_cobalt_batch!(self.cobalt_proxy, &metric_events, "log_disconnect_cobalt");
414    }
415
416    pub async fn handle_periodic_telemetry(&self) {
417        let mut metric_events = vec![];
418        let now = fasync::BootInstant::now();
419        if let Some(failed_at) = *self.last_connect_failure_at.lock()
420            && now - failed_at >= SUCCESSIVE_CONNECT_ATTEMPT_FAILURES_TIMEOUT
421        {
422            let failures = self.successive_connect_attempt_failures.swap(0, Ordering::SeqCst);
423            if failures > 0 {
424                metric_events.push(MetricEvent {
425                    metric_id: metrics::SUCCESSIVE_CONNECT_ATTEMPT_FAILURES_METRIC_ID,
426                    event_codes: vec![],
427                    payload: MetricEventPayload::IntegerValue(failures as i64),
428                });
429            }
430        }
431
432        log_cobalt_batch!(self.cobalt_proxy, &metric_events, "handle_periodic_telemetry");
433    }
434
435    pub async fn handle_suspend_imminent(&self) {
436        let mut metric_events = vec![];
437
438        let flushed_successive_failures =
439            self.successive_connect_attempt_failures.swap(0, Ordering::SeqCst);
440        if flushed_successive_failures > 0 {
441            metric_events.push(MetricEvent {
442                metric_id: metrics::SUCCESSIVE_CONNECT_ATTEMPT_FAILURES_METRIC_ID,
443                event_codes: vec![],
444                payload: MetricEventPayload::IntegerValue(flushed_successive_failures as i64),
445            });
446        }
447
448        log_cobalt_batch!(self.cobalt_proxy, &metric_events, "handle_suspend_imminent");
449    }
450
451    pub async fn handle_iface_destroyed(&self) {
452        self.update_connection_state(ConnectionState::Idle(IdleState {}));
453    }
454
455    pub async fn handle_client_connections_toggle(&self, event: &ClientConnectionsToggleEvent) {
456        if event == &ClientConnectionsToggleEvent::Disabled {
457            self.update_connection_state(ConnectionState::Idle(IdleState {}));
458        }
459    }
460
461    pub async fn handle_pno_scan_failure(&self) {
462        let mut metric_events = vec![MetricEvent {
463            metric_id: metrics::PNO_SCAN_FAILURE_OCCURRENCE_METRIC_ID,
464            event_codes: vec![],
465            payload: MetricEventPayload::Count(1),
466        }];
467
468        let state = self.connection_state.lock().clone();
469        match state {
470            ConnectionState::Idle(_)
471            | ConnectionState::Disconnected(_)
472            | ConnectionState::ConnectFailed(_)
473            | ConnectionState::PnoScanFailedIdle(_) => {
474                metric_events.push(MetricEvent {
475                    metric_id: metrics::PNO_SCAN_FAILURE_WHILE_NOT_CONNECTED_OCCURRENCE_METRIC_ID,
476                    event_codes: vec![],
477                    payload: MetricEventPayload::Count(1),
478                });
479
480                // PNO scan failures while not connected indicate that the system is looking for
481                // networks to connect to but it is unable to.  In this case, we should transition
482                // to the PnoScanFailedIdle state to flag a period of potential connectivity loss.
483                self.update_connection_state(ConnectionState::PnoScanFailedIdle(
484                    PnoScanFailedIdleState {},
485                ));
486            }
487            ConnectionState::Connected(_)
488            | ConnectionState::FailedToStart(_)
489            | ConnectionState::FailedToStop(_) => {
490                // PNO scan failures while connected will not affect the current connectivity state.
491                // If WLAN has already failed to start or failed to stop, the state should remain
492                // unchanged until a different failure or successful connection occurs.
493            }
494        }
495
496        log_cobalt_batch!(self.cobalt_proxy, &metric_events, "handle_pno_scan_failure");
497    }
498    pub async fn handle_client_connections_failed_to_start(&self) {
499        self.update_connection_state(ConnectionState::FailedToStart(FailedToStartState {}));
500    }
501
502    pub async fn handle_client_connections_failed_to_stop(&self) {
503        self.update_connection_state(ConnectionState::FailedToStop(FailedToStopState {}));
504    }
505}
506
507struct InspectMetadataNode {
508    connected_networks: LruCacheNode<InspectConnectedNetwork>,
509    disconnect_sources: LruCacheNode<InspectDisconnectSource>,
510    connect_attempt_results: LruCacheNode<InspectConnectAttemptResult>,
511}
512
513impl InspectMetadataNode {
514    const CONNECTED_NETWORKS: &'static str = "connected_networks";
515    const DISCONNECT_SOURCES: &'static str = "disconnect_sources";
516    const CONNECT_ATTEMPT_RESULTS: &'static str = "connect_attempt_results";
517
518    fn new(inspect_node: &InspectNode) -> Self {
519        let connected_networks = inspect_node.create_child(Self::CONNECTED_NETWORKS);
520        let disconnect_sources = inspect_node.create_child(Self::DISCONNECT_SOURCES);
521        let connect_attempt_results = inspect_node.create_child(Self::CONNECT_ATTEMPT_RESULTS);
522        Self {
523            connected_networks: LruCacheNode::new(
524                connected_networks,
525                INSPECT_CONNECTED_NETWORKS_ID_LIMIT,
526            ),
527            disconnect_sources: LruCacheNode::new(
528                disconnect_sources,
529                INSPECT_DISCONNECT_SOURCES_ID_LIMIT,
530            ),
531            connect_attempt_results: LruCacheNode::new(
532                connect_attempt_results,
533                INSPECT_CONNECT_ATTEMPT_RESULTS_ID_LIMIT,
534            ),
535        }
536    }
537}
538
539#[derive(Debug, Clone)]
540struct ConnectDisconnectTimeSeries {
541    wlan_connectivity_states: InspectedTimeMatrix<u64>,
542    connected_networks: InspectedTimeMatrix<u64>,
543    disconnected_networks: InspectedTimeMatrix<u64>,
544    disconnect_sources: InspectedTimeMatrix<u64>,
545    connect_attempt_results: InspectedTimeMatrix<u64>,
546}
547
548impl ConnectDisconnectTimeSeries {
549    pub fn new<S: InspectSender>(client: &S, inspect_metadata_path: &str) -> Self {
550        let wlan_connectivity_states = client.inspect_time_matrix_with_metadata(
551            "wlan_connectivity_states",
552            TimeMatrix::<Union<u64>, LastSample>::new(
553                SamplingProfile::highly_granular(),
554                LastSample::or(0),
555            ),
556            // Update the ConnectionState IdEnum trait when making changes to this list.
557            BitSetMap::from_ordered(Self::wlan_connectivity_states_bitset_map().iter().copied()),
558        );
559        let connected_networks = client.inspect_time_matrix_with_metadata(
560            "connected_networks",
561            TimeMatrix::<Union<u64>, ConstantSample>::new(
562                SamplingProfile::granular(),
563                ConstantSample::default(),
564            ),
565            BitSetNode::from_path(format!(
566                "{}/{}",
567                inspect_metadata_path,
568                InspectMetadataNode::CONNECTED_NETWORKS
569            )),
570        );
571        let disconnected_networks = client.inspect_time_matrix_with_metadata(
572            "disconnected_networks",
573            TimeMatrix::<Union<u64>, ConstantSample>::new(
574                SamplingProfile::granular(),
575                ConstantSample::default(),
576            ),
577            // This time matrix shares its bit labels with `connected_networks`.
578            BitSetNode::from_path(format!(
579                "{}/{}",
580                inspect_metadata_path,
581                InspectMetadataNode::CONNECTED_NETWORKS
582            )),
583        );
584        let disconnect_sources = client.inspect_time_matrix_with_metadata(
585            "disconnect_sources",
586            TimeMatrix::<Union<u64>, ConstantSample>::new(
587                SamplingProfile::granular(),
588                ConstantSample::default(),
589            ),
590            BitSetNode::from_path(format!(
591                "{}/{}",
592                inspect_metadata_path,
593                InspectMetadataNode::DISCONNECT_SOURCES,
594            )),
595        );
596        let connect_attempt_results = client.inspect_time_matrix_with_metadata(
597            "connect_attempt_results",
598            TimeMatrix::<Union<u64>, ConstantSample>::new(
599                SamplingProfile::granular(),
600                ConstantSample::default(),
601            ),
602            BitSetNode::from_path(format!(
603                "{}/{}",
604                inspect_metadata_path,
605                InspectMetadataNode::CONNECT_ATTEMPT_RESULTS,
606            )),
607        );
608        Self {
609            wlan_connectivity_states,
610            connected_networks,
611            disconnected_networks,
612            disconnect_sources,
613            connect_attempt_results,
614        }
615    }
616
617    // TODO(https://fxbug.dev/504712259): Update BitSetMap to accept the enum type
618    // it's associated with rather than constructing bit labels separately like this
619    fn wlan_connectivity_states_bitset_map() -> &'static [&'static str] {
620        &[
621            "idle",
622            "disconnected",
623            "connect_failed",
624            "connected",
625            "start_failure",
626            "stop_failure",
627            "pno_scan_failed",
628        ]
629    }
630
631    fn log_wlan_connectivity_state(&self, data: u64) {
632        self.wlan_connectivity_states.fold_or_log_error(data);
633    }
634    fn log_connected_networks(&self, data: u64) {
635        self.connected_networks.fold_or_log_error(data);
636    }
637    fn log_disconnected_networks(&self, data: u64) {
638        self.disconnected_networks.fold_or_log_error(data);
639    }
640    fn log_disconnect_sources(&self, data: u64) {
641        self.disconnect_sources.fold_or_log_error(data);
642    }
643    fn log_connect_attempt_results(&self, data: u64) {
644        self.connect_attempt_results.fold_or_log_error(data);
645    }
646}
647
648pub trait DisconnectSourceExt {
649    fn should_log_for_mobile_device(&self) -> bool;
650    fn cobalt_reason_code(&self) -> u16;
651    fn as_cobalt_disconnect_source(
652        &self,
653    ) -> metrics::ConnectivityWlanMetricDimensionDisconnectSource;
654}
655
656impl DisconnectSourceExt for fidl_sme::DisconnectSource {
657    fn should_log_for_mobile_device(&self) -> bool {
658        match self {
659            fidl_sme::DisconnectSource::Ap(_) => true,
660            fidl_sme::DisconnectSource::Mlme(cause)
661                if cause.reason_code != fidl_ieee80211::ReasonCode::MlmeLinkFailed =>
662            {
663                true
664            }
665            _ => false,
666        }
667    }
668
669    fn cobalt_reason_code(&self) -> u16 {
670        let cobalt_disconnect_reason_code = match self {
671            fidl_sme::DisconnectSource::Ap(cause) | fidl_sme::DisconnectSource::Mlme(cause) => {
672                cause.reason_code.into_primitive()
673            }
674            fidl_sme::DisconnectSource::User(reason) => *reason as u16,
675        };
676        // This `max_event_code: 1000` is set in the metrics registry, but doesn't show up in the
677        // generated bindings.
678        const REASON_CODE_MAX: u16 = 1000;
679        std::cmp::min(cobalt_disconnect_reason_code, REASON_CODE_MAX)
680    }
681
682    fn as_cobalt_disconnect_source(
683        &self,
684    ) -> metrics::ConnectivityWlanMetricDimensionDisconnectSource {
685        use metrics::ConnectivityWlanMetricDimensionDisconnectSource as DS;
686        match self {
687            fidl_sme::DisconnectSource::Ap(..) => DS::Ap,
688            fidl_sme::DisconnectSource::User(..) => DS::User,
689            fidl_sme::DisconnectSource::Mlme(..) => DS::Mlme,
690        }
691    }
692}
693
694#[cfg(test)]
695mod tests {
696    use super::*;
697    use crate::testing::*;
698    use assert_matches::assert_matches;
699    use diagnostics_assertions::{
700        AnyBoolProperty, AnyBytesProperty, AnyNumericProperty, AnyStringProperty, assert_data_tree,
701    };
702    use futures::task::Poll;
703    use ieee80211_testutils::{BSSID_REGEX, SSID_REGEX};
704    use rand::Rng;
705    use std::pin::pin;
706    use strum::IntoEnumIterator;
707    use test_case::test_case;
708    use windowed_stats::experimental::clock::Timed;
709    use windowed_stats::experimental::inspect::TimeMatrixClient;
710    use windowed_stats::experimental::testing::TimeMatrixCall;
711    use wlan_common::channel::{Cbw, Channel};
712    use wlan_common::{fake_bss_description, random_bss_description};
713
714    #[fuchsia::test]
715    fn log_connect_attempt_then_inspect_data_tree_contains_time_matrix_metadata() {
716        let mut harness = setup_test();
717
718        let client =
719            TimeMatrixClient::new(harness.inspect_node.create_child("wlan_connect_disconnect"));
720        let logger = ConnectDisconnectLogger::new(
721            harness.cobalt_proxy.clone(),
722            &harness.inspect_node,
723            &harness.inspect_metadata_node,
724            &harness.inspect_metadata_path,
725            &client,
726        );
727        let bss = random_bss_description!();
728        let mut log_connect_attempt =
729            pin!(logger.handle_connect_attempt(fidl_ieee80211::StatusCode::Success, &bss, false));
730        assert!(
731            harness.run_until_stalled_drain_cobalt_events(&mut log_connect_attempt).is_ready(),
732            "`log_connect_attempt` did not complete",
733        );
734
735        let tree = harness.get_inspect_data_tree();
736        assert_data_tree!(
737            @executor harness.exec,
738            tree,
739            root: contains {
740                test_stats: contains {
741                    wlan_connect_disconnect: contains {
742                        wlan_connectivity_states: {
743                            "type": "bitset",
744                            "data": AnyBytesProperty,
745                            metadata: {
746                                index: {
747                                    "0": "idle",
748                                    "1": "disconnected",
749                                    "2": "connect_failed",
750                                    "3": "connected",
751                                    "4": "start_failure",
752                                    "5": "stop_failure",
753                                    "6": "pno_scan_failed",
754                                },
755                            },
756                        },
757                        connected_networks: {
758                            "type": "bitset",
759                            "data": AnyBytesProperty,
760                            metadata: {
761                                "index_node_path": "root/test_stats/metadata/connected_networks",
762                            },
763                        },
764                        disconnected_networks: {
765                            "type": "bitset",
766                            "data": AnyBytesProperty,
767                            metadata: {
768                                "index_node_path": "root/test_stats/metadata/connected_networks",
769                            },
770                        },
771                        disconnect_sources: {
772                            "type": "bitset",
773                            "data": AnyBytesProperty,
774                            metadata: {
775                                "index_node_path": "root/test_stats/metadata/disconnect_sources",
776                            },
777                        },
778                        connect_attempt_results: {
779                            "type": "bitset",
780                            "data": AnyBytesProperty,
781                            metadata: {
782                                "index_node_path": "root/test_stats/metadata/connect_attempt_results",
783                            },
784                        },
785                    },
786                },
787            }
788        );
789    }
790
791    #[fuchsia::test]
792    fn test_log_connect_attempt_inspect() {
793        let mut test_helper = setup_test();
794        let logger = ConnectDisconnectLogger::new(
795            test_helper.cobalt_proxy.clone(),
796            &test_helper.inspect_node,
797            &test_helper.inspect_metadata_node,
798            &test_helper.inspect_metadata_path,
799            &test_helper.mock_time_matrix_client,
800        );
801
802        // Log the event
803        let bss_description = random_bss_description!();
804        let mut test_fut = pin!(logger.handle_connect_attempt(
805            fidl_ieee80211::StatusCode::Success,
806            &bss_description,
807            false
808        ));
809        assert_eq!(
810            test_helper.run_until_stalled_drain_cobalt_events(&mut test_fut),
811            Poll::Ready(())
812        );
813
814        // Validate Inspect data
815        let data = test_helper.get_inspect_data_tree();
816        assert_data_tree!(@executor test_helper.exec, data, root: contains {
817            test_stats: contains {
818                metadata: contains {
819                    connected_networks: contains {
820                        "0": {
821                            "@time": AnyNumericProperty,
822                            "data": contains {
823                                bssid: &*BSSID_REGEX,
824                                ssid: &*SSID_REGEX,
825                            }
826                        }
827                    },
828                    connect_attempt_results: contains {
829                        "0": {
830                            "@time": AnyNumericProperty,
831                            "data": contains {
832                                status_code: 0u64,
833                                result: "Success",
834                            }
835                        }
836                    },
837                },
838                connect_events: {
839                    "0": {
840                        "@time": AnyNumericProperty,
841                        network_id: 0u64,
842                    }
843                },
844                connect_attempt_results: {
845                    "0": {
846                        "@time": AnyNumericProperty,
847                        result: "Success",
848                        ssid: &*SSID_REGEX,
849                        bssid: &*BSSID_REGEX,
850                        protection: AnyStringProperty,
851                    }
852                }
853            }
854        });
855
856        let mut time_matrix_calls = test_helper.mock_time_matrix_client.drain_calls();
857        assert_eq!(
858            &time_matrix_calls.drain::<u64>("wlan_connectivity_states")[..],
859            &[TimeMatrixCall::Fold(Timed::now(1 << 0)), TimeMatrixCall::Fold(Timed::now(1 << 3)),]
860        );
861        assert_eq!(
862            &time_matrix_calls.drain::<u64>("connected_networks")[..],
863            &[TimeMatrixCall::Fold(Timed::now(1 << 0))]
864        );
865        assert_eq!(
866            &time_matrix_calls.drain::<u64>("connect_attempt_results")[..],
867            &[TimeMatrixCall::Fold(Timed::now(1 << 0))]
868        );
869    }
870
871    #[fuchsia::test]
872    fn test_log_connect_attempt_cobalt() {
873        let mut test_helper = setup_test();
874        let logger = ConnectDisconnectLogger::new(
875            test_helper.cobalt_proxy.clone(),
876            &test_helper.inspect_node,
877            &test_helper.inspect_metadata_node,
878            &test_helper.inspect_metadata_path,
879            &test_helper.mock_time_matrix_client,
880        );
881
882        // Generate BSS Description
883        let bss_description = random_bss_description!(Wpa2,
884            channel: Channel::new(157, Cbw::Cbw40),
885            bssid: [0x00, 0xf6, 0x20, 0x03, 0x04, 0x05],
886        );
887
888        // Log the event
889        let mut test_fut = pin!(logger.handle_connect_attempt(
890            fidl_ieee80211::StatusCode::Success,
891            &bss_description,
892            false
893        ));
894        assert_eq!(
895            test_helper.run_until_stalled_drain_cobalt_events(&mut test_fut),
896            Poll::Ready(())
897        );
898
899        // Validate Cobalt data
900        let breakdowns_by_status_code = test_helper
901            .get_logged_metrics(metrics::CONNECT_ATTEMPT_BREAKDOWN_BY_STATUS_CODE_METRIC_ID);
902        assert_eq!(breakdowns_by_status_code.len(), 1);
903        assert_eq!(
904            breakdowns_by_status_code[0].event_codes,
905            vec![fidl_ieee80211::StatusCode::Success.into_primitive() as u32]
906        );
907        assert_eq!(breakdowns_by_status_code[0].payload, MetricEventPayload::Count(1));
908    }
909
910    #[fuchsia::test]
911    fn test_successive_connect_attempt_failures_cobalt_zero_failures() {
912        let mut test_helper = setup_test();
913        let logger = ConnectDisconnectLogger::new(
914            test_helper.cobalt_proxy.clone(),
915            &test_helper.inspect_node,
916            &test_helper.inspect_metadata_node,
917            &test_helper.inspect_metadata_path,
918            &test_helper.mock_time_matrix_client,
919        );
920
921        let bss_description = random_bss_description!(Wpa2);
922        let mut test_fut = pin!(logger.handle_connect_attempt(
923            fidl_ieee80211::StatusCode::Success,
924            &bss_description,
925            false
926        ));
927        assert_eq!(
928            test_helper.run_until_stalled_drain_cobalt_events(&mut test_fut),
929            Poll::Ready(())
930        );
931
932        let metrics =
933            test_helper.get_logged_metrics(metrics::SUCCESSIVE_CONNECT_ATTEMPT_FAILURES_METRIC_ID);
934        assert_eq!(metrics.len(), 1);
935        assert_eq!(metrics[0].payload, MetricEventPayload::IntegerValue(0));
936    }
937
938    #[test_case(1; "one_failure")]
939    #[test_case(2; "two_failures")]
940    #[fuchsia::test(add_test_attr = false)]
941    fn test_successive_connect_attempt_failures_cobalt_one_failure_then_success(n_failures: usize) {
942        let mut test_helper = setup_test();
943        let logger = ConnectDisconnectLogger::new(
944            test_helper.cobalt_proxy.clone(),
945            &test_helper.inspect_node,
946            &test_helper.inspect_metadata_node,
947            &test_helper.inspect_metadata_path,
948            &test_helper.mock_time_matrix_client,
949        );
950
951        let bss_description = random_bss_description!(Wpa2);
952        for _i in 0..n_failures {
953            let mut test_fut = pin!(logger.handle_connect_attempt(
954                fidl_ieee80211::StatusCode::RefusedReasonUnspecified,
955                &bss_description,
956                false
957            ));
958            assert_eq!(
959                test_helper.run_until_stalled_drain_cobalt_events(&mut test_fut),
960                Poll::Ready(())
961            );
962        }
963
964        let metrics =
965            test_helper.get_logged_metrics(metrics::SUCCESSIVE_CONNECT_ATTEMPT_FAILURES_METRIC_ID);
966        assert!(metrics.is_empty());
967
968        let mut test_fut = pin!(logger.handle_connect_attempt(
969            fidl_ieee80211::StatusCode::Success,
970            &bss_description,
971            false
972        ));
973        assert_eq!(
974            test_helper.run_until_stalled_drain_cobalt_events(&mut test_fut),
975            Poll::Ready(())
976        );
977
978        let metrics =
979            test_helper.get_logged_metrics(metrics::SUCCESSIVE_CONNECT_ATTEMPT_FAILURES_METRIC_ID);
980        assert_eq!(metrics.len(), 1);
981        assert_eq!(metrics[0].payload, MetricEventPayload::IntegerValue(n_failures as i64));
982
983        // Verify subsequent successes would report 0 failures
984        test_helper.clear_cobalt_events();
985        let mut test_fut = pin!(logger.handle_connect_attempt(
986            fidl_ieee80211::StatusCode::Success,
987            &bss_description,
988            false
989        ));
990        assert_eq!(
991            test_helper.run_until_stalled_drain_cobalt_events(&mut test_fut),
992            Poll::Ready(())
993        );
994        let metrics =
995            test_helper.get_logged_metrics(metrics::SUCCESSIVE_CONNECT_ATTEMPT_FAILURES_METRIC_ID);
996        assert_eq!(metrics.len(), 1);
997        assert_eq!(metrics[0].payload, MetricEventPayload::IntegerValue(0));
998    }
999
1000    #[test_case(1; "one_failure")]
1001    #[test_case(2; "two_failures")]
1002    #[fuchsia::test(add_test_attr = false)]
1003    fn test_successive_connect_attempt_failures_cobalt_one_failure_then_timeout(n_failures: usize) {
1004        let mut test_helper = setup_test();
1005        let logger = ConnectDisconnectLogger::new(
1006            test_helper.cobalt_proxy.clone(),
1007            &test_helper.inspect_node,
1008            &test_helper.inspect_metadata_node,
1009            &test_helper.inspect_metadata_path,
1010            &test_helper.mock_time_matrix_client,
1011        );
1012
1013        let bss_description = random_bss_description!(Wpa2);
1014        for _i in 0..n_failures {
1015            let mut test_fut = pin!(logger.handle_connect_attempt(
1016                fidl_ieee80211::StatusCode::RefusedReasonUnspecified,
1017                &bss_description,
1018                false
1019            ));
1020            assert_eq!(
1021                test_helper.run_until_stalled_drain_cobalt_events(&mut test_fut),
1022                Poll::Ready(())
1023            );
1024        }
1025
1026        test_helper.exec.set_fake_time(fasync::MonotonicInstant::from_nanos(60_000_000_000));
1027        let mut test_fut = pin!(logger.handle_periodic_telemetry());
1028        assert_eq!(
1029            test_helper.run_until_stalled_drain_cobalt_events(&mut test_fut),
1030            Poll::Ready(())
1031        );
1032
1033        // Not enough time has passed, so successive_connect_attempt_failures is not flushed yet
1034        let metrics =
1035            test_helper.get_logged_metrics(metrics::SUCCESSIVE_CONNECT_ATTEMPT_FAILURES_METRIC_ID);
1036        assert!(metrics.is_empty());
1037
1038        test_helper.exec.set_fake_time(fasync::MonotonicInstant::from_nanos(120_000_000_000));
1039        let mut test_fut = pin!(logger.handle_periodic_telemetry());
1040        assert_eq!(
1041            test_helper.run_until_stalled_drain_cobalt_events(&mut test_fut),
1042            Poll::Ready(())
1043        );
1044
1045        let metrics =
1046            test_helper.get_logged_metrics(metrics::SUCCESSIVE_CONNECT_ATTEMPT_FAILURES_METRIC_ID);
1047        assert_eq!(metrics.len(), 1);
1048        assert_eq!(metrics[0].payload, MetricEventPayload::IntegerValue(n_failures as i64));
1049
1050        // Verify timeout fires only once
1051        test_helper.clear_cobalt_events();
1052        test_helper.exec.set_fake_time(fasync::MonotonicInstant::from_nanos(240_000_000_000));
1053        let mut test_fut = pin!(logger.handle_periodic_telemetry());
1054        assert_eq!(
1055            test_helper.run_until_stalled_drain_cobalt_events(&mut test_fut),
1056            Poll::Ready(())
1057        );
1058        let metrics =
1059            test_helper.get_logged_metrics(metrics::SUCCESSIVE_CONNECT_ATTEMPT_FAILURES_METRIC_ID);
1060        assert!(metrics.is_empty());
1061    }
1062
1063    #[fuchsia::test]
1064    fn test_zero_successive_connect_attempt_failures_on_suspend() {
1065        let mut test_helper = setup_test();
1066        let logger = ConnectDisconnectLogger::new(
1067            test_helper.cobalt_proxy.clone(),
1068            &test_helper.inspect_node,
1069            &test_helper.inspect_metadata_node,
1070            &test_helper.inspect_metadata_path,
1071            &test_helper.mock_time_matrix_client,
1072        );
1073
1074        let mut test_fut = pin!(logger.handle_suspend_imminent());
1075        assert_eq!(
1076            test_helper.run_until_stalled_drain_cobalt_events(&mut test_fut),
1077            Poll::Ready(())
1078        );
1079
1080        let metrics =
1081            test_helper.get_logged_metrics(metrics::SUCCESSIVE_CONNECT_ATTEMPT_FAILURES_METRIC_ID);
1082        assert!(metrics.is_empty());
1083    }
1084
1085    #[test_case(1; "one_failure")]
1086    #[test_case(2; "two_failures")]
1087    #[fuchsia::test(add_test_attr = false)]
1088    fn test_one_or_more_successive_connect_attempt_failures_on_suspend(n_failures: usize) {
1089        let mut test_helper = setup_test();
1090        let logger = ConnectDisconnectLogger::new(
1091            test_helper.cobalt_proxy.clone(),
1092            &test_helper.inspect_node,
1093            &test_helper.inspect_metadata_node,
1094            &test_helper.inspect_metadata_path,
1095            &test_helper.mock_time_matrix_client,
1096        );
1097
1098        let bss_description = random_bss_description!(Wpa2);
1099        for _i in 0..n_failures {
1100            let mut test_fut = pin!(logger.handle_connect_attempt(
1101                fidl_ieee80211::StatusCode::RefusedReasonUnspecified,
1102                &bss_description,
1103                false
1104            ));
1105            assert_eq!(
1106                test_helper.run_until_stalled_drain_cobalt_events(&mut test_fut),
1107                Poll::Ready(())
1108            );
1109        }
1110
1111        let mut test_fut = pin!(logger.handle_suspend_imminent());
1112        assert_eq!(
1113            test_helper.run_until_stalled_drain_cobalt_events(&mut test_fut),
1114            Poll::Ready(())
1115        );
1116
1117        let metrics =
1118            test_helper.get_logged_metrics(metrics::SUCCESSIVE_CONNECT_ATTEMPT_FAILURES_METRIC_ID);
1119        assert_eq!(metrics.len(), 1);
1120        assert_eq!(metrics[0].payload, MetricEventPayload::IntegerValue(n_failures as i64));
1121
1122        test_helper.clear_cobalt_events();
1123        let mut test_fut = pin!(logger.handle_suspend_imminent());
1124        assert_eq!(
1125            test_helper.run_until_stalled_drain_cobalt_events(&mut test_fut),
1126            Poll::Ready(())
1127        );
1128
1129        // Count of successive failures shouldn't be logged again since it was already logged
1130        let metrics =
1131            test_helper.get_logged_metrics(metrics::SUCCESSIVE_CONNECT_ATTEMPT_FAILURES_METRIC_ID);
1132        assert!(metrics.is_empty());
1133
1134        // Verify that the connection state has transitioned to ConnectFailed
1135        assert_matches!(*logger.connection_state.lock(), ConnectionState::ConnectFailed(_));
1136    }
1137
1138    #[fuchsia::test]
1139    fn test_log_disconnect_inspect() {
1140        let mut test_helper = setup_test();
1141        let logger = ConnectDisconnectLogger::new(
1142            test_helper.cobalt_proxy.clone(),
1143            &test_helper.inspect_node,
1144            &test_helper.inspect_metadata_node,
1145            &test_helper.inspect_metadata_path,
1146            &test_helper.mock_time_matrix_client,
1147        );
1148
1149        // Log the event
1150        let bss_description = fake_bss_description!(Open);
1151        let channel = bss_description.channel;
1152        let disconnect_info = DisconnectInfo {
1153            iface_id: 32,
1154            connected_duration: zx::BootDuration::from_seconds(30),
1155            is_sme_reconnecting: false,
1156            disconnect_source: fidl_sme::DisconnectSource::Ap(fidl_sme::DisconnectCause {
1157                mlme_event_name: fidl_sme::DisconnectMlmeEventName::DeauthenticateIndication,
1158                reason_code: fidl_ieee80211::ReasonCode::UnspecifiedReason,
1159            }),
1160            original_bss_desc: Box::new(bss_description),
1161            current_rssi_dbm: -30,
1162            current_snr_db: 25,
1163            current_channel: channel,
1164        };
1165        let mut test_fut = pin!(logger.log_disconnect(&disconnect_info));
1166        assert_eq!(
1167            test_helper.run_until_stalled_drain_cobalt_events(&mut test_fut),
1168            Poll::Ready(())
1169        );
1170
1171        // Validate Inspect data
1172        let data = test_helper.get_inspect_data_tree();
1173        assert_data_tree!(@executor test_helper.exec, data, root: contains {
1174            test_stats: contains {
1175                metadata: contains {
1176                    connected_networks: {
1177                        "0": {
1178                            "@time": AnyNumericProperty,
1179                            "data": {
1180                                bssid: &*BSSID_REGEX,
1181                                ssid: &*SSID_REGEX,
1182                                ht_cap: AnyBytesProperty,
1183                                vht_cap: AnyBytesProperty,
1184                                protection: "Open",
1185                                is_wmm_assoc: AnyBoolProperty,
1186                                wmm_param: AnyBytesProperty,
1187                            }
1188                        }
1189                    },
1190                    disconnect_sources: {
1191                        "0": {
1192                            "@time": AnyNumericProperty,
1193                            "data": {
1194                                source: "ap",
1195                                reason: "UnspecifiedReason",
1196                                mlme_event_name: "DeauthenticateIndication",
1197                            }
1198                        }
1199                    },
1200                },
1201                disconnect_events: {
1202                    "0": {
1203                        "@time": AnyNumericProperty,
1204                        connected_duration: zx::BootDuration::from_seconds(30).into_nanos(),
1205                        disconnect_source_id: 0u64,
1206                        network_id: 0u64,
1207                        rssi_dbm: -30i64,
1208                        snr_db: 25i64,
1209                        channel: AnyStringProperty,
1210                    }
1211                }
1212            }
1213        });
1214
1215        let mut time_matrix_calls = test_helper.mock_time_matrix_client.drain_calls();
1216        assert_eq!(
1217            &time_matrix_calls.drain::<u64>("wlan_connectivity_states")[..],
1218            &[TimeMatrixCall::Fold(Timed::now(1 << 0)), TimeMatrixCall::Fold(Timed::now(1 << 1)),]
1219        );
1220        assert_eq!(
1221            &time_matrix_calls.drain::<u64>("disconnected_networks")[..],
1222            &[TimeMatrixCall::Fold(Timed::now(1 << 0))]
1223        );
1224        assert_eq!(
1225            &time_matrix_calls.drain::<u64>("disconnect_sources")[..],
1226            &[TimeMatrixCall::Fold(Timed::now(1 << 0))]
1227        );
1228    }
1229
1230    #[fuchsia::test]
1231    fn test_log_disconnect_cobalt() {
1232        let mut test_helper = setup_test();
1233        let logger = ConnectDisconnectLogger::new(
1234            test_helper.cobalt_proxy.clone(),
1235            &test_helper.inspect_node,
1236            &test_helper.inspect_metadata_node,
1237            &test_helper.inspect_metadata_path,
1238            &test_helper.mock_time_matrix_client,
1239        );
1240
1241        // Log the event
1242        let disconnect_info = DisconnectInfo {
1243            connected_duration: zx::BootDuration::from_millis(300_000),
1244            disconnect_source: fidl_sme::DisconnectSource::Ap(fidl_sme::DisconnectCause {
1245                mlme_event_name: fidl_sme::DisconnectMlmeEventName::DeauthenticateIndication,
1246                reason_code: fidl_ieee80211::ReasonCode::ApInitiated,
1247            }),
1248            ..fake_disconnect_info()
1249        };
1250        let mut test_fut = pin!(logger.log_disconnect(&disconnect_info));
1251        assert_eq!(
1252            test_helper.run_until_stalled_drain_cobalt_events(&mut test_fut),
1253            Poll::Ready(())
1254        );
1255
1256        let disconnect_count_metrics =
1257            test_helper.get_logged_metrics(metrics::TOTAL_DISCONNECT_COUNT_METRIC_ID);
1258        assert_eq!(disconnect_count_metrics.len(), 1);
1259        assert_eq!(disconnect_count_metrics[0].payload, MetricEventPayload::Count(1));
1260
1261        let connected_duration_metrics =
1262            test_helper.get_logged_metrics(metrics::CONNECTED_DURATION_ON_DISCONNECT_METRIC_ID);
1263        assert_eq!(connected_duration_metrics.len(), 1);
1264        assert_eq!(
1265            connected_duration_metrics[0].payload,
1266            MetricEventPayload::IntegerValue(300_000)
1267        );
1268
1269        let disconnect_by_reason_metrics =
1270            test_helper.get_logged_metrics(metrics::DISCONNECT_BREAKDOWN_BY_REASON_CODE_METRIC_ID);
1271        assert_eq!(disconnect_by_reason_metrics.len(), 1);
1272        assert_eq!(disconnect_by_reason_metrics[0].payload, MetricEventPayload::Count(1));
1273        assert_eq!(disconnect_by_reason_metrics[0].event_codes.len(), 2);
1274        assert_eq!(
1275            disconnect_by_reason_metrics[0].event_codes[0],
1276            fidl_ieee80211::ReasonCode::ApInitiated.into_primitive() as u32
1277        );
1278        assert_eq!(
1279            disconnect_by_reason_metrics[0].event_codes[1],
1280            metrics::ConnectivityWlanMetricDimensionDisconnectSource::Ap as u32
1281        );
1282    }
1283
1284    #[test_case(
1285        fidl_sme::DisconnectSource::Ap(fidl_sme::DisconnectCause {
1286            mlme_event_name: fidl_sme::DisconnectMlmeEventName::DeauthenticateIndication,
1287            reason_code: fidl_ieee80211::ReasonCode::UnspecifiedReason,
1288        }),
1289        true;
1290        "ap_disconnect_source"
1291    )]
1292    #[test_case(
1293        fidl_sme::DisconnectSource::Mlme(fidl_sme::DisconnectCause {
1294            mlme_event_name: fidl_sme::DisconnectMlmeEventName::DeauthenticateIndication,
1295            reason_code: fidl_ieee80211::ReasonCode::UnspecifiedReason,
1296        }),
1297        true;
1298        "mlme_disconnect_source_not_link_failed"
1299    )]
1300    #[test_case(
1301        fidl_sme::DisconnectSource::Mlme(fidl_sme::DisconnectCause {
1302            mlme_event_name: fidl_sme::DisconnectMlmeEventName::DeauthenticateIndication,
1303            reason_code: fidl_ieee80211::ReasonCode::MlmeLinkFailed,
1304        }),
1305        false;
1306        "mlme_link_failed"
1307    )]
1308    #[test_case(
1309        fidl_sme::DisconnectSource::User(fidl_sme::UserDisconnectReason::Unknown),
1310        false;
1311        "user_disconnect_source"
1312    )]
1313    #[fuchsia::test(add_test_attr = false)]
1314    fn test_log_disconnect_for_mobile_device_cobalt(
1315        disconnect_source: fidl_sme::DisconnectSource,
1316        should_log: bool,
1317    ) {
1318        let mut test_helper = setup_test();
1319        let logger = ConnectDisconnectLogger::new(
1320            test_helper.cobalt_proxy.clone(),
1321            &test_helper.inspect_node,
1322            &test_helper.inspect_metadata_node,
1323            &test_helper.inspect_metadata_path,
1324            &test_helper.mock_time_matrix_client,
1325        );
1326
1327        // Log the event
1328        let disconnect_info = DisconnectInfo { disconnect_source, ..fake_disconnect_info() };
1329        let mut test_fut = pin!(logger.log_disconnect(&disconnect_info));
1330        assert_eq!(
1331            test_helper.run_until_stalled_drain_cobalt_events(&mut test_fut),
1332            Poll::Ready(())
1333        );
1334
1335        let metrics = test_helper
1336            .get_logged_metrics(metrics::DISCONNECT_OCCURRENCE_FOR_MOBILE_DEVICE_METRIC_ID);
1337        if should_log {
1338            assert_eq!(metrics.len(), 1);
1339            assert_eq!(metrics[0].payload, MetricEventPayload::Count(1));
1340            assert_matches!(*logger.connection_state.lock(), ConnectionState::Disconnected(_));
1341        } else {
1342            assert!(metrics.is_empty());
1343            assert_matches!(*logger.connection_state.lock(), ConnectionState::Idle(_));
1344        }
1345    }
1346
1347    #[fuchsia::test]
1348    fn test_log_downtime_post_disconnect_on_reconnect() {
1349        let mut test_helper = setup_test();
1350        let logger = ConnectDisconnectLogger::new(
1351            test_helper.cobalt_proxy.clone(),
1352            &test_helper.inspect_node,
1353            &test_helper.inspect_metadata_node,
1354            &test_helper.inspect_metadata_path,
1355            &test_helper.mock_time_matrix_client,
1356        );
1357
1358        // Connect at 15th second
1359        test_helper.exec.set_fake_time(fasync::MonotonicInstant::from_nanos(15_000_000_000));
1360        let bss_description = random_bss_description!(Wpa2);
1361        let mut test_fut = pin!(logger.handle_connect_attempt(
1362            fidl_ieee80211::StatusCode::Success,
1363            &bss_description,
1364            false
1365        ));
1366        assert_eq!(
1367            test_helper.run_until_stalled_drain_cobalt_events(&mut test_fut),
1368            Poll::Ready(())
1369        );
1370
1371        // Verify no downtime metric is logged on first successful connect
1372        let metrics = test_helper.get_logged_metrics(metrics::DOWNTIME_POST_DISCONNECT_METRIC_ID);
1373        assert!(metrics.is_empty());
1374
1375        // Verify that the connection state has transitioned to Connected
1376        assert_matches!(*logger.connection_state.lock(), ConnectionState::Connected(_));
1377
1378        // Disconnect at 25th second
1379        test_helper.exec.set_fake_time(fasync::MonotonicInstant::from_nanos(25_000_000_000));
1380        let disconnect_info = DisconnectInfo {
1381            connected_duration: zx::BootDuration::from_millis(300_000),
1382            disconnect_source: fidl_sme::DisconnectSource::Ap(fidl_sme::DisconnectCause {
1383                mlme_event_name: fidl_sme::DisconnectMlmeEventName::DeauthenticateIndication,
1384                reason_code: fidl_ieee80211::ReasonCode::ApInitiated,
1385            }),
1386            ..fake_disconnect_info()
1387        };
1388        let mut test_fut = pin!(logger.log_disconnect(&disconnect_info));
1389        assert_eq!(
1390            test_helper.run_until_stalled_drain_cobalt_events(&mut test_fut),
1391            Poll::Ready(())
1392        );
1393
1394        // Verify that the connection state has transitioned to Disconnected
1395        assert_matches!(*logger.connection_state.lock(), ConnectionState::Disconnected(_));
1396
1397        // Reconnect at 60th second
1398        test_helper.exec.set_fake_time(fasync::MonotonicInstant::from_nanos(60_000_000_000));
1399        let mut test_fut = pin!(logger.handle_connect_attempt(
1400            fidl_ieee80211::StatusCode::Success,
1401            &bss_description,
1402            false
1403        ));
1404        assert_eq!(
1405            test_helper.run_until_stalled_drain_cobalt_events(&mut test_fut),
1406            Poll::Ready(())
1407        );
1408
1409        // Verify that downtime metric is logged
1410        let metrics = test_helper.get_logged_metrics(metrics::DOWNTIME_POST_DISCONNECT_METRIC_ID);
1411        assert_eq!(metrics.len(), 1);
1412        assert_eq!(metrics[0].payload, MetricEventPayload::IntegerValue(35_000));
1413
1414        // Verify that the connection state has transitioned to Connected
1415        assert_matches!(*logger.connection_state.lock(), ConnectionState::Connected(_));
1416    }
1417
1418    #[fuchsia::test]
1419    fn test_log_iface_destroyed() {
1420        let mut test_helper = setup_test();
1421        let logger = ConnectDisconnectLogger::new(
1422            test_helper.cobalt_proxy.clone(),
1423            &test_helper.inspect_node,
1424            &test_helper.inspect_metadata_node,
1425            &test_helper.inspect_metadata_path,
1426            &test_helper.mock_time_matrix_client,
1427        );
1428
1429        // Log connect event to move state to connected
1430        let bss_description = random_bss_description!();
1431        let mut test_fut = pin!(logger.handle_connect_attempt(
1432            fidl_ieee80211::StatusCode::Success,
1433            &bss_description,
1434            false
1435        ));
1436        assert_eq!(
1437            test_helper.run_until_stalled_drain_cobalt_events(&mut test_fut),
1438            Poll::Ready(())
1439        );
1440
1441        // Verify that the connection state has transitioned to Connected
1442        assert_matches!(*logger.connection_state.lock(), ConnectionState::Connected(_));
1443
1444        // Log iface destroyed event to move state to idle
1445        let mut test_fut = pin!(logger.handle_iface_destroyed());
1446        assert_eq!(
1447            test_helper.run_until_stalled_drain_cobalt_events(&mut test_fut),
1448            Poll::Ready(())
1449        );
1450
1451        let mut time_matrix_calls = test_helper.mock_time_matrix_client.drain_calls();
1452        assert_eq!(
1453            &time_matrix_calls.drain::<u64>("wlan_connectivity_states")[..],
1454            &[
1455                TimeMatrixCall::Fold(Timed::now(1 << 0)),
1456                TimeMatrixCall::Fold(Timed::now(1 << 3)),
1457                TimeMatrixCall::Fold(Timed::now(1 << 0))
1458            ]
1459        );
1460
1461        // Verify that the connection state has transitioned to Idle
1462        assert_matches!(*logger.connection_state.lock(), ConnectionState::Idle(_));
1463    }
1464
1465    #[fuchsia::test]
1466    fn test_log_disable_client_connections() {
1467        let mut test_helper = setup_test();
1468        let logger = ConnectDisconnectLogger::new(
1469            test_helper.cobalt_proxy.clone(),
1470            &test_helper.inspect_node,
1471            &test_helper.inspect_metadata_node,
1472            &test_helper.inspect_metadata_path,
1473            &test_helper.mock_time_matrix_client,
1474        );
1475
1476        // Log connect event to move state to connected
1477        let bss_description = random_bss_description!();
1478        let mut test_fut = pin!(logger.handle_connect_attempt(
1479            fidl_ieee80211::StatusCode::Success,
1480            &bss_description,
1481            false
1482        ));
1483        assert_eq!(
1484            test_helper.run_until_stalled_drain_cobalt_events(&mut test_fut),
1485            Poll::Ready(())
1486        );
1487
1488        // Verify that the connection state has transitioned to Connected
1489        assert_matches!(*logger.connection_state.lock(), ConnectionState::Connected(_));
1490
1491        // Disable client connections to move state to idle
1492        let mut test_fut =
1493            pin!(logger.handle_client_connections_toggle(&ClientConnectionsToggleEvent::Disabled));
1494        assert_eq!(
1495            test_helper.run_until_stalled_drain_cobalt_events(&mut test_fut),
1496            Poll::Ready(())
1497        );
1498
1499        let mut time_matrix_calls = test_helper.mock_time_matrix_client.drain_calls();
1500        assert_eq!(
1501            &time_matrix_calls.drain::<u64>("wlan_connectivity_states")[..],
1502            &[
1503                TimeMatrixCall::Fold(Timed::now(1 << 0)),
1504                TimeMatrixCall::Fold(Timed::now(1 << 3)),
1505                TimeMatrixCall::Fold(Timed::now(1 << 0))
1506            ]
1507        );
1508
1509        // Verify that the connection state has transitioned to Idle
1510        assert_matches!(*logger.connection_state.lock(), ConnectionState::Idle(_));
1511    }
1512
1513    #[fuchsia::test]
1514    fn test_wlan_connectivity_states_credential_rejected() {
1515        let mut test_helper = setup_test();
1516        let logger = ConnectDisconnectLogger::new(
1517            test_helper.cobalt_proxy.clone(),
1518            &test_helper.inspect_node,
1519            &test_helper.inspect_metadata_node,
1520            &test_helper.inspect_metadata_path,
1521            &test_helper.mock_time_matrix_client,
1522        );
1523
1524        // Log connect failure with credential rejected to move state to idle
1525        let bss_description = random_bss_description!();
1526        let mut test_fut = pin!(logger.handle_connect_attempt(
1527            fidl_ieee80211::StatusCode::RefusedReasonUnspecified,
1528            &bss_description,
1529            true
1530        ));
1531        assert_eq!(
1532            test_helper.run_until_stalled_drain_cobalt_events(&mut test_fut),
1533            Poll::Ready(())
1534        );
1535
1536        assert_matches!(*logger.connection_state.lock(), ConnectionState::Idle(_));
1537    }
1538
1539    #[fuchsia::test]
1540    fn test_wlan_connectivity_states_failed_to_start() {
1541        let mut test_helper = setup_test();
1542        let logger = ConnectDisconnectLogger::new(
1543            test_helper.cobalt_proxy.clone(),
1544            &test_helper.inspect_node,
1545            &test_helper.inspect_metadata_node,
1546            &test_helper.inspect_metadata_path,
1547            &test_helper.mock_time_matrix_client,
1548        );
1549
1550        let mut test_fut = pin!(logger.handle_client_connections_failed_to_start());
1551        assert_eq!(
1552            test_helper.run_until_stalled_drain_cobalt_events(&mut test_fut),
1553            Poll::Ready(())
1554        );
1555
1556        let mut time_matrix_calls = test_helper.mock_time_matrix_client.drain_calls();
1557        assert_eq!(
1558            &time_matrix_calls.drain::<u64>("wlan_connectivity_states")[..],
1559            &[
1560                TimeMatrixCall::Fold(Timed::now(1 << 0)), // Initialization
1561                TimeMatrixCall::Fold(Timed::now(1 << 4)), // FailedToStart ID is 4 -> bit 1 << 4
1562            ]
1563        );
1564        assert_matches!(*logger.connection_state.lock(), ConnectionState::FailedToStart(_));
1565    }
1566
1567    #[fuchsia::test]
1568    fn test_wlan_connectivity_states_failed_to_stop() {
1569        let mut test_helper = setup_test();
1570        let logger = ConnectDisconnectLogger::new(
1571            test_helper.cobalt_proxy.clone(),
1572            &test_helper.inspect_node,
1573            &test_helper.inspect_metadata_node,
1574            &test_helper.inspect_metadata_path,
1575            &test_helper.mock_time_matrix_client,
1576        );
1577
1578        let mut test_fut = pin!(logger.handle_client_connections_failed_to_stop());
1579        assert_eq!(
1580            test_helper.run_until_stalled_drain_cobalt_events(&mut test_fut),
1581            Poll::Ready(())
1582        );
1583
1584        let mut time_matrix_calls = test_helper.mock_time_matrix_client.drain_calls();
1585        assert_eq!(
1586            &time_matrix_calls.drain::<u64>("wlan_connectivity_states")[..],
1587            &[
1588                TimeMatrixCall::Fold(Timed::now(1 << 0)), // Initialization
1589                TimeMatrixCall::Fold(Timed::now(1 << 5)), // FailedToStop ID is 5 -> bit 1 << 5
1590            ]
1591        );
1592
1593        assert_matches!(*logger.connection_state.lock(), ConnectionState::FailedToStop(_));
1594    }
1595
1596    #[test_case(ConnectionState::Idle(IdleState {}))]
1597    #[test_case(ConnectionState::Disconnected(DisconnectedState {}))]
1598    #[test_case(ConnectionState::ConnectFailed(ConnectFailedState {}))]
1599    #[test_case(ConnectionState::PnoScanFailedIdle(PnoScanFailedIdleState {}))]
1600    fn test_connectivity_state_transition_on_pno_scan_failure(initial_state: ConnectionState) {
1601        let mut test_helper = setup_test();
1602        let logger = ConnectDisconnectLogger::new(
1603            test_helper.cobalt_proxy.clone(),
1604            &test_helper.inspect_node,
1605            &test_helper.inspect_metadata_node,
1606            &test_helper.inspect_metadata_path,
1607            &test_helper.mock_time_matrix_client,
1608        );
1609
1610        // Transition to initial state
1611        *logger.connection_state.lock() = initial_state.clone();
1612
1613        // Log a PNO scan failure
1614        let mut test_fut = pin!(logger.handle_pno_scan_failure());
1615        assert_matches!(
1616            test_helper.run_until_stalled_drain_cobalt_events(&mut test_fut),
1617            Poll::Ready(())
1618        );
1619
1620        // Verify the metrics were logged
1621        let metric_events = test_helper
1622            .get_logged_metrics(metrics::PNO_SCAN_FAILURE_WHILE_NOT_CONNECTED_OCCURRENCE_METRIC_ID);
1623        assert_eq!(metric_events.len(), 1);
1624        assert_eq!(metric_events[0].payload, MetricEventPayload::Count(1));
1625
1626        let metric_events =
1627            test_helper.get_logged_metrics(metrics::PNO_SCAN_FAILURE_OCCURRENCE_METRIC_ID);
1628        assert_eq!(metric_events.len(), 1);
1629        assert_eq!(metric_events[0].payload, MetricEventPayload::Count(1));
1630
1631        // Verify the time matrix shows the PNO scan failure state
1632        let mut time_matrix_calls = test_helper.mock_time_matrix_client.drain_calls();
1633        assert_eq!(
1634            *time_matrix_calls.drain::<u64>("wlan_connectivity_states")[..].last().unwrap(),
1635            TimeMatrixCall::Fold(Timed::now(1 << 6)), // PnoScanFailedIdle ID is 6 -> bit 1 << 6
1636        );
1637
1638        // A PNO scan failure should cause a transition to PnoScanFailedIdle
1639        assert_matches!(*logger.connection_state.lock(), ConnectionState::PnoScanFailedIdle(_));
1640    }
1641
1642    #[test_case(ConnectionState::Connected(ConnectedState {}))]
1643    #[test_case(ConnectionState::FailedToStart(FailedToStartState {}))]
1644    #[test_case(ConnectionState::FailedToStop(FailedToStopState {}))]
1645    fn test_no_connectivity_state_transition_on_pno_scan_failure(initial_state: ConnectionState) {
1646        let mut test_helper = setup_test();
1647        let logger = ConnectDisconnectLogger::new(
1648            test_helper.cobalt_proxy.clone(),
1649            &test_helper.inspect_node,
1650            &test_helper.inspect_metadata_node,
1651            &test_helper.inspect_metadata_path,
1652            &test_helper.mock_time_matrix_client,
1653        );
1654
1655        // Transition to initial state
1656        *logger.connection_state.lock() = initial_state.clone();
1657
1658        // Log a PNO scan failure
1659        let mut test_fut = pin!(logger.handle_pno_scan_failure());
1660        assert_matches!(
1661            test_helper.run_until_stalled_drain_cobalt_events(&mut test_fut),
1662            Poll::Ready(())
1663        );
1664
1665        // Verify the metrics were logged
1666        let metric_events =
1667            test_helper.get_logged_metrics(metrics::PNO_SCAN_FAILURE_OCCURRENCE_METRIC_ID);
1668        assert_eq!(metric_events.len(), 1);
1669        assert_eq!(metric_events[0].payload, MetricEventPayload::Count(1));
1670
1671        // State should not change
1672        assert_eq!(logger.connection_state.lock().to_id(), initial_state.to_id());
1673    }
1674
1675    #[fuchsia::test]
1676    fn test_wlan_connectivity_states_bitset_map_size() {
1677        let enum_variant_count = ConnectionState::iter().count();
1678        let bitset_map_size =
1679            ConnectDisconnectTimeSeries::wlan_connectivity_states_bitset_map().len();
1680        assert_eq!(enum_variant_count, bitset_map_size);
1681    }
1682
1683    fn fake_disconnect_info() -> DisconnectInfo {
1684        let bss_description = random_bss_description!(Wpa2);
1685        let channel = bss_description.channel;
1686        DisconnectInfo {
1687            iface_id: 1,
1688            connected_duration: zx::BootDuration::from_hours(6),
1689            is_sme_reconnecting: false,
1690            disconnect_source: fidl_sme::DisconnectSource::User(
1691                fidl_sme::UserDisconnectReason::Unknown,
1692            ),
1693            original_bss_desc: bss_description.into(),
1694            current_rssi_dbm: -30,
1695            current_snr_db: 25,
1696            current_channel: channel,
1697        }
1698    }
1699}