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