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::config::DeviceMobility;
6use crate::convert::{
7    convert_channel_band, convert_is_owe_transition, convert_rssi_bucket, convert_security_type,
8    convert_snr_bucket,
9};
10use crate::processors::toggle_events::ClientConnectionsToggleEvent;
11use crate::util::cobalt_logger::{FilteredCobaltLogger, log_cobalt_batch};
12use derivative::Derivative;
13use fidl_fuchsia_metrics::{MetricEvent, MetricEventPayload};
14use fidl_fuchsia_wlan_ieee80211 as fidl_ieee80211;
15use fidl_fuchsia_wlan_sme as fidl_sme;
16use fuchsia_async as fasync;
17use fuchsia_inspect::Node as InspectNode;
18use fuchsia_inspect_contrib::id_enum::IdEnum;
19use fuchsia_inspect_contrib::inspect_log;
20use fuchsia_inspect_contrib::nodes::{BoundedListNode, LruCacheNode};
21use fuchsia_inspect_derive::Unit;
22use fuchsia_sync::Mutex;
23use ieee80211::OuiFmt;
24use std::collections::HashMap;
25use std::sync::Arc;
26use std::sync::atomic::{AtomicUsize, Ordering};
27use strum_macros::{Display, EnumIter};
28use windowed_stats::experimental::inspect::{InspectSender, InspectedTimeMatrix};
29use windowed_stats::experimental::series::interpolation::{ConstantSample, LastSample};
30use windowed_stats::experimental::series::metadata::{BitsetMap, BitsetNode};
31use windowed_stats::experimental::series::statistic::Union;
32use windowed_stats::experimental::series::{SamplingProfile, TimeMatrix};
33use wlan_common::bss::BssDescription;
34use wlan_common::channel::Channel;
35use wlan_legacy_metrics_registry as metrics;
36use zx;
37
38const INSPECT_CONNECT_EVENTS_LIMIT: usize = 10;
39const INSPECT_DISCONNECT_EVENTS_LIMIT: usize = 20;
40const INSPECT_CONNECT_ATTEMPT_RESULTS_LIMIT: usize = 50;
41const INSPECT_CONNECTED_NETWORKS_ID_LIMIT: usize = 16;
42const INSPECT_DISCONNECT_SOURCES_ID_LIMIT: usize = 32;
43const INSPECT_CONNECT_ATTEMPT_RESULTS_ID_LIMIT: usize = 32;
44const SUCCESSIVE_CONNECT_ATTEMPT_FAILURES_TIMEOUT: zx::BootDuration =
45    zx::BootDuration::from_minutes(2);
46const DAILY_METRICS_LOG_INTERVAL: zx::BootDuration = zx::BootDuration::from_hours(24);
47
48#[derive(Clone, Debug, Display, EnumIter)]
49enum ConnectionState {
50    Idle(IdleState),
51    Connected(ConnectedState),
52    Disconnected(DisconnectedState),
53    ConnectFailed(ConnectFailedState),
54    FailedToStart(FailedToStartState),
55    FailedToStop(FailedToStopState),
56    PnoScanFailedIdle(PnoScanFailedIdleState),
57}
58
59// Update the ConnectDisconnectTimeSeries BitsetMap when making changes to this enum.
60impl IdEnum for ConnectionState {
61    type Id = u8;
62    fn to_id(&self) -> Self::Id {
63        match self {
64            Self::Idle(_) => 0,
65            Self::Disconnected(_) => 1,
66            Self::ConnectFailed(_) => 2,
67            Self::Connected(_) => 3,
68            Self::FailedToStart(_) => 4,
69            Self::FailedToStop(_) => 5,
70            Self::PnoScanFailedIdle(_) => 6,
71        }
72    }
73}
74
75#[derive(Clone, Debug, Default)]
76struct IdleState {}
77
78#[derive(Clone, Debug, Default)]
79struct ConnectedState {}
80
81#[derive(Clone, Debug, Default)]
82struct DisconnectedState {}
83
84#[derive(Clone, Debug, Default)]
85struct ConnectFailedState {}
86
87#[derive(Clone, Debug, Default)]
88struct FailedToStartState {}
89
90#[derive(Clone, Debug, Default)]
91struct FailedToStopState {}
92
93#[derive(Clone, Debug, Default)]
94struct PnoScanFailedIdleState {}
95
96#[derive(Derivative, Unit)]
97#[derivative(PartialEq, Eq, Hash)]
98struct InspectConnectedNetwork {
99    bssid: String,
100    ssid: String,
101    protection: String,
102    ht_cap: Option<Vec<u8>>,
103    vht_cap: Option<Vec<u8>>,
104    #[derivative(PartialEq = "ignore")]
105    #[derivative(Hash = "ignore")]
106    wsc: Option<InspectNetworkWsc>,
107    is_wmm_assoc: bool,
108    wmm_param: Option<Vec<u8>>,
109}
110
111impl From<&BssDescription> for InspectConnectedNetwork {
112    fn from(bss_description: &BssDescription) -> Self {
113        Self {
114            bssid: bss_description.bssid.to_string(),
115            ssid: bss_description.ssid.to_string(),
116            protection: format!("{:?}", bss_description.protection()),
117            ht_cap: bss_description.raw_ht_cap().map(|cap| cap.bytes.into()),
118            vht_cap: bss_description.raw_vht_cap().map(|cap| cap.bytes.into()),
119            wsc: bss_description.probe_resp_wsc().as_ref().map(InspectNetworkWsc::from),
120            is_wmm_assoc: bss_description.find_wmm_param().is_some(),
121            wmm_param: bss_description.find_wmm_param().map(|bytes| bytes.into()),
122        }
123    }
124}
125
126#[derive(PartialEq, Unit, Hash)]
127struct InspectNetworkWsc {
128    device_name: String,
129    manufacturer: String,
130    model_name: String,
131    model_number: String,
132}
133
134impl From<&wlan_common::ie::wsc::ProbeRespWsc> for InspectNetworkWsc {
135    fn from(wsc: &wlan_common::ie::wsc::ProbeRespWsc) -> Self {
136        Self {
137            device_name: String::from_utf8_lossy(&wsc.device_name[..]).to_string(),
138            manufacturer: String::from_utf8_lossy(&wsc.manufacturer[..]).to_string(),
139            model_name: String::from_utf8_lossy(&wsc.model_name[..]).to_string(),
140            model_number: String::from_utf8_lossy(&wsc.model_number[..]).to_string(),
141        }
142    }
143}
144
145#[derive(PartialEq, Eq, Unit, Hash)]
146struct InspectConnectAttemptResult {
147    status_code: u16,
148    result: String,
149}
150
151#[derive(PartialEq, Eq, Unit, Hash)]
152struct InspectDisconnectSource {
153    source: String,
154    reason: String,
155    mlme_event_name: Option<String>,
156}
157
158impl From<&fidl_sme::DisconnectSource> for InspectDisconnectSource {
159    fn from(disconnect_source: &fidl_sme::DisconnectSource) -> Self {
160        match disconnect_source {
161            fidl_sme::DisconnectSource::User(reason) => Self {
162                source: "user".to_string(),
163                reason: format!("{reason:?}"),
164                mlme_event_name: None,
165            },
166            fidl_sme::DisconnectSource::Ap(cause) => Self {
167                source: "ap".to_string(),
168                reason: format!("{:?}", cause.reason_code),
169                mlme_event_name: Some(format!("{:?}", cause.mlme_event_name)),
170            },
171            fidl_sme::DisconnectSource::Mlme(cause) => Self {
172                source: "mlme".to_string(),
173                reason: format!("{:?}", cause.reason_code),
174                mlme_event_name: Some(format!("{:?}", cause.mlme_event_name)),
175            },
176        }
177    }
178}
179
180#[derive(Clone, Debug, PartialEq)]
181pub struct DisconnectInfo {
182    pub iface_id: u16,
183    pub connected_duration: zx::BootDuration,
184    pub is_sme_reconnecting: bool,
185    pub disconnect_source: fidl_sme::DisconnectSource,
186    pub original_bss_desc: Box<BssDescription>,
187    pub current_rssi_dbm: i8,
188    pub current_snr_db: i8,
189    pub current_channel: Channel,
190}
191
192pub struct ConnectDisconnectLogger {
193    connection_state: Arc<Mutex<ConnectionState>>,
194    cobalt_proxy: Arc<FilteredCobaltLogger>,
195    connect_events_node: Mutex<BoundedListNode>,
196    disconnect_events_node: Mutex<BoundedListNode>,
197    connect_attempt_results_node: Mutex<BoundedListNode>,
198    inspect_metadata_node: Mutex<InspectMetadataNode>,
199    time_series_stats: ConnectDisconnectTimeSeries,
200    successive_connect_attempt_failures: AtomicUsize,
201    last_connect_failure_at: Arc<Mutex<Option<fasync::BootInstant>>>,
202    last_disconnect_at: Arc<Mutex<Option<fasync::MonotonicInstant>>>,
203    daily_connect_stats: Mutex<DailyConnectStats>,
204    device_mobility: DeviceMobility,
205}
206
207impl ConnectDisconnectLogger {
208    pub fn new<S: InspectSender>(
209        cobalt_proxy: Arc<FilteredCobaltLogger>,
210        inspect_node: &InspectNode,
211        inspect_metadata_node: &InspectNode,
212        inspect_metadata_path: &str,
213        time_matrix_client: &S,
214        device_mobility: DeviceMobility,
215    ) -> Self {
216        let connect_events = inspect_node.create_child("connect_events");
217        let disconnect_events = inspect_node.create_child("disconnect_events");
218        let connect_attempt_results = inspect_node.create_child("connect_attempt_results");
219        let this = Self {
220            cobalt_proxy,
221            connection_state: Arc::new(Mutex::new(ConnectionState::Idle(IdleState {}))),
222            connect_events_node: Mutex::new(BoundedListNode::new(
223                connect_events,
224                INSPECT_CONNECT_EVENTS_LIMIT,
225            )),
226            disconnect_events_node: Mutex::new(BoundedListNode::new(
227                disconnect_events,
228                INSPECT_DISCONNECT_EVENTS_LIMIT,
229            )),
230            connect_attempt_results_node: Mutex::new(BoundedListNode::new(
231                connect_attempt_results,
232                INSPECT_CONNECT_ATTEMPT_RESULTS_LIMIT,
233            )),
234            inspect_metadata_node: Mutex::new(InspectMetadataNode::new(inspect_metadata_node)),
235            time_series_stats: ConnectDisconnectTimeSeries::new(
236                time_matrix_client,
237                inspect_metadata_path,
238            ),
239            successive_connect_attempt_failures: AtomicUsize::new(0),
240            last_connect_failure_at: Arc::new(Mutex::new(None)),
241            last_disconnect_at: Arc::new(Mutex::new(None)),
242            daily_connect_stats: Mutex::new(DailyConnectStats::new(fasync::BootInstant::now())),
243            device_mobility,
244        };
245        this.log_connection_state();
246        this
247    }
248
249    fn update_connection_state(&self, state: ConnectionState) {
250        *self.connection_state.lock() = state;
251        self.log_connection_state();
252    }
253
254    fn log_connection_state(&self) {
255        let wlan_connectivity_state_id = self.connection_state.lock().to_id() as u64;
256        self.time_series_stats.log_wlan_connectivity_state(1 << wlan_connectivity_state_id);
257    }
258
259    pub fn is_connected(&self) -> bool {
260        matches!(*self.connection_state.lock(), ConnectionState::Connected(_))
261    }
262
263    pub async fn handle_connect_attempt(
264        &self,
265        result: fidl_ieee80211::StatusCode,
266        bss: &BssDescription,
267        is_credential_rejected: bool,
268        is_owe_transition: bool,
269    ) {
270        let mut flushed_successive_failures = None;
271        let mut downtime_duration = None;
272        if result == fidl_ieee80211::StatusCode::Success {
273            self.update_connection_state(ConnectionState::Connected(ConnectedState {}));
274            flushed_successive_failures =
275                Some(self.successive_connect_attempt_failures.swap(0, Ordering::SeqCst));
276            downtime_duration =
277                self.last_disconnect_at.lock().map(|t| fasync::MonotonicInstant::now() - t);
278        } else if is_credential_rejected {
279            self.update_connection_state(ConnectionState::Idle(IdleState {}));
280            let _prev = self.successive_connect_attempt_failures.fetch_add(1, Ordering::SeqCst);
281            let _prev = self.last_connect_failure_at.lock().replace(fasync::BootInstant::now());
282        } else {
283            self.update_connection_state(ConnectionState::ConnectFailed(ConnectFailedState {}));
284            let _prev = self.successive_connect_attempt_failures.fetch_add(1, Ordering::SeqCst);
285            let _prev = self.last_connect_failure_at.lock().replace(fasync::BootInstant::now());
286        }
287
288        self.log_connect_attempt_inspect(result, bss);
289        self.log_connect_attempt_cobalt(result, flushed_successive_failures, downtime_duration)
290            .await;
291        if result == fidl_ieee80211::StatusCode::Success {
292            self.log_device_connected_cobalt_metrics(bss, is_owe_transition).await;
293        }
294
295        let security_type = convert_security_type(&bss.protection());
296        let primary_channel = bss.channel.primary;
297        let channel_band = convert_channel_band(bss.channel.band);
298        let rssi_bucket = convert_rssi_bucket(bss.rssi_dbm);
299        let snr_bucket = convert_snr_bucket(bss.snr_db);
300        let is_owe_transition_dim = convert_is_owe_transition(is_owe_transition);
301
302        let mut daily_stats = self.daily_connect_stats.lock();
303        daily_stats.connect_per_security_type.entry(security_type).or_default().increment(result);
304        daily_stats
305            .connect_per_primary_channel
306            .entry(primary_channel)
307            .or_default()
308            .increment(result);
309        daily_stats.connect_per_channel_band.entry(channel_band).or_default().increment(result);
310        daily_stats.connect_per_rssi_bucket.entry(rssi_bucket).or_default().increment(result);
311        daily_stats.connect_per_snr_bucket.entry(snr_bucket).or_default().increment(result);
312        daily_stats
313            .connect_per_is_owe_transition
314            .entry(is_owe_transition_dim)
315            .or_default()
316            .increment(result);
317    }
318
319    fn log_connect_attempt_inspect(
320        &self,
321        result: fidl_ieee80211::StatusCode,
322        bss: &BssDescription,
323    ) {
324        let mut inspect_metadata_node = self.inspect_metadata_node.lock();
325        let connect_result_id =
326            inspect_metadata_node.connect_attempt_results.insert(InspectConnectAttemptResult {
327                status_code: result.into_primitive(),
328                result: format!("{:?}", result),
329            }) as u64;
330        self.time_series_stats.log_connect_attempt_results(1 << connect_result_id);
331
332        inspect_log!(self.connect_attempt_results_node.lock(), {
333            result: format!("{:?}", result),
334            ssid: bss.ssid.to_string(),
335            bssid: bss.bssid.to_string(),
336            protection: format!("{:?}", bss.protection()),
337        });
338
339        if result == fidl_ieee80211::StatusCode::Success {
340            let connected_network = InspectConnectedNetwork::from(bss);
341            let connected_network_id =
342                inspect_metadata_node.connected_networks.insert(connected_network) as u64;
343
344            self.time_series_stats.log_connected_networks(1 << connected_network_id);
345
346            inspect_log!(self.connect_events_node.lock(), {
347                network_id: connected_network_id,
348            });
349        }
350    }
351
352    #[allow(clippy::vec_init_then_push, reason = "mass allow for https://fxbug.dev/381896734")]
353    async fn log_connect_attempt_cobalt(
354        &self,
355        result: fidl_ieee80211::StatusCode,
356        flushed_successive_failures: Option<usize>,
357        downtime_duration: Option<zx::MonotonicDuration>,
358    ) {
359        let mut metric_events = vec![];
360        metric_events.push(MetricEvent {
361            metric_id: metrics::CONNECT_ATTEMPT_BREAKDOWN_BY_STATUS_CODE_METRIC_ID,
362            event_codes: vec![result.into_primitive() as u32],
363            payload: MetricEventPayload::Count(1),
364        });
365
366        if let Some(failures) = flushed_successive_failures {
367            metric_events.push(MetricEvent {
368                metric_id: metrics::SUCCESSIVE_CONNECT_ATTEMPT_FAILURES_METRIC_ID,
369                event_codes: vec![],
370                payload: MetricEventPayload::IntegerValue(failures as i64),
371            });
372        }
373
374        if let Some(duration) = downtime_duration {
375            metric_events.push(MetricEvent {
376                metric_id: metrics::DOWNTIME_POST_DISCONNECT_METRIC_ID,
377                event_codes: vec![],
378                payload: MetricEventPayload::IntegerValue(duration.into_millis()),
379            });
380        }
381
382        log_cobalt_batch!(self.cobalt_proxy, &metric_events, "log_connect_attempt_cobalt");
383    }
384
385    async fn log_device_connected_cobalt_metrics(
386        &self,
387        bss: &BssDescription,
388        is_owe_transition: bool,
389    ) {
390        let mut metric_events = vec![];
391        metric_events.push(MetricEvent {
392            metric_id: metrics::NUMBER_OF_CONNECTED_DEVICES_METRIC_ID,
393            event_codes: vec![],
394            payload: MetricEventPayload::Count(1),
395        });
396
397        let security_type_dim = convert_security_type(&bss.protection());
398        metric_events.push(MetricEvent {
399            metric_id: metrics::CONNECTED_NETWORK_SECURITY_TYPE_METRIC_ID,
400            event_codes: vec![security_type_dim as u32],
401            payload: MetricEventPayload::Count(1),
402        });
403
404        if bss.supports_uapsd() {
405            metric_events.push(MetricEvent {
406                metric_id: metrics::DEVICE_CONNECTED_TO_AP_THAT_SUPPORTS_APSD_METRIC_ID,
407                event_codes: vec![],
408                payload: MetricEventPayload::Count(1),
409            });
410        }
411
412        if let Some(rm_enabled_cap) = bss.rm_enabled_cap() {
413            if rm_enabled_cap.link_measurement_enabled() {
414                metric_events.push(MetricEvent {
415                    metric_id:
416                        metrics::DEVICE_CONNECTED_TO_AP_THAT_SUPPORTS_LINK_MEASUREMENT_METRIC_ID,
417                    event_codes: vec![],
418                    payload: MetricEventPayload::Count(1),
419                });
420            }
421            if rm_enabled_cap.neighbor_report_enabled() {
422                metric_events.push(MetricEvent {
423                    metric_id:
424                        metrics::DEVICE_CONNECTED_TO_AP_THAT_SUPPORTS_NEIGHBOR_REPORT_METRIC_ID,
425                    event_codes: vec![],
426                    payload: MetricEventPayload::Count(1),
427                });
428            }
429        }
430
431        if bss.supports_ft() {
432            metric_events.push(MetricEvent {
433                metric_id: metrics::DEVICE_CONNECTED_TO_AP_THAT_SUPPORTS_FT_METRIC_ID,
434                event_codes: vec![],
435                payload: MetricEventPayload::Count(1),
436            });
437        }
438
439        if let Some(cap) = bss.ext_cap().and_then(|cap| cap.ext_caps_octet_3)
440            && cap.bss_transition()
441        {
442            metric_events.push(MetricEvent {
443                    metric_id: metrics::DEVICE_CONNECTED_TO_AP_THAT_SUPPORTS_BSS_TRANSITION_MANAGEMENT_METRIC_ID,
444                    event_codes: vec![],
445                    payload: MetricEventPayload::Count(1),
446                });
447        }
448
449        metric_events.push(MetricEvent {
450            metric_id: metrics::DEVICE_CONNECTED_TO_AP_BREAKDOWN_BY_PRIMARY_CHANNEL_METRIC_ID,
451            event_codes: vec![bss.channel.primary as u32],
452            payload: MetricEventPayload::Count(1),
453        });
454
455        let channel_band_dim = convert_channel_band(bss.channel.band);
456        metric_events.push(MetricEvent {
457            metric_id: metrics::DEVICE_CONNECTED_TO_AP_BREAKDOWN_BY_CHANNEL_BAND_METRIC_ID,
458            event_codes: vec![channel_band_dim as u32],
459            payload: MetricEventPayload::Count(1),
460        });
461
462        let oui_string = bss.bssid.to_oui_uppercase("");
463        metric_events.push(MetricEvent {
464            metric_id: metrics::DEVICE_CONNECTED_TO_AP_OUI_2_METRIC_ID,
465            event_codes: vec![],
466            payload: MetricEventPayload::StringValue(oui_string),
467        });
468
469        let is_owe_transition_dim = convert_is_owe_transition(is_owe_transition);
470        metric_events.push(MetricEvent {
471            metric_id: metrics::DEVICE_CONNECTED_TO_AP_BREAKDOWN_BY_IS_OWE_TRANSITION_METRIC_ID,
472            event_codes: vec![is_owe_transition_dim as u32],
473            payload: MetricEventPayload::Count(1),
474        });
475
476        log_cobalt_batch!(self.cobalt_proxy, &metric_events, "log_device_connected_cobalt_metrics");
477    }
478
479    pub async fn log_disconnect(&self, info: &DisconnectInfo) {
480        match self.device_mobility {
481            DeviceMobility::Mobile => {
482                // Mobile devices can be considered idle if they disconnect for reasons associated
483                // with going out of range or are commanded to disconnect by upper layers.
484                if !info.disconnect_source.should_log_for_mobile_device() {
485                    self.update_connection_state(ConnectionState::Idle(IdleState {}));
486                } else {
487                    self.update_connection_state(ConnectionState::Disconnected(
488                        DisconnectedState {},
489                    ));
490                }
491            }
492            DeviceMobility::Stationary => {
493                self.update_connection_state(ConnectionState::Disconnected(DisconnectedState {}));
494            }
495        }
496        let _prev = self.last_disconnect_at.lock().replace(fasync::MonotonicInstant::now());
497        self.log_disconnect_inspect(info);
498        self.log_disconnect_cobalt(info).await;
499    }
500
501    fn log_disconnect_inspect(&self, info: &DisconnectInfo) {
502        let mut inspect_metadata_node = self.inspect_metadata_node.lock();
503        let connected_network = InspectConnectedNetwork::from(&*info.original_bss_desc);
504        let connected_network_id =
505            inspect_metadata_node.connected_networks.insert(connected_network) as u64;
506        let disconnect_source = InspectDisconnectSource::from(&info.disconnect_source);
507        let disconnect_source_id =
508            inspect_metadata_node.disconnect_sources.insert(disconnect_source) as u64;
509        inspect_log!(self.disconnect_events_node.lock(), {
510            connected_duration: info.connected_duration.into_nanos(),
511            disconnect_source_id: disconnect_source_id,
512            network_id: connected_network_id,
513            rssi_dbm: info.current_rssi_dbm,
514            snr_db: info.current_snr_db,
515            channel: format!("{}", info.current_channel),
516        });
517
518        self.time_series_stats.log_disconnected_networks(1 << connected_network_id);
519        self.time_series_stats.log_disconnect_sources(1 << disconnect_source_id);
520    }
521
522    async fn log_disconnect_cobalt(&self, info: &DisconnectInfo) {
523        let mut metric_events = vec![];
524        metric_events.push(MetricEvent {
525            metric_id: metrics::TOTAL_DISCONNECT_COUNT_METRIC_ID,
526            event_codes: vec![],
527            payload: MetricEventPayload::Count(1),
528        });
529
530        if self.device_mobility == DeviceMobility::Mobile
531            && info.disconnect_source.should_log_for_mobile_device()
532        {
533            metric_events.push(MetricEvent {
534                metric_id: metrics::DISCONNECT_OCCURRENCE_FOR_MOBILE_DEVICE_METRIC_ID,
535                event_codes: vec![],
536                payload: MetricEventPayload::Count(1),
537            });
538        }
539
540        metric_events.push(MetricEvent {
541            metric_id: metrics::CONNECTED_DURATION_ON_DISCONNECT_METRIC_ID,
542            event_codes: vec![],
543            payload: MetricEventPayload::IntegerValue(info.connected_duration.into_millis()),
544        });
545
546        metric_events.push(MetricEvent {
547            metric_id: metrics::DISCONNECT_BREAKDOWN_BY_REASON_CODE_METRIC_ID,
548            event_codes: vec![
549                u32::from(info.disconnect_source.cobalt_reason_code()),
550                info.disconnect_source.as_cobalt_disconnect_source() as u32,
551            ],
552            payload: MetricEventPayload::Count(1),
553        });
554
555        log_cobalt_batch!(self.cobalt_proxy, &metric_events, "log_disconnect_cobalt");
556    }
557
558    pub async fn handle_periodic_telemetry(&self) {
559        let mut metric_events = vec![];
560        let now = fasync::BootInstant::now();
561        if let Some(failed_at) = *self.last_connect_failure_at.lock()
562            && now - failed_at >= SUCCESSIVE_CONNECT_ATTEMPT_FAILURES_TIMEOUT
563        {
564            let failures = self.successive_connect_attempt_failures.swap(0, Ordering::SeqCst);
565            if failures > 0 {
566                metric_events.push(MetricEvent {
567                    metric_id: metrics::SUCCESSIVE_CONNECT_ATTEMPT_FAILURES_METRIC_ID,
568                    event_codes: vec![],
569                    payload: MetricEventPayload::IntegerValue(failures as i64),
570                });
571            }
572        }
573
574        {
575            let mut daily_stats = self.daily_connect_stats.lock();
576            if now - daily_stats.last_log_time >= DAILY_METRICS_LOG_INTERVAL {
577                for (security_type, counter) in daily_stats.connect_per_security_type.drain() {
578                    if counter.total > 0 {
579                        let success_rate = counter.success as f64 / counter.total as f64;
580                        metric_events.push(MetricEvent {
581                            metric_id:
582                                metrics::DAILY_CONNECT_SUCCESS_RATE_BREAKDOWN_BY_SECURITY_TYPE_METRIC_ID,
583                            event_codes: vec![security_type as u32],
584                            payload: MetricEventPayload::IntegerValue(float_to_ten_thousandth(
585                                success_rate,
586                            )),
587                        });
588                    }
589                }
590                for (primary_channel, counter) in daily_stats.connect_per_primary_channel.drain() {
591                    if counter.total > 0 {
592                        let success_rate = counter.success as f64 / counter.total as f64;
593                        metric_events.push(MetricEvent {
594                            metric_id:
595                                metrics::DAILY_CONNECT_SUCCESS_RATE_BREAKDOWN_BY_PRIMARY_CHANNEL_METRIC_ID,
596                            event_codes: vec![primary_channel as u32],
597                            payload: MetricEventPayload::IntegerValue(float_to_ten_thousandth(
598                                success_rate,
599                            )),
600                        });
601                    }
602                }
603                for (channel_band, counter) in daily_stats.connect_per_channel_band.drain() {
604                    if counter.total > 0 {
605                        let success_rate = counter.success as f64 / counter.total as f64;
606                        metric_events.push(MetricEvent {
607                            metric_id:
608                                metrics::DAILY_CONNECT_SUCCESS_RATE_BREAKDOWN_BY_CHANNEL_BAND_METRIC_ID,
609                            event_codes: vec![channel_band as u32],
610                            payload: MetricEventPayload::IntegerValue(float_to_ten_thousandth(
611                                success_rate,
612                            )),
613                        });
614                    }
615                }
616                for (rssi_bucket, counter) in daily_stats.connect_per_rssi_bucket.drain() {
617                    if counter.total > 0 {
618                        let success_rate = counter.success as f64 / counter.total as f64;
619                        metric_events.push(MetricEvent {
620                            metric_id:
621                                metrics::DAILY_CONNECT_SUCCESS_RATE_BREAKDOWN_BY_RSSI_BUCKET_METRIC_ID,
622                            event_codes: vec![rssi_bucket as u32],
623                            payload: MetricEventPayload::IntegerValue(float_to_ten_thousandth(
624                                success_rate,
625                            )),
626                        });
627                    }
628                }
629                for (snr_bucket, counter) in daily_stats.connect_per_snr_bucket.drain() {
630                    if counter.total > 0 {
631                        let success_rate = counter.success as f64 / counter.total as f64;
632                        metric_events.push(MetricEvent {
633                            metric_id:
634                                metrics::DAILY_CONNECT_SUCCESS_RATE_BREAKDOWN_BY_SNR_BUCKET_METRIC_ID,
635                            event_codes: vec![snr_bucket as u32],
636                            payload: MetricEventPayload::IntegerValue(float_to_ten_thousandth(
637                                success_rate,
638                            )),
639                        });
640                    }
641                }
642                for (is_owe_transition, counter) in
643                    daily_stats.connect_per_is_owe_transition.drain()
644                {
645                    if counter.total > 0 {
646                        let success_rate = counter.success as f64 / counter.total as f64;
647                        metric_events.push(MetricEvent {
648                            metric_id:
649                                metrics::DAILY_CONNECT_SUCCESS_RATE_BREAKDOWN_BY_IS_OWE_TRANSITION_METRIC_ID,
650                            event_codes: vec![is_owe_transition as u32],
651                            payload: MetricEventPayload::IntegerValue(float_to_ten_thousandth(
652                                success_rate,
653                            )),
654                        });
655                    }
656                }
657                daily_stats.last_log_time = now;
658            }
659        }
660
661        log_cobalt_batch!(self.cobalt_proxy, &metric_events, "handle_periodic_telemetry");
662    }
663
664    pub async fn handle_suspend_imminent(&self) {
665        let mut metric_events = vec![];
666
667        let flushed_successive_failures =
668            self.successive_connect_attempt_failures.swap(0, Ordering::SeqCst);
669        if flushed_successive_failures > 0 {
670            metric_events.push(MetricEvent {
671                metric_id: metrics::SUCCESSIVE_CONNECT_ATTEMPT_FAILURES_METRIC_ID,
672                event_codes: vec![],
673                payload: MetricEventPayload::IntegerValue(flushed_successive_failures as i64),
674            });
675        }
676
677        log_cobalt_batch!(self.cobalt_proxy, &metric_events, "handle_suspend_imminent");
678    }
679
680    pub async fn handle_iface_destroyed(&self) {
681        self.update_connection_state(ConnectionState::Idle(IdleState {}));
682    }
683
684    pub async fn handle_client_connections_toggle(&self, event: &ClientConnectionsToggleEvent) {
685        if event == &ClientConnectionsToggleEvent::Disabled {
686            self.update_connection_state(ConnectionState::Idle(IdleState {}));
687        }
688    }
689
690    pub async fn handle_pno_scan_failure(&self) {
691        let mut metric_events = vec![MetricEvent {
692            metric_id: metrics::PNO_SCAN_FAILURE_OCCURRENCE_METRIC_ID,
693            event_codes: vec![],
694            payload: MetricEventPayload::Count(1),
695        }];
696
697        let state = self.connection_state.lock().clone();
698        match state {
699            ConnectionState::Idle(_)
700            | ConnectionState::Disconnected(_)
701            | ConnectionState::ConnectFailed(_)
702            | ConnectionState::PnoScanFailedIdle(_) => {
703                metric_events.push(MetricEvent {
704                    metric_id: metrics::PNO_SCAN_FAILURE_WHILE_NOT_CONNECTED_OCCURRENCE_METRIC_ID,
705                    event_codes: vec![],
706                    payload: MetricEventPayload::Count(1),
707                });
708
709                // PNO scan failures while not connected indicate that the system is looking for
710                // networks to connect to but it is unable to.  In this case, we should transition
711                // to the PnoScanFailedIdle state to flag a period of potential connectivity loss.
712                self.update_connection_state(ConnectionState::PnoScanFailedIdle(
713                    PnoScanFailedIdleState {},
714                ));
715            }
716            ConnectionState::Connected(_)
717            | ConnectionState::FailedToStart(_)
718            | ConnectionState::FailedToStop(_) => {
719                // PNO scan failures while connected will not affect the current connectivity state.
720                // If WLAN has already failed to start or failed to stop, the state should remain
721                // unchanged until a different failure or successful connection occurs.
722            }
723        }
724
725        log_cobalt_batch!(self.cobalt_proxy, &metric_events, "handle_pno_scan_failure");
726    }
727    pub async fn handle_client_connections_failed_to_start(&self) {
728        self.update_connection_state(ConnectionState::FailedToStart(FailedToStartState {}));
729    }
730
731    pub async fn handle_client_connections_failed_to_stop(&self) {
732        self.update_connection_state(ConnectionState::FailedToStop(FailedToStopState {}));
733    }
734}
735
736struct InspectMetadataNode {
737    connected_networks: LruCacheNode<InspectConnectedNetwork>,
738    disconnect_sources: LruCacheNode<InspectDisconnectSource>,
739    connect_attempt_results: LruCacheNode<InspectConnectAttemptResult>,
740}
741
742impl InspectMetadataNode {
743    const CONNECTED_NETWORKS: &'static str = "connected_networks";
744    const DISCONNECT_SOURCES: &'static str = "disconnect_sources";
745    const CONNECT_ATTEMPT_RESULTS: &'static str = "connect_attempt_results";
746
747    fn new(inspect_node: &InspectNode) -> Self {
748        let connected_networks = inspect_node.create_child(Self::CONNECTED_NETWORKS);
749        let disconnect_sources = inspect_node.create_child(Self::DISCONNECT_SOURCES);
750        let connect_attempt_results = inspect_node.create_child(Self::CONNECT_ATTEMPT_RESULTS);
751        Self {
752            connected_networks: LruCacheNode::new(
753                connected_networks,
754                INSPECT_CONNECTED_NETWORKS_ID_LIMIT,
755            ),
756            disconnect_sources: LruCacheNode::new(
757                disconnect_sources,
758                INSPECT_DISCONNECT_SOURCES_ID_LIMIT,
759            ),
760            connect_attempt_results: LruCacheNode::new(
761                connect_attempt_results,
762                INSPECT_CONNECT_ATTEMPT_RESULTS_ID_LIMIT,
763            ),
764        }
765    }
766}
767
768#[derive(Debug, Clone)]
769struct ConnectDisconnectTimeSeries {
770    wlan_connectivity_states: InspectedTimeMatrix<u64>,
771    connected_networks: InspectedTimeMatrix<u64>,
772    disconnected_networks: InspectedTimeMatrix<u64>,
773    disconnect_sources: InspectedTimeMatrix<u64>,
774    connect_attempt_results: InspectedTimeMatrix<u64>,
775}
776
777impl ConnectDisconnectTimeSeries {
778    pub fn new<S: InspectSender>(client: &S, inspect_metadata_path: &str) -> Self {
779        let wlan_connectivity_states = client.inspect_time_matrix_with_metadata(
780            "wlan_connectivity_states",
781            TimeMatrix::<Union<u64>, LastSample>::new(
782                SamplingProfile::highly_granular(),
783                LastSample::or(0),
784            ),
785            // Update the ConnectionState IdEnum trait when making changes to this list.
786            BitsetMap::from_ordered(Self::wlan_connectivity_states_bitset_map().iter().copied()),
787        );
788        let connected_networks = client.inspect_time_matrix_with_metadata(
789            "connected_networks",
790            TimeMatrix::<Union<u64>, ConstantSample>::new(
791                SamplingProfile::granular(),
792                ConstantSample::default(),
793            ),
794            BitsetNode::from_path(format!(
795                "{}/{}",
796                inspect_metadata_path,
797                InspectMetadataNode::CONNECTED_NETWORKS
798            )),
799        );
800        let disconnected_networks = client.inspect_time_matrix_with_metadata(
801            "disconnected_networks",
802            TimeMatrix::<Union<u64>, ConstantSample>::new(
803                SamplingProfile::granular(),
804                ConstantSample::default(),
805            ),
806            // This time matrix shares its bit labels with `connected_networks`.
807            BitsetNode::from_path(format!(
808                "{}/{}",
809                inspect_metadata_path,
810                InspectMetadataNode::CONNECTED_NETWORKS
811            )),
812        );
813        let disconnect_sources = client.inspect_time_matrix_with_metadata(
814            "disconnect_sources",
815            TimeMatrix::<Union<u64>, ConstantSample>::new(
816                SamplingProfile::granular(),
817                ConstantSample::default(),
818            ),
819            BitsetNode::from_path(format!(
820                "{}/{}",
821                inspect_metadata_path,
822                InspectMetadataNode::DISCONNECT_SOURCES,
823            )),
824        );
825        let connect_attempt_results = client.inspect_time_matrix_with_metadata(
826            "connect_attempt_results",
827            TimeMatrix::<Union<u64>, ConstantSample>::new(
828                SamplingProfile::granular(),
829                ConstantSample::default(),
830            ),
831            BitsetNode::from_path(format!(
832                "{}/{}",
833                inspect_metadata_path,
834                InspectMetadataNode::CONNECT_ATTEMPT_RESULTS,
835            )),
836        );
837        Self {
838            wlan_connectivity_states,
839            connected_networks,
840            disconnected_networks,
841            disconnect_sources,
842            connect_attempt_results,
843        }
844    }
845
846    // TODO(https://fxbug.dev/504712259): Update BitsetMap to accept the enum type
847    // it's associated with rather than constructing bit labels separately like this
848    fn wlan_connectivity_states_bitset_map() -> &'static [&'static str] {
849        &[
850            "idle",
851            "disconnected",
852            "connect_failed",
853            "connected",
854            "start_failure",
855            "stop_failure",
856            "pno_scan_failed",
857        ]
858    }
859
860    fn log_wlan_connectivity_state(&self, data: u64) {
861        self.wlan_connectivity_states.fold_or_log_error(data);
862    }
863    fn log_connected_networks(&self, data: u64) {
864        self.connected_networks.fold_or_log_error(data);
865    }
866    fn log_disconnected_networks(&self, data: u64) {
867        self.disconnected_networks.fold_or_log_error(data);
868    }
869    fn log_disconnect_sources(&self, data: u64) {
870        self.disconnect_sources.fold_or_log_error(data);
871    }
872    fn log_connect_attempt_results(&self, data: u64) {
873        self.connect_attempt_results.fold_or_log_error(data);
874    }
875}
876
877pub trait DisconnectSourceExt {
878    fn should_log_for_mobile_device(&self) -> bool;
879    fn cobalt_reason_code(&self) -> u16;
880    fn as_cobalt_disconnect_source(
881        &self,
882    ) -> metrics::ConnectivityWlanMetricDimensionDisconnectSource;
883}
884
885impl DisconnectSourceExt for fidl_sme::DisconnectSource {
886    fn should_log_for_mobile_device(&self) -> bool {
887        match self {
888            fidl_sme::DisconnectSource::Ap(_) => true,
889            fidl_sme::DisconnectSource::Mlme(cause)
890                if cause.reason_code != fidl_ieee80211::ReasonCode::MlmeLinkFailed =>
891            {
892                true
893            }
894            _ => false,
895        }
896    }
897
898    fn cobalt_reason_code(&self) -> u16 {
899        let cobalt_disconnect_reason_code = match self {
900            fidl_sme::DisconnectSource::Ap(cause) | fidl_sme::DisconnectSource::Mlme(cause) => {
901                cause.reason_code.into_primitive()
902            }
903            fidl_sme::DisconnectSource::User(reason) => *reason as u16,
904        };
905        // This `max_event_code: 1000` is set in the metrics registry, but doesn't show up in the
906        // generated bindings.
907        const REASON_CODE_MAX: u16 = 1000;
908        std::cmp::min(cobalt_disconnect_reason_code, REASON_CODE_MAX)
909    }
910
911    fn as_cobalt_disconnect_source(
912        &self,
913    ) -> metrics::ConnectivityWlanMetricDimensionDisconnectSource {
914        use metrics::ConnectivityWlanMetricDimensionDisconnectSource as DS;
915        match self {
916            fidl_sme::DisconnectSource::Ap(..) => DS::Ap,
917            fidl_sme::DisconnectSource::User(..) => DS::User,
918            fidl_sme::DisconnectSource::Mlme(..) => DS::Mlme,
919        }
920    }
921}
922
923#[derive(Debug, Default, Copy, Clone, PartialEq)]
924struct ConnectAttemptsCounter {
925    success: u64,
926    total: u64,
927}
928
929impl ConnectAttemptsCounter {
930    fn increment(&mut self, code: fidl_ieee80211::StatusCode) {
931        self.total += 1;
932        if code == fidl_ieee80211::StatusCode::Success {
933            self.success += 1;
934        }
935    }
936}
937
938struct DailyConnectStats {
939    last_log_time: fasync::BootInstant,
940    connect_per_security_type: HashMap<
941        metrics::SuccessfulConnectBreakdownBySecurityTypeMetricDimensionSecurityType,
942        ConnectAttemptsCounter,
943    >,
944    connect_per_primary_channel: HashMap<u8, ConnectAttemptsCounter>,
945    connect_per_channel_band: HashMap<
946        metrics::SuccessfulConnectBreakdownByChannelBandMetricDimensionChannelBand,
947        ConnectAttemptsCounter,
948    >,
949    connect_per_rssi_bucket:
950        HashMap<metrics::ConnectivityWlanMetricDimensionRssiBucket, ConnectAttemptsCounter>,
951    connect_per_snr_bucket:
952        HashMap<metrics::ConnectivityWlanMetricDimensionSnrBucket, ConnectAttemptsCounter>,
953    connect_per_is_owe_transition: HashMap<
954        metrics::DailyConnectSuccessRateBreakdownByIsOweTransitionMetricDimensionIsOweTransition,
955        ConnectAttemptsCounter,
956    >,
957}
958
959impl DailyConnectStats {
960    fn new(now: fasync::BootInstant) -> Self {
961        Self {
962            last_log_time: now,
963            connect_per_security_type: HashMap::new(),
964            connect_per_primary_channel: HashMap::new(),
965            connect_per_channel_band: HashMap::new(),
966            connect_per_rssi_bucket: HashMap::new(),
967            connect_per_snr_bucket: HashMap::new(),
968            connect_per_is_owe_transition: HashMap::new(),
969        }
970    }
971}
972
973// Convert float to an integer in "ten thousandth" unit
974// Example: 0.02f64 (i.e. 2%) -> 200 per ten thousand
975fn float_to_ten_thousandth(value: f64) -> i64 {
976    (value * 10000f64) as i64
977}
978
979#[cfg(test)]
980mod tests {
981    use super::*;
982    use crate::testing::*;
983    use assert_matches::assert_matches;
984    use diagnostics_assertions::{
985        AnyBoolProperty, AnyBytesProperty, AnyNumericProperty, AnyStringProperty, assert_data_tree,
986    };
987    use futures::task::Poll;
988    use ieee80211_testutils::{BSSID_REGEX, SSID_REGEX};
989    use rand::Rng;
990    use std::pin::pin;
991    use strum::IntoEnumIterator;
992    use test_case::test_case;
993    use windowed_stats::experimental::clock::Timed;
994    use windowed_stats::experimental::inspect::TimeMatrixClient;
995    use windowed_stats::experimental::testing::TimeMatrixCall;
996    use wlan_common::channel::{Bandwidth, Channel};
997    use wlan_common::ie::IeType;
998    use wlan_common::test_utils::fake_stas::IesOverrides;
999    use wlan_common::{fake_bss_description, random_bss_description};
1000
1001    #[fuchsia::test]
1002    fn log_connect_attempt_then_inspect_data_tree_contains_time_matrix_metadata() {
1003        let mut harness = setup_test();
1004
1005        let client =
1006            TimeMatrixClient::new(harness.inspect_node.create_child("wlan_connect_disconnect"));
1007        let logger = ConnectDisconnectLogger::new(
1008            harness.filtered_cobalt_logger(),
1009            &harness.inspect_node,
1010            &harness.inspect_metadata_node,
1011            &harness.inspect_metadata_path,
1012            &client,
1013            DeviceMobility::Mobile,
1014        );
1015        let bss = random_bss_description!();
1016        let mut log_connect_attempt = pin!(logger.handle_connect_attempt(
1017            fidl_ieee80211::StatusCode::Success,
1018            &bss,
1019            false,
1020            false
1021        ));
1022        assert!(
1023            harness.run_until_stalled_drain_cobalt_events(&mut log_connect_attempt).is_ready(),
1024            "`log_connect_attempt` did not complete",
1025        );
1026
1027        let tree = harness.get_inspect_data_tree();
1028        assert_data_tree!(
1029            @executor harness.exec,
1030            tree,
1031            root: contains {
1032                test_stats: contains {
1033                    wlan_connect_disconnect: contains {
1034                        wlan_connectivity_states: {
1035                            "type": "bitset",
1036                            "data": AnyBytesProperty,
1037                            metadata: {
1038                                index: {
1039                                    "0": "idle",
1040                                    "1": "disconnected",
1041                                    "2": "connect_failed",
1042                                    "3": "connected",
1043                                    "4": "start_failure",
1044                                    "5": "stop_failure",
1045                                    "6": "pno_scan_failed",
1046                                },
1047                            },
1048                        },
1049                        connected_networks: {
1050                            "type": "bitset",
1051                            "data": AnyBytesProperty,
1052                            metadata: {
1053                                "index_node_path": "root/test_stats/metadata/connected_networks",
1054                            },
1055                        },
1056                        disconnected_networks: {
1057                            "type": "bitset",
1058                            "data": AnyBytesProperty,
1059                            metadata: {
1060                                "index_node_path": "root/test_stats/metadata/connected_networks",
1061                            },
1062                        },
1063                        disconnect_sources: {
1064                            "type": "bitset",
1065                            "data": AnyBytesProperty,
1066                            metadata: {
1067                                "index_node_path": "root/test_stats/metadata/disconnect_sources",
1068                            },
1069                        },
1070                        connect_attempt_results: {
1071                            "type": "bitset",
1072                            "data": AnyBytesProperty,
1073                            metadata: {
1074                                "index_node_path": "root/test_stats/metadata/connect_attempt_results",
1075                            },
1076                        },
1077                    },
1078                },
1079            }
1080        );
1081    }
1082
1083    #[fuchsia::test]
1084    fn test_log_connect_attempt_inspect() {
1085        let mut test_helper = setup_test();
1086        let logger = ConnectDisconnectLogger::new(
1087            test_helper.filtered_cobalt_logger(),
1088            &test_helper.inspect_node,
1089            &test_helper.inspect_metadata_node,
1090            &test_helper.inspect_metadata_path,
1091            &test_helper.mock_time_matrix_client,
1092            DeviceMobility::Mobile,
1093        );
1094
1095        // Log the event
1096        let bss_description = random_bss_description!();
1097        let mut test_fut = pin!(logger.handle_connect_attempt(
1098            fidl_ieee80211::StatusCode::Success,
1099            &bss_description,
1100            false,
1101            false
1102        ));
1103        assert_eq!(
1104            test_helper.run_until_stalled_drain_cobalt_events(&mut test_fut),
1105            Poll::Ready(())
1106        );
1107
1108        // Validate Inspect data
1109        let data = test_helper.get_inspect_data_tree();
1110        assert_data_tree!(@executor test_helper.exec, data, root: contains {
1111            test_stats: contains {
1112                metadata: contains {
1113                    connected_networks: contains {
1114                        "0": {
1115                            "@time": AnyNumericProperty,
1116                            "data": contains {
1117                                bssid: &*BSSID_REGEX,
1118                                ssid: &*SSID_REGEX,
1119                            }
1120                        }
1121                    },
1122                    connect_attempt_results: contains {
1123                        "0": {
1124                            "@time": AnyNumericProperty,
1125                            "data": contains {
1126                                status_code: 0u64,
1127                                result: "Success",
1128                            }
1129                        }
1130                    },
1131                },
1132                connect_events: {
1133                    "0": {
1134                        "@time": AnyNumericProperty,
1135                        network_id: 0u64,
1136                    }
1137                },
1138                connect_attempt_results: {
1139                    "0": {
1140                        "@time": AnyNumericProperty,
1141                        result: "Success",
1142                        ssid: &*SSID_REGEX,
1143                        bssid: &*BSSID_REGEX,
1144                        protection: AnyStringProperty,
1145                    }
1146                }
1147            }
1148        });
1149
1150        let mut time_matrix_calls = test_helper.mock_time_matrix_client.drain_calls();
1151        assert_eq!(
1152            &time_matrix_calls.drain::<u64>("wlan_connectivity_states")[..],
1153            &[TimeMatrixCall::Fold(Timed::now(1 << 0)), TimeMatrixCall::Fold(Timed::now(1 << 3)),]
1154        );
1155        assert_eq!(
1156            &time_matrix_calls.drain::<u64>("connected_networks")[..],
1157            &[TimeMatrixCall::Fold(Timed::now(1 << 0))]
1158        );
1159        assert_eq!(
1160            &time_matrix_calls.drain::<u64>("connect_attempt_results")[..],
1161            &[TimeMatrixCall::Fold(Timed::now(1 << 0))]
1162        );
1163    }
1164
1165    #[fuchsia::test]
1166    fn test_log_connect_attempt_cobalt() {
1167        let mut test_helper = setup_test();
1168        let logger = ConnectDisconnectLogger::new(
1169            test_helper.filtered_cobalt_logger(),
1170            &test_helper.inspect_node,
1171            &test_helper.inspect_metadata_node,
1172            &test_helper.inspect_metadata_path,
1173            &test_helper.mock_time_matrix_client,
1174            DeviceMobility::Mobile,
1175        );
1176
1177        // Generate BSS Description
1178        let bss_description = random_bss_description!(Wpa2,
1179            channel: Channel::new(157, Bandwidth::Cbw40, fidl_ieee80211::WlanBand::FiveGhz),
1180            bssid: [0x00, 0xf6, 0x20, 0x03, 0x04, 0x05],
1181        );
1182
1183        // Log the event
1184        let mut test_fut = pin!(logger.handle_connect_attempt(
1185            fidl_ieee80211::StatusCode::Success,
1186            &bss_description,
1187            false,
1188            false
1189        ));
1190        assert_eq!(
1191            test_helper.run_until_stalled_drain_cobalt_events(&mut test_fut),
1192            Poll::Ready(())
1193        );
1194
1195        // Validate Cobalt data
1196        let breakdowns_by_status_code = test_helper
1197            .get_logged_metrics(metrics::CONNECT_ATTEMPT_BREAKDOWN_BY_STATUS_CODE_METRIC_ID);
1198        assert_eq!(breakdowns_by_status_code.len(), 1);
1199        assert_eq!(
1200            breakdowns_by_status_code[0].event_codes,
1201            vec![fidl_ieee80211::StatusCode::Success.into_primitive() as u32]
1202        );
1203        assert_eq!(breakdowns_by_status_code[0].payload, MetricEventPayload::Count(1));
1204
1205        let metrics_devices =
1206            test_helper.get_logged_metrics(metrics::NUMBER_OF_CONNECTED_DEVICES_METRIC_ID);
1207        assert_eq!(metrics_devices.len(), 1);
1208        assert_eq!(metrics_devices[0].payload, MetricEventPayload::Count(1));
1209
1210        let metrics_security =
1211            test_helper.get_logged_metrics(metrics::CONNECTED_NETWORK_SECURITY_TYPE_METRIC_ID);
1212        assert_eq!(metrics_security.len(), 1);
1213        assert_eq!(metrics_security[0].event_codes, vec![5]); // Wpa2Personal
1214
1215        let metrics_channel = test_helper.get_logged_metrics(
1216            metrics::DEVICE_CONNECTED_TO_AP_BREAKDOWN_BY_PRIMARY_CHANNEL_METRIC_ID,
1217        );
1218        assert_eq!(metrics_channel.len(), 1);
1219        assert_eq!(metrics_channel[0].event_codes, vec![157]);
1220
1221        let metrics_band = test_helper.get_logged_metrics(
1222            metrics::DEVICE_CONNECTED_TO_AP_BREAKDOWN_BY_CHANNEL_BAND_METRIC_ID,
1223        );
1224        assert_eq!(metrics_band.len(), 1);
1225        assert_eq!(metrics_band[0].event_codes, vec![2]); // Band5Ghz
1226
1227        let metrics_oui =
1228            test_helper.get_logged_metrics(metrics::DEVICE_CONNECTED_TO_AP_OUI_2_METRIC_ID);
1229        assert_eq!(metrics_oui.len(), 1);
1230        assert_eq!(metrics_oui[0].payload, MetricEventPayload::StringValue("00F620".to_string()));
1231
1232        let metrics_owe_transition = test_helper.get_logged_metrics(
1233            metrics::DEVICE_CONNECTED_TO_AP_BREAKDOWN_BY_IS_OWE_TRANSITION_METRIC_ID,
1234        );
1235        assert_eq!(metrics_owe_transition.len(), 1);
1236        assert_eq!(
1237            metrics_owe_transition[0].event_codes,
1238            vec![
1239                metrics::DailyConnectSuccessRateBreakdownByIsOweTransitionMetricDimensionIsOweTransition::No
1240                    as u32
1241            ]
1242        );
1243    }
1244
1245    #[fuchsia::test]
1246    fn test_successive_connect_attempt_failures_cobalt_zero_failures() {
1247        let mut test_helper = setup_test();
1248        let logger = ConnectDisconnectLogger::new(
1249            test_helper.filtered_cobalt_logger(),
1250            &test_helper.inspect_node,
1251            &test_helper.inspect_metadata_node,
1252            &test_helper.inspect_metadata_path,
1253            &test_helper.mock_time_matrix_client,
1254            DeviceMobility::Mobile,
1255        );
1256
1257        let bss_description = random_bss_description!(Wpa2);
1258        let mut test_fut = pin!(logger.handle_connect_attempt(
1259            fidl_ieee80211::StatusCode::Success,
1260            &bss_description,
1261            false,
1262            false
1263        ));
1264        assert_eq!(
1265            test_helper.run_until_stalled_drain_cobalt_events(&mut test_fut),
1266            Poll::Ready(())
1267        );
1268
1269        let metrics =
1270            test_helper.get_logged_metrics(metrics::SUCCESSIVE_CONNECT_ATTEMPT_FAILURES_METRIC_ID);
1271        assert_eq!(metrics.len(), 1);
1272        assert_eq!(metrics[0].payload, MetricEventPayload::IntegerValue(0));
1273    }
1274
1275    #[fuchsia::test]
1276    fn test_log_device_connected_metrics_capabilities() {
1277        let mut test_helper = setup_test();
1278        let logger = ConnectDisconnectLogger::new(
1279            test_helper.filtered_cobalt_logger(),
1280            &test_helper.inspect_node,
1281            &test_helper.inspect_metadata_node,
1282            &test_helper.inspect_metadata_path,
1283            &test_helper.mock_time_matrix_client,
1284            DeviceMobility::Mobile,
1285        );
1286
1287        let wmm_info = vec![0x80]; // U-APSD enabled
1288        #[rustfmt::skip]
1289        let rm_enabled_capabilities = vec![
1290            0x03, // link measurement and neighbor report enabled
1291            0x00, 0x00, 0x00, 0x00,
1292        ];
1293        #[rustfmt::skip]
1294        let ext_capabilities = vec![
1295            0x04, 0x00,
1296            0x08, // BSS transition supported
1297            0x00, 0x00, 0x00, 0x00, 0x40
1298        ];
1299
1300        let bss_description = fake_bss_description!(Wpa2,
1301            ies_overrides: IesOverrides::new()
1302                .remove(IeType::WMM_PARAM)
1303                .set(IeType::WMM_INFO, wmm_info)
1304                .set(IeType::RM_ENABLED_CAPABILITIES, rm_enabled_capabilities)
1305                .set(IeType::MOBILITY_DOMAIN, vec![0x00; 3])
1306                .set(IeType::EXT_CAPABILITIES, ext_capabilities),
1307        );
1308
1309        let mut test_fut = pin!(logger.handle_connect_attempt(
1310            fidl_ieee80211::StatusCode::Success,
1311            &bss_description,
1312            false,
1313            false
1314        ));
1315        assert_eq!(
1316            test_helper.run_until_stalled_drain_cobalt_events(&mut test_fut),
1317            Poll::Ready(())
1318        );
1319
1320        let metrics = test_helper
1321            .get_logged_metrics(metrics::DEVICE_CONNECTED_TO_AP_THAT_SUPPORTS_APSD_METRIC_ID);
1322        assert_eq!(metrics.len(), 1);
1323        assert_eq!(metrics[0].payload, MetricEventPayload::Count(1));
1324
1325        let metrics = test_helper.get_logged_metrics(
1326            metrics::DEVICE_CONNECTED_TO_AP_THAT_SUPPORTS_BSS_TRANSITION_MANAGEMENT_METRIC_ID,
1327        );
1328        assert_eq!(metrics.len(), 1);
1329        assert_eq!(metrics[0].payload, MetricEventPayload::Count(1));
1330
1331        let metrics = test_helper.get_logged_metrics(
1332            metrics::DEVICE_CONNECTED_TO_AP_THAT_SUPPORTS_LINK_MEASUREMENT_METRIC_ID,
1333        );
1334        assert_eq!(metrics.len(), 1);
1335        assert_eq!(metrics[0].payload, MetricEventPayload::Count(1));
1336
1337        let metrics = test_helper.get_logged_metrics(
1338            metrics::DEVICE_CONNECTED_TO_AP_THAT_SUPPORTS_NEIGHBOR_REPORT_METRIC_ID,
1339        );
1340        assert_eq!(metrics.len(), 1);
1341        assert_eq!(metrics[0].payload, MetricEventPayload::Count(1));
1342    }
1343
1344    #[test_case(1; "one_failure")]
1345    #[test_case(2; "two_failures")]
1346    #[fuchsia::test(add_test_attr = false)]
1347    fn test_successive_connect_attempt_failures_cobalt_one_failure_then_success(n_failures: usize) {
1348        let mut test_helper = setup_test();
1349        let logger = ConnectDisconnectLogger::new(
1350            test_helper.filtered_cobalt_logger(),
1351            &test_helper.inspect_node,
1352            &test_helper.inspect_metadata_node,
1353            &test_helper.inspect_metadata_path,
1354            &test_helper.mock_time_matrix_client,
1355            DeviceMobility::Mobile,
1356        );
1357
1358        let bss_description = random_bss_description!(Wpa2);
1359        for _i in 0..n_failures {
1360            let mut test_fut = pin!(logger.handle_connect_attempt(
1361                fidl_ieee80211::StatusCode::RefusedReasonUnspecified,
1362                &bss_description,
1363                false,
1364                false
1365            ));
1366            assert_eq!(
1367                test_helper.run_until_stalled_drain_cobalt_events(&mut test_fut),
1368                Poll::Ready(())
1369            );
1370        }
1371
1372        let metrics =
1373            test_helper.get_logged_metrics(metrics::SUCCESSIVE_CONNECT_ATTEMPT_FAILURES_METRIC_ID);
1374        assert!(metrics.is_empty());
1375
1376        let mut test_fut = pin!(logger.handle_connect_attempt(
1377            fidl_ieee80211::StatusCode::Success,
1378            &bss_description,
1379            false,
1380            false
1381        ));
1382        assert_eq!(
1383            test_helper.run_until_stalled_drain_cobalt_events(&mut test_fut),
1384            Poll::Ready(())
1385        );
1386
1387        let metrics =
1388            test_helper.get_logged_metrics(metrics::SUCCESSIVE_CONNECT_ATTEMPT_FAILURES_METRIC_ID);
1389        assert_eq!(metrics.len(), 1);
1390        assert_eq!(metrics[0].payload, MetricEventPayload::IntegerValue(n_failures as i64));
1391
1392        // Verify subsequent successes would report 0 failures
1393        test_helper.clear_cobalt_events();
1394        let mut test_fut = pin!(logger.handle_connect_attempt(
1395            fidl_ieee80211::StatusCode::Success,
1396            &bss_description,
1397            false,
1398            false
1399        ));
1400        assert_eq!(
1401            test_helper.run_until_stalled_drain_cobalt_events(&mut test_fut),
1402            Poll::Ready(())
1403        );
1404        let metrics =
1405            test_helper.get_logged_metrics(metrics::SUCCESSIVE_CONNECT_ATTEMPT_FAILURES_METRIC_ID);
1406        assert_eq!(metrics.len(), 1);
1407        assert_eq!(metrics[0].payload, MetricEventPayload::IntegerValue(0));
1408    }
1409
1410    #[test_case(1; "one_failure")]
1411    #[test_case(2; "two_failures")]
1412    #[fuchsia::test(add_test_attr = false)]
1413    fn test_successive_connect_attempt_failures_cobalt_one_failure_then_timeout(n_failures: usize) {
1414        let mut test_helper = setup_test();
1415        let logger = ConnectDisconnectLogger::new(
1416            test_helper.filtered_cobalt_logger(),
1417            &test_helper.inspect_node,
1418            &test_helper.inspect_metadata_node,
1419            &test_helper.inspect_metadata_path,
1420            &test_helper.mock_time_matrix_client,
1421            DeviceMobility::Mobile,
1422        );
1423
1424        let bss_description = random_bss_description!(Wpa2);
1425        for _i in 0..n_failures {
1426            let mut test_fut = pin!(logger.handle_connect_attempt(
1427                fidl_ieee80211::StatusCode::RefusedReasonUnspecified,
1428                &bss_description,
1429                false,
1430                false
1431            ));
1432            assert_eq!(
1433                test_helper.run_until_stalled_drain_cobalt_events(&mut test_fut),
1434                Poll::Ready(())
1435            );
1436        }
1437
1438        test_helper.exec.set_fake_time(fasync::MonotonicInstant::from_nanos(60_000_000_000));
1439        let mut test_fut = pin!(logger.handle_periodic_telemetry());
1440        assert_eq!(
1441            test_helper.run_until_stalled_drain_cobalt_events(&mut test_fut),
1442            Poll::Ready(())
1443        );
1444
1445        // Not enough time has passed, so successive_connect_attempt_failures is not flushed yet
1446        let metrics =
1447            test_helper.get_logged_metrics(metrics::SUCCESSIVE_CONNECT_ATTEMPT_FAILURES_METRIC_ID);
1448        assert!(metrics.is_empty());
1449
1450        test_helper.exec.set_fake_time(fasync::MonotonicInstant::from_nanos(120_000_000_000));
1451        let mut test_fut = pin!(logger.handle_periodic_telemetry());
1452        assert_eq!(
1453            test_helper.run_until_stalled_drain_cobalt_events(&mut test_fut),
1454            Poll::Ready(())
1455        );
1456
1457        let metrics =
1458            test_helper.get_logged_metrics(metrics::SUCCESSIVE_CONNECT_ATTEMPT_FAILURES_METRIC_ID);
1459        assert_eq!(metrics.len(), 1);
1460        assert_eq!(metrics[0].payload, MetricEventPayload::IntegerValue(n_failures as i64));
1461
1462        // Verify timeout fires only once
1463        test_helper.clear_cobalt_events();
1464        test_helper.exec.set_fake_time(fasync::MonotonicInstant::from_nanos(240_000_000_000));
1465        let mut test_fut = pin!(logger.handle_periodic_telemetry());
1466        assert_eq!(
1467            test_helper.run_until_stalled_drain_cobalt_events(&mut test_fut),
1468            Poll::Ready(())
1469        );
1470        let metrics =
1471            test_helper.get_logged_metrics(metrics::SUCCESSIVE_CONNECT_ATTEMPT_FAILURES_METRIC_ID);
1472        assert!(metrics.is_empty());
1473    }
1474
1475    #[fuchsia::test]
1476    fn test_daily_connect_success_rate_breakdowns() {
1477        let mut test_helper = setup_test();
1478        let logger = ConnectDisconnectLogger::new(
1479            test_helper.filtered_cobalt_logger(),
1480            &test_helper.inspect_node,
1481            &test_helper.inspect_metadata_node,
1482            &test_helper.inspect_metadata_path,
1483            &test_helper.mock_time_matrix_client,
1484            DeviceMobility::Mobile,
1485        );
1486
1487        let mut bss = random_bss_description!(Wpa2);
1488        bss.channel = Channel::new(6, Bandwidth::Cbw20, fidl_ieee80211::WlanBand::TwoGhz); // primary channel 6 -> Band2Dot4Ghz
1489        bss.rssi_dbm = -50; // rssi -50 -> From50To35 (event code 11)
1490        bss.snr_db = 15; // snr 15 -> From11To15 (event code 3)
1491
1492        // 1 success, 1 failure => 50% success rate
1493        let mut test_fut = pin!(logger.handle_connect_attempt(
1494            fidl_ieee80211::StatusCode::Success,
1495            &bss,
1496            false,
1497            true
1498        ));
1499        assert_eq!(
1500            test_helper.run_until_stalled_drain_cobalt_events(&mut test_fut),
1501            Poll::Ready(())
1502        );
1503
1504        let mut test_fut = pin!(logger.handle_connect_attempt(
1505            fidl_ieee80211::StatusCode::RefusedReasonUnspecified,
1506            &bss,
1507            false,
1508            true
1509        ));
1510        assert_eq!(
1511            test_helper.run_until_stalled_drain_cobalt_events(&mut test_fut),
1512            Poll::Ready(())
1513        );
1514
1515        // Before 24 hours pass, no daily metrics should be logged
1516        test_helper.clear_cobalt_events();
1517        test_helper
1518            .exec
1519            .set_fake_time(fasync::MonotonicInstant::from_nanos(24 * 3600 * 1_000_000_000 - 1));
1520        let mut test_fut = pin!(logger.handle_periodic_telemetry());
1521        assert_eq!(
1522            test_helper.run_until_stalled_drain_cobalt_events(&mut test_fut),
1523            Poll::Ready(())
1524        );
1525        assert!(
1526            test_helper
1527                .get_logged_metrics(
1528                    metrics::DAILY_CONNECT_SUCCESS_RATE_BREAKDOWN_BY_SECURITY_TYPE_METRIC_ID
1529                )
1530                .is_empty()
1531        );
1532
1533        // After 24 hours pass, daily metrics should be logged
1534        test_helper
1535            .exec
1536            .set_fake_time(fasync::MonotonicInstant::from_nanos(24 * 3600 * 1_000_000_000));
1537        let mut test_fut = pin!(logger.handle_periodic_telemetry());
1538        assert_eq!(
1539            test_helper.run_until_stalled_drain_cobalt_events(&mut test_fut),
1540            Poll::Ready(())
1541        );
1542
1543        // Check security type breakdown
1544        let daily_security_metrics = test_helper.get_logged_metrics(
1545            metrics::DAILY_CONNECT_SUCCESS_RATE_BREAKDOWN_BY_SECURITY_TYPE_METRIC_ID,
1546        );
1547        assert_eq!(daily_security_metrics.len(), 1);
1548        assert_eq!(
1549            daily_security_metrics[0].event_codes,
1550            vec![
1551                metrics::SuccessfulConnectBreakdownBySecurityTypeMetricDimensionSecurityType::Wpa2Personal
1552                    as u32
1553            ]
1554        );
1555        assert_eq!(daily_security_metrics[0].payload, MetricEventPayload::IntegerValue(5000));
1556
1557        // Check primary channel breakdown
1558        let daily_channel_metrics = test_helper.get_logged_metrics(
1559            metrics::DAILY_CONNECT_SUCCESS_RATE_BREAKDOWN_BY_PRIMARY_CHANNEL_METRIC_ID,
1560        );
1561        assert_eq!(daily_channel_metrics.len(), 1);
1562        assert_eq!(daily_channel_metrics[0].event_codes, vec![6]);
1563        assert_eq!(daily_channel_metrics[0].payload, MetricEventPayload::IntegerValue(5000));
1564
1565        // Check channel band breakdown
1566        let daily_band_metrics = test_helper.get_logged_metrics(
1567            metrics::DAILY_CONNECT_SUCCESS_RATE_BREAKDOWN_BY_CHANNEL_BAND_METRIC_ID,
1568        );
1569        assert_eq!(daily_band_metrics.len(), 1);
1570        assert_eq!(
1571            daily_band_metrics[0].event_codes,
1572            vec![
1573                metrics::SuccessfulConnectBreakdownByChannelBandMetricDimensionChannelBand::Band2Dot4Ghz
1574                    as u32
1575            ]
1576        );
1577        assert_eq!(daily_band_metrics[0].payload, MetricEventPayload::IntegerValue(5000));
1578
1579        // Check rssi bucket breakdown
1580        let daily_rssi_metrics = test_helper.get_logged_metrics(
1581            metrics::DAILY_CONNECT_SUCCESS_RATE_BREAKDOWN_BY_RSSI_BUCKET_METRIC_ID,
1582        );
1583        assert_eq!(daily_rssi_metrics.len(), 1);
1584        assert_eq!(
1585            daily_rssi_metrics[0].event_codes,
1586            vec![metrics::ConnectivityWlanMetricDimensionRssiBucket::From50To35 as u32]
1587        );
1588        assert_eq!(daily_rssi_metrics[0].payload, MetricEventPayload::IntegerValue(5000));
1589
1590        // Check snr bucket breakdown
1591        let daily_snr_metrics = test_helper.get_logged_metrics(
1592            metrics::DAILY_CONNECT_SUCCESS_RATE_BREAKDOWN_BY_SNR_BUCKET_METRIC_ID,
1593        );
1594        assert_eq!(daily_snr_metrics.len(), 1);
1595        assert_eq!(
1596            daily_snr_metrics[0].event_codes,
1597            vec![metrics::ConnectivityWlanMetricDimensionSnrBucket::From11To15 as u32]
1598        );
1599        assert_eq!(daily_snr_metrics[0].payload, MetricEventPayload::IntegerValue(5000));
1600
1601        // Check is_owe_transition breakdown
1602        let daily_owe_metrics = test_helper.get_logged_metrics(
1603            metrics::DAILY_CONNECT_SUCCESS_RATE_BREAKDOWN_BY_IS_OWE_TRANSITION_METRIC_ID,
1604        );
1605        assert_eq!(daily_owe_metrics.len(), 1);
1606        assert_eq!(
1607            daily_owe_metrics[0].event_codes,
1608            vec![
1609                metrics::DailyConnectSuccessRateBreakdownByIsOweTransitionMetricDimensionIsOweTransition::Yes
1610                    as u32
1611            ]
1612        );
1613        assert_eq!(daily_owe_metrics[0].payload, MetricEventPayload::IntegerValue(5000));
1614    }
1615
1616    #[fuchsia::test]
1617    fn test_log_connect_attempt_cobalt_owe_transition() {
1618        let mut test_helper = setup_test();
1619        let logger = ConnectDisconnectLogger::new(
1620            test_helper.filtered_cobalt_logger(),
1621            &test_helper.inspect_node,
1622            &test_helper.inspect_metadata_node,
1623            &test_helper.inspect_metadata_path,
1624            &test_helper.mock_time_matrix_client,
1625            DeviceMobility::Mobile,
1626        );
1627
1628        // Generate BSS Description
1629        let bss_description = random_bss_description!(Wpa2,
1630            channel: Channel::new(157, Bandwidth::Cbw40, fidl_ieee80211::WlanBand::FiveGhz),
1631            bssid: [0x00, 0xf6, 0x20, 0x03, 0x04, 0x05],
1632        );
1633
1634        // Log the event with is_owe_transition = true
1635        let mut test_fut = pin!(logger.handle_connect_attempt(
1636            fidl_ieee80211::StatusCode::Success,
1637            &bss_description,
1638            false,
1639            true
1640        ));
1641        assert_eq!(
1642            test_helper.run_until_stalled_drain_cobalt_events(&mut test_fut),
1643            Poll::Ready(())
1644        );
1645
1646        let metrics_owe_transition = test_helper.get_logged_metrics(
1647            metrics::DEVICE_CONNECTED_TO_AP_BREAKDOWN_BY_IS_OWE_TRANSITION_METRIC_ID,
1648        );
1649        assert_eq!(metrics_owe_transition.len(), 1);
1650        assert_eq!(
1651            metrics_owe_transition[0].event_codes,
1652            vec![
1653                metrics::DailyConnectSuccessRateBreakdownByIsOweTransitionMetricDimensionIsOweTransition::Yes
1654                    as u32
1655            ]
1656        );
1657    }
1658
1659    #[fuchsia::test]
1660    fn test_zero_successive_connect_attempt_failures_on_suspend() {
1661        let mut test_helper = setup_test();
1662        let logger = ConnectDisconnectLogger::new(
1663            test_helper.filtered_cobalt_logger(),
1664            &test_helper.inspect_node,
1665            &test_helper.inspect_metadata_node,
1666            &test_helper.inspect_metadata_path,
1667            &test_helper.mock_time_matrix_client,
1668            DeviceMobility::Mobile,
1669        );
1670
1671        let mut test_fut = pin!(logger.handle_suspend_imminent());
1672        assert_eq!(
1673            test_helper.run_until_stalled_drain_cobalt_events(&mut test_fut),
1674            Poll::Ready(())
1675        );
1676
1677        let metrics =
1678            test_helper.get_logged_metrics(metrics::SUCCESSIVE_CONNECT_ATTEMPT_FAILURES_METRIC_ID);
1679        assert!(metrics.is_empty());
1680    }
1681
1682    #[test_case(1; "one_failure")]
1683    #[test_case(2; "two_failures")]
1684    #[fuchsia::test(add_test_attr = false)]
1685    fn test_one_or_more_successive_connect_attempt_failures_on_suspend(n_failures: usize) {
1686        let mut test_helper = setup_test();
1687        let logger = ConnectDisconnectLogger::new(
1688            test_helper.filtered_cobalt_logger(),
1689            &test_helper.inspect_node,
1690            &test_helper.inspect_metadata_node,
1691            &test_helper.inspect_metadata_path,
1692            &test_helper.mock_time_matrix_client,
1693            DeviceMobility::Mobile,
1694        );
1695
1696        let bss_description = random_bss_description!(Wpa2);
1697        for _i in 0..n_failures {
1698            let mut test_fut = pin!(logger.handle_connect_attempt(
1699                fidl_ieee80211::StatusCode::RefusedReasonUnspecified,
1700                &bss_description,
1701                false,
1702                false
1703            ));
1704            assert_eq!(
1705                test_helper.run_until_stalled_drain_cobalt_events(&mut test_fut),
1706                Poll::Ready(())
1707            );
1708        }
1709
1710        let mut test_fut = pin!(logger.handle_suspend_imminent());
1711        assert_eq!(
1712            test_helper.run_until_stalled_drain_cobalt_events(&mut test_fut),
1713            Poll::Ready(())
1714        );
1715
1716        let metrics =
1717            test_helper.get_logged_metrics(metrics::SUCCESSIVE_CONNECT_ATTEMPT_FAILURES_METRIC_ID);
1718        assert_eq!(metrics.len(), 1);
1719        assert_eq!(metrics[0].payload, MetricEventPayload::IntegerValue(n_failures as i64));
1720
1721        test_helper.clear_cobalt_events();
1722        let mut test_fut = pin!(logger.handle_suspend_imminent());
1723        assert_eq!(
1724            test_helper.run_until_stalled_drain_cobalt_events(&mut test_fut),
1725            Poll::Ready(())
1726        );
1727
1728        // Count of successive failures shouldn't be logged again since it was already logged
1729        let metrics =
1730            test_helper.get_logged_metrics(metrics::SUCCESSIVE_CONNECT_ATTEMPT_FAILURES_METRIC_ID);
1731        assert!(metrics.is_empty());
1732
1733        // Verify that the connection state has transitioned to ConnectFailed
1734        assert_matches!(*logger.connection_state.lock(), ConnectionState::ConnectFailed(_));
1735    }
1736
1737    #[fuchsia::test]
1738    fn test_log_disconnect_inspect() {
1739        let mut test_helper = setup_test();
1740        let logger = ConnectDisconnectLogger::new(
1741            test_helper.filtered_cobalt_logger(),
1742            &test_helper.inspect_node,
1743            &test_helper.inspect_metadata_node,
1744            &test_helper.inspect_metadata_path,
1745            &test_helper.mock_time_matrix_client,
1746            DeviceMobility::Mobile,
1747        );
1748
1749        // Log the event
1750        let bss_description = fake_bss_description!(Open);
1751        let channel = bss_description.channel;
1752        let disconnect_info = DisconnectInfo {
1753            iface_id: 32,
1754            connected_duration: zx::BootDuration::from_seconds(30),
1755            is_sme_reconnecting: false,
1756            disconnect_source: fidl_sme::DisconnectSource::Ap(fidl_sme::DisconnectCause {
1757                mlme_event_name: fidl_sme::DisconnectMlmeEventName::DeauthenticateIndication,
1758                reason_code: fidl_ieee80211::ReasonCode::UnspecifiedReason,
1759            }),
1760            original_bss_desc: Box::new(bss_description),
1761            current_rssi_dbm: -30,
1762            current_snr_db: 25,
1763            current_channel: channel,
1764        };
1765        let mut test_fut = pin!(logger.log_disconnect(&disconnect_info));
1766        assert_eq!(
1767            test_helper.run_until_stalled_drain_cobalt_events(&mut test_fut),
1768            Poll::Ready(())
1769        );
1770
1771        // Validate Inspect data
1772        let data = test_helper.get_inspect_data_tree();
1773        assert_data_tree!(@executor test_helper.exec, data, root: contains {
1774            test_stats: contains {
1775                metadata: contains {
1776                    connected_networks: {
1777                        "0": {
1778                            "@time": AnyNumericProperty,
1779                            "data": {
1780                                bssid: &*BSSID_REGEX,
1781                                ssid: &*SSID_REGEX,
1782                                ht_cap: AnyBytesProperty,
1783                                vht_cap: AnyBytesProperty,
1784                                protection: "Open",
1785                                is_wmm_assoc: AnyBoolProperty,
1786                                wmm_param: AnyBytesProperty,
1787                            }
1788                        }
1789                    },
1790                    disconnect_sources: {
1791                        "0": {
1792                            "@time": AnyNumericProperty,
1793                            "data": {
1794                                source: "ap",
1795                                reason: "UnspecifiedReason",
1796                                mlme_event_name: "DeauthenticateIndication",
1797                            }
1798                        }
1799                    },
1800                },
1801                disconnect_events: {
1802                    "0": {
1803                        "@time": AnyNumericProperty,
1804                        connected_duration: zx::BootDuration::from_seconds(30).into_nanos(),
1805                        disconnect_source_id: 0u64,
1806                        network_id: 0u64,
1807                        rssi_dbm: -30i64,
1808                        snr_db: 25i64,
1809                        channel: AnyStringProperty,
1810                    }
1811                }
1812            }
1813        });
1814
1815        let mut time_matrix_calls = test_helper.mock_time_matrix_client.drain_calls();
1816        assert_eq!(
1817            &time_matrix_calls.drain::<u64>("wlan_connectivity_states")[..],
1818            &[TimeMatrixCall::Fold(Timed::now(1 << 0)), TimeMatrixCall::Fold(Timed::now(1 << 1)),]
1819        );
1820        assert_eq!(
1821            &time_matrix_calls.drain::<u64>("disconnected_networks")[..],
1822            &[TimeMatrixCall::Fold(Timed::now(1 << 0))]
1823        );
1824        assert_eq!(
1825            &time_matrix_calls.drain::<u64>("disconnect_sources")[..],
1826            &[TimeMatrixCall::Fold(Timed::now(1 << 0))]
1827        );
1828    }
1829
1830    #[fuchsia::test]
1831    fn test_log_disconnect_cobalt() {
1832        let mut test_helper = setup_test();
1833        let logger = ConnectDisconnectLogger::new(
1834            test_helper.filtered_cobalt_logger(),
1835            &test_helper.inspect_node,
1836            &test_helper.inspect_metadata_node,
1837            &test_helper.inspect_metadata_path,
1838            &test_helper.mock_time_matrix_client,
1839            DeviceMobility::Mobile,
1840        );
1841
1842        // Log the event
1843        let disconnect_info = DisconnectInfo {
1844            connected_duration: zx::BootDuration::from_millis(300_000),
1845            disconnect_source: fidl_sme::DisconnectSource::Ap(fidl_sme::DisconnectCause {
1846                mlme_event_name: fidl_sme::DisconnectMlmeEventName::DeauthenticateIndication,
1847                reason_code: fidl_ieee80211::ReasonCode::ApInitiated,
1848            }),
1849            ..fake_disconnect_info()
1850        };
1851        let mut test_fut = pin!(logger.log_disconnect(&disconnect_info));
1852        assert_eq!(
1853            test_helper.run_until_stalled_drain_cobalt_events(&mut test_fut),
1854            Poll::Ready(())
1855        );
1856
1857        let disconnect_count_metrics =
1858            test_helper.get_logged_metrics(metrics::TOTAL_DISCONNECT_COUNT_METRIC_ID);
1859        assert_eq!(disconnect_count_metrics.len(), 1);
1860        assert_eq!(disconnect_count_metrics[0].payload, MetricEventPayload::Count(1));
1861
1862        let connected_duration_metrics =
1863            test_helper.get_logged_metrics(metrics::CONNECTED_DURATION_ON_DISCONNECT_METRIC_ID);
1864        assert_eq!(connected_duration_metrics.len(), 1);
1865        assert_eq!(
1866            connected_duration_metrics[0].payload,
1867            MetricEventPayload::IntegerValue(300_000)
1868        );
1869
1870        let disconnect_by_reason_metrics =
1871            test_helper.get_logged_metrics(metrics::DISCONNECT_BREAKDOWN_BY_REASON_CODE_METRIC_ID);
1872        assert_eq!(disconnect_by_reason_metrics.len(), 1);
1873        assert_eq!(disconnect_by_reason_metrics[0].payload, MetricEventPayload::Count(1));
1874        assert_eq!(disconnect_by_reason_metrics[0].event_codes.len(), 2);
1875        assert_eq!(
1876            disconnect_by_reason_metrics[0].event_codes[0],
1877            fidl_ieee80211::ReasonCode::ApInitiated.into_primitive() as u32
1878        );
1879        assert_eq!(
1880            disconnect_by_reason_metrics[0].event_codes[1],
1881            metrics::ConnectivityWlanMetricDimensionDisconnectSource::Ap as u32
1882        );
1883    }
1884
1885    #[test_case(
1886        fidl_sme::DisconnectSource::Ap(fidl_sme::DisconnectCause {
1887            mlme_event_name: fidl_sme::DisconnectMlmeEventName::DeauthenticateIndication,
1888            reason_code: fidl_ieee80211::ReasonCode::UnspecifiedReason,
1889        }),
1890        true;
1891        "ap_disconnect_source"
1892    )]
1893    #[test_case(
1894        fidl_sme::DisconnectSource::Mlme(fidl_sme::DisconnectCause {
1895            mlme_event_name: fidl_sme::DisconnectMlmeEventName::DeauthenticateIndication,
1896            reason_code: fidl_ieee80211::ReasonCode::UnspecifiedReason,
1897        }),
1898        true;
1899        "mlme_disconnect_source_not_link_failed"
1900    )]
1901    #[test_case(
1902        fidl_sme::DisconnectSource::Mlme(fidl_sme::DisconnectCause {
1903            mlme_event_name: fidl_sme::DisconnectMlmeEventName::DeauthenticateIndication,
1904            reason_code: fidl_ieee80211::ReasonCode::MlmeLinkFailed,
1905        }),
1906        false;
1907        "mlme_link_failed"
1908    )]
1909    #[test_case(
1910        fidl_sme::DisconnectSource::User(fidl_sme::UserDisconnectReason::Unknown),
1911        false;
1912        "user_disconnect_source"
1913    )]
1914    #[fuchsia::test(add_test_attr = false)]
1915    fn test_log_disconnect_for_mobile_device_cobalt(
1916        disconnect_source: fidl_sme::DisconnectSource,
1917        should_log: bool,
1918    ) {
1919        let mut test_helper = setup_test();
1920        let logger = ConnectDisconnectLogger::new(
1921            test_helper.filtered_cobalt_logger(),
1922            &test_helper.inspect_node,
1923            &test_helper.inspect_metadata_node,
1924            &test_helper.inspect_metadata_path,
1925            &test_helper.mock_time_matrix_client,
1926            DeviceMobility::Mobile,
1927        );
1928
1929        // Log the event
1930        let disconnect_info = DisconnectInfo { disconnect_source, ..fake_disconnect_info() };
1931        let mut test_fut = pin!(logger.log_disconnect(&disconnect_info));
1932        assert_eq!(
1933            test_helper.run_until_stalled_drain_cobalt_events(&mut test_fut),
1934            Poll::Ready(())
1935        );
1936
1937        let metrics = test_helper
1938            .get_logged_metrics(metrics::DISCONNECT_OCCURRENCE_FOR_MOBILE_DEVICE_METRIC_ID);
1939        if should_log {
1940            assert_eq!(metrics.len(), 1);
1941            assert_eq!(metrics[0].payload, MetricEventPayload::Count(1));
1942            assert_matches!(*logger.connection_state.lock(), ConnectionState::Disconnected(_));
1943        } else {
1944            assert!(metrics.is_empty());
1945            assert_matches!(*logger.connection_state.lock(), ConnectionState::Idle(_));
1946        }
1947    }
1948
1949    #[test_case(
1950        fidl_sme::DisconnectSource::Ap(fidl_sme::DisconnectCause {
1951            mlme_event_name: fidl_sme::DisconnectMlmeEventName::DeauthenticateIndication,
1952            reason_code: fidl_ieee80211::ReasonCode::UnspecifiedReason,
1953        });
1954        "mlme_disconnect_source_not_link_failed"
1955    )]
1956    #[test_case(
1957        fidl_sme::DisconnectSource::Mlme(fidl_sme::DisconnectCause {
1958            mlme_event_name: fidl_sme::DisconnectMlmeEventName::DeauthenticateIndication,
1959            reason_code: fidl_ieee80211::ReasonCode::MlmeLinkFailed,
1960        });
1961        "mlme_link_failed"
1962    )]
1963    #[test_case(
1964        fidl_sme::DisconnectSource::User(fidl_sme::UserDisconnectReason::Unknown);
1965        "user_disconnect_source"
1966    )]
1967    #[fuchsia::test(add_test_attr = false)]
1968    fn test_log_disconnect_for_stationary_device(disconnect_source: fidl_sme::DisconnectSource) {
1969        let mut test_helper = setup_test();
1970        let logger = ConnectDisconnectLogger::new(
1971            test_helper.filtered_cobalt_logger(),
1972            &test_helper.inspect_node,
1973            &test_helper.inspect_metadata_node,
1974            &test_helper.inspect_metadata_path,
1975            &test_helper.mock_time_matrix_client,
1976            DeviceMobility::Stationary,
1977        );
1978
1979        // Log the event
1980        let disconnect_info = DisconnectInfo { disconnect_source, ..fake_disconnect_info() };
1981        let mut test_fut = pin!(logger.log_disconnect(&disconnect_info));
1982        assert_eq!(
1983            test_helper.run_until_stalled_drain_cobalt_events(&mut test_fut),
1984            Poll::Ready(())
1985        );
1986
1987        let metrics = test_helper
1988            .get_logged_metrics(metrics::DISCONNECT_OCCURRENCE_FOR_MOBILE_DEVICE_METRIC_ID);
1989        assert!(metrics.is_empty());
1990        assert_matches!(*logger.connection_state.lock(), ConnectionState::Disconnected(_));
1991    }
1992
1993    #[fuchsia::test]
1994    fn test_log_downtime_post_disconnect_on_reconnect() {
1995        let mut test_helper = setup_test();
1996        let logger = ConnectDisconnectLogger::new(
1997            test_helper.filtered_cobalt_logger(),
1998            &test_helper.inspect_node,
1999            &test_helper.inspect_metadata_node,
2000            &test_helper.inspect_metadata_path,
2001            &test_helper.mock_time_matrix_client,
2002            DeviceMobility::Mobile,
2003        );
2004
2005        // Connect at 15th second
2006        test_helper.exec.set_fake_time(fasync::MonotonicInstant::from_nanos(15_000_000_000));
2007        let bss_description = random_bss_description!(Wpa2);
2008        let mut test_fut = pin!(logger.handle_connect_attempt(
2009            fidl_ieee80211::StatusCode::Success,
2010            &bss_description,
2011            false,
2012            false
2013        ));
2014        assert_eq!(
2015            test_helper.run_until_stalled_drain_cobalt_events(&mut test_fut),
2016            Poll::Ready(())
2017        );
2018
2019        // Verify no downtime metric is logged on first successful connect
2020        let metrics = test_helper.get_logged_metrics(metrics::DOWNTIME_POST_DISCONNECT_METRIC_ID);
2021        assert!(metrics.is_empty());
2022
2023        // Verify that the connection state has transitioned to Connected
2024        assert_matches!(*logger.connection_state.lock(), ConnectionState::Connected(_));
2025
2026        // Disconnect at 25th second
2027        test_helper.exec.set_fake_time(fasync::MonotonicInstant::from_nanos(25_000_000_000));
2028        let disconnect_info = DisconnectInfo {
2029            connected_duration: zx::BootDuration::from_millis(300_000),
2030            disconnect_source: fidl_sme::DisconnectSource::Ap(fidl_sme::DisconnectCause {
2031                mlme_event_name: fidl_sme::DisconnectMlmeEventName::DeauthenticateIndication,
2032                reason_code: fidl_ieee80211::ReasonCode::ApInitiated,
2033            }),
2034            ..fake_disconnect_info()
2035        };
2036        let mut test_fut = pin!(logger.log_disconnect(&disconnect_info));
2037        assert_eq!(
2038            test_helper.run_until_stalled_drain_cobalt_events(&mut test_fut),
2039            Poll::Ready(())
2040        );
2041
2042        // Verify that the connection state has transitioned to Disconnected
2043        assert_matches!(*logger.connection_state.lock(), ConnectionState::Disconnected(_));
2044
2045        // Reconnect at 60th second
2046        test_helper.exec.set_fake_time(fasync::MonotonicInstant::from_nanos(60_000_000_000));
2047        let mut test_fut = pin!(logger.handle_connect_attempt(
2048            fidl_ieee80211::StatusCode::Success,
2049            &bss_description,
2050            false,
2051            false
2052        ));
2053        assert_eq!(
2054            test_helper.run_until_stalled_drain_cobalt_events(&mut test_fut),
2055            Poll::Ready(())
2056        );
2057
2058        // Verify that downtime metric is logged
2059        let metrics = test_helper.get_logged_metrics(metrics::DOWNTIME_POST_DISCONNECT_METRIC_ID);
2060        assert_eq!(metrics.len(), 1);
2061        assert_eq!(metrics[0].payload, MetricEventPayload::IntegerValue(35_000));
2062
2063        // Verify that the connection state has transitioned to Connected
2064        assert_matches!(*logger.connection_state.lock(), ConnectionState::Connected(_));
2065    }
2066
2067    #[fuchsia::test]
2068    fn test_log_iface_destroyed() {
2069        let mut test_helper = setup_test();
2070        let logger = ConnectDisconnectLogger::new(
2071            test_helper.filtered_cobalt_logger(),
2072            &test_helper.inspect_node,
2073            &test_helper.inspect_metadata_node,
2074            &test_helper.inspect_metadata_path,
2075            &test_helper.mock_time_matrix_client,
2076            DeviceMobility::Mobile,
2077        );
2078
2079        // Log connect event to move state to connected
2080        let bss_description = random_bss_description!();
2081        let mut test_fut = pin!(logger.handle_connect_attempt(
2082            fidl_ieee80211::StatusCode::Success,
2083            &bss_description,
2084            false,
2085            false
2086        ));
2087        assert_eq!(
2088            test_helper.run_until_stalled_drain_cobalt_events(&mut test_fut),
2089            Poll::Ready(())
2090        );
2091
2092        // Verify that the connection state has transitioned to Connected
2093        assert_matches!(*logger.connection_state.lock(), ConnectionState::Connected(_));
2094
2095        // Log iface destroyed event to move state to idle
2096        let mut test_fut = pin!(logger.handle_iface_destroyed());
2097        assert_eq!(
2098            test_helper.run_until_stalled_drain_cobalt_events(&mut test_fut),
2099            Poll::Ready(())
2100        );
2101
2102        let mut time_matrix_calls = test_helper.mock_time_matrix_client.drain_calls();
2103        assert_eq!(
2104            &time_matrix_calls.drain::<u64>("wlan_connectivity_states")[..],
2105            &[
2106                TimeMatrixCall::Fold(Timed::now(1 << 0)),
2107                TimeMatrixCall::Fold(Timed::now(1 << 3)),
2108                TimeMatrixCall::Fold(Timed::now(1 << 0))
2109            ]
2110        );
2111
2112        // Verify that the connection state has transitioned to Idle
2113        assert_matches!(*logger.connection_state.lock(), ConnectionState::Idle(_));
2114    }
2115
2116    #[fuchsia::test]
2117    fn test_log_disable_client_connections() {
2118        let mut test_helper = setup_test();
2119        let logger = ConnectDisconnectLogger::new(
2120            test_helper.filtered_cobalt_logger(),
2121            &test_helper.inspect_node,
2122            &test_helper.inspect_metadata_node,
2123            &test_helper.inspect_metadata_path,
2124            &test_helper.mock_time_matrix_client,
2125            DeviceMobility::Mobile,
2126        );
2127
2128        // Log connect event to move state to connected
2129        let bss_description = random_bss_description!();
2130        let mut test_fut = pin!(logger.handle_connect_attempt(
2131            fidl_ieee80211::StatusCode::Success,
2132            &bss_description,
2133            false,
2134            false
2135        ));
2136        assert_eq!(
2137            test_helper.run_until_stalled_drain_cobalt_events(&mut test_fut),
2138            Poll::Ready(())
2139        );
2140
2141        // Verify that the connection state has transitioned to Connected
2142        assert_matches!(*logger.connection_state.lock(), ConnectionState::Connected(_));
2143
2144        // Disable client connections to move state to idle
2145        let mut test_fut =
2146            pin!(logger.handle_client_connections_toggle(&ClientConnectionsToggleEvent::Disabled));
2147        assert_eq!(
2148            test_helper.run_until_stalled_drain_cobalt_events(&mut test_fut),
2149            Poll::Ready(())
2150        );
2151
2152        let mut time_matrix_calls = test_helper.mock_time_matrix_client.drain_calls();
2153        assert_eq!(
2154            &time_matrix_calls.drain::<u64>("wlan_connectivity_states")[..],
2155            &[
2156                TimeMatrixCall::Fold(Timed::now(1 << 0)),
2157                TimeMatrixCall::Fold(Timed::now(1 << 3)),
2158                TimeMatrixCall::Fold(Timed::now(1 << 0))
2159            ]
2160        );
2161
2162        // Verify that the connection state has transitioned to Idle
2163        assert_matches!(*logger.connection_state.lock(), ConnectionState::Idle(_));
2164    }
2165
2166    #[fuchsia::test]
2167    fn test_wlan_connectivity_states_credential_rejected() {
2168        let mut test_helper = setup_test();
2169        let logger = ConnectDisconnectLogger::new(
2170            test_helper.filtered_cobalt_logger(),
2171            &test_helper.inspect_node,
2172            &test_helper.inspect_metadata_node,
2173            &test_helper.inspect_metadata_path,
2174            &test_helper.mock_time_matrix_client,
2175            DeviceMobility::Mobile,
2176        );
2177
2178        // Log connect failure with credential rejected to move state to idle
2179        let bss_description = random_bss_description!();
2180        let mut test_fut = pin!(logger.handle_connect_attempt(
2181            fidl_ieee80211::StatusCode::RefusedReasonUnspecified,
2182            &bss_description,
2183            true,
2184            false
2185        ));
2186        assert_eq!(
2187            test_helper.run_until_stalled_drain_cobalt_events(&mut test_fut),
2188            Poll::Ready(())
2189        );
2190
2191        assert_matches!(*logger.connection_state.lock(), ConnectionState::Idle(_));
2192    }
2193
2194    #[fuchsia::test]
2195    fn test_wlan_connectivity_states_failed_to_start() {
2196        let mut test_helper = setup_test();
2197        let logger = ConnectDisconnectLogger::new(
2198            test_helper.filtered_cobalt_logger(),
2199            &test_helper.inspect_node,
2200            &test_helper.inspect_metadata_node,
2201            &test_helper.inspect_metadata_path,
2202            &test_helper.mock_time_matrix_client,
2203            DeviceMobility::Mobile,
2204        );
2205
2206        let mut test_fut = pin!(logger.handle_client_connections_failed_to_start());
2207        assert_eq!(
2208            test_helper.run_until_stalled_drain_cobalt_events(&mut test_fut),
2209            Poll::Ready(())
2210        );
2211
2212        let mut time_matrix_calls = test_helper.mock_time_matrix_client.drain_calls();
2213        assert_eq!(
2214            &time_matrix_calls.drain::<u64>("wlan_connectivity_states")[..],
2215            &[
2216                TimeMatrixCall::Fold(Timed::now(1 << 0)), // Initialization
2217                TimeMatrixCall::Fold(Timed::now(1 << 4)), // FailedToStart ID is 4 -> bit 1 << 4
2218            ]
2219        );
2220        assert_matches!(*logger.connection_state.lock(), ConnectionState::FailedToStart(_));
2221    }
2222
2223    #[fuchsia::test]
2224    fn test_wlan_connectivity_states_failed_to_stop() {
2225        let mut test_helper = setup_test();
2226        let logger = ConnectDisconnectLogger::new(
2227            test_helper.filtered_cobalt_logger(),
2228            &test_helper.inspect_node,
2229            &test_helper.inspect_metadata_node,
2230            &test_helper.inspect_metadata_path,
2231            &test_helper.mock_time_matrix_client,
2232            DeviceMobility::Mobile,
2233        );
2234
2235        let mut test_fut = pin!(logger.handle_client_connections_failed_to_stop());
2236        assert_eq!(
2237            test_helper.run_until_stalled_drain_cobalt_events(&mut test_fut),
2238            Poll::Ready(())
2239        );
2240
2241        let mut time_matrix_calls = test_helper.mock_time_matrix_client.drain_calls();
2242        assert_eq!(
2243            &time_matrix_calls.drain::<u64>("wlan_connectivity_states")[..],
2244            &[
2245                TimeMatrixCall::Fold(Timed::now(1 << 0)), // Initialization
2246                TimeMatrixCall::Fold(Timed::now(1 << 5)), // FailedToStop ID is 5 -> bit 1 << 5
2247            ]
2248        );
2249
2250        assert_matches!(*logger.connection_state.lock(), ConnectionState::FailedToStop(_));
2251    }
2252
2253    #[test_case(ConnectionState::Idle(IdleState {}))]
2254    #[test_case(ConnectionState::Disconnected(DisconnectedState {}))]
2255    #[test_case(ConnectionState::ConnectFailed(ConnectFailedState {}))]
2256    #[test_case(ConnectionState::PnoScanFailedIdle(PnoScanFailedIdleState {}))]
2257    fn test_connectivity_state_transition_on_pno_scan_failure(initial_state: ConnectionState) {
2258        let mut test_helper = setup_test();
2259        let logger = ConnectDisconnectLogger::new(
2260            test_helper.filtered_cobalt_logger(),
2261            &test_helper.inspect_node,
2262            &test_helper.inspect_metadata_node,
2263            &test_helper.inspect_metadata_path,
2264            &test_helper.mock_time_matrix_client,
2265            DeviceMobility::Mobile,
2266        );
2267
2268        // Transition to initial state
2269        *logger.connection_state.lock() = initial_state.clone();
2270
2271        // Log a PNO scan failure
2272        let mut test_fut = pin!(logger.handle_pno_scan_failure());
2273        assert_matches!(
2274            test_helper.run_until_stalled_drain_cobalt_events(&mut test_fut),
2275            Poll::Ready(())
2276        );
2277
2278        // Verify the metrics were logged
2279        let metric_events = test_helper
2280            .get_logged_metrics(metrics::PNO_SCAN_FAILURE_WHILE_NOT_CONNECTED_OCCURRENCE_METRIC_ID);
2281        assert_eq!(metric_events.len(), 1);
2282        assert_eq!(metric_events[0].payload, MetricEventPayload::Count(1));
2283
2284        let metric_events =
2285            test_helper.get_logged_metrics(metrics::PNO_SCAN_FAILURE_OCCURRENCE_METRIC_ID);
2286        assert_eq!(metric_events.len(), 1);
2287        assert_eq!(metric_events[0].payload, MetricEventPayload::Count(1));
2288
2289        // Verify the time matrix shows the PNO scan failure state
2290        let mut time_matrix_calls = test_helper.mock_time_matrix_client.drain_calls();
2291        assert_eq!(
2292            *time_matrix_calls.drain::<u64>("wlan_connectivity_states")[..].last().unwrap(),
2293            TimeMatrixCall::Fold(Timed::now(1 << 6)), // PnoScanFailedIdle ID is 6 -> bit 1 << 6
2294        );
2295
2296        // A PNO scan failure should cause a transition to PnoScanFailedIdle
2297        assert_matches!(*logger.connection_state.lock(), ConnectionState::PnoScanFailedIdle(_));
2298    }
2299
2300    #[test_case(ConnectionState::Connected(ConnectedState {}))]
2301    #[test_case(ConnectionState::FailedToStart(FailedToStartState {}))]
2302    #[test_case(ConnectionState::FailedToStop(FailedToStopState {}))]
2303    fn test_no_connectivity_state_transition_on_pno_scan_failure(initial_state: ConnectionState) {
2304        let mut test_helper = setup_test();
2305        let logger = ConnectDisconnectLogger::new(
2306            test_helper.filtered_cobalt_logger(),
2307            &test_helper.inspect_node,
2308            &test_helper.inspect_metadata_node,
2309            &test_helper.inspect_metadata_path,
2310            &test_helper.mock_time_matrix_client,
2311            DeviceMobility::Mobile,
2312        );
2313
2314        // Transition to initial state
2315        *logger.connection_state.lock() = initial_state.clone();
2316
2317        // Log a PNO scan failure
2318        let mut test_fut = pin!(logger.handle_pno_scan_failure());
2319        assert_matches!(
2320            test_helper.run_until_stalled_drain_cobalt_events(&mut test_fut),
2321            Poll::Ready(())
2322        );
2323
2324        // Verify the metrics were logged
2325        let metric_events =
2326            test_helper.get_logged_metrics(metrics::PNO_SCAN_FAILURE_OCCURRENCE_METRIC_ID);
2327        assert_eq!(metric_events.len(), 1);
2328        assert_eq!(metric_events[0].payload, MetricEventPayload::Count(1));
2329
2330        // State should not change
2331        assert_eq!(logger.connection_state.lock().to_id(), initial_state.to_id());
2332    }
2333
2334    #[fuchsia::test]
2335    fn test_wlan_connectivity_states_bitset_map_size() {
2336        let enum_variant_count = ConnectionState::iter().count();
2337        let bitset_map_size =
2338            ConnectDisconnectTimeSeries::wlan_connectivity_states_bitset_map().len();
2339        assert_eq!(enum_variant_count, bitset_map_size);
2340    }
2341
2342    fn fake_disconnect_info() -> DisconnectInfo {
2343        let bss_description = random_bss_description!(Wpa2);
2344        let channel = bss_description.channel;
2345        DisconnectInfo {
2346            iface_id: 1,
2347            connected_duration: zx::BootDuration::from_hours(6),
2348            is_sme_reconnecting: false,
2349            disconnect_source: fidl_sme::DisconnectSource::User(
2350                fidl_sme::UserDisconnectReason::Unknown,
2351            ),
2352            original_bss_desc: bss_description.into(),
2353            current_rssi_dbm: -30,
2354            current_snr_db: 25,
2355            current_channel: channel,
2356        }
2357    }
2358}