Skip to main content

wlan_telemetry/processors/
pno_scan.rs

1// Copyright 2026 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::util::cobalt_logger::log_cobalt_batch;
6use fidl_fuchsia_metrics::{MetricEvent, MetricEventPayload};
7use fuchsia_async as fasync;
8use wlan_legacy_metrics_registry as metrics;
9
10#[derive(Debug, Clone, Copy, PartialEq)]
11pub enum PnoScanDisabledReason {
12    ApiRequest,
13    Internal,
14    Firmware,
15}
16
17pub struct PnoScanLogger {
18    cobalt_proxy: fidl_fuchsia_metrics::MetricEventLoggerProxy,
19    enabled_at: Option<fasync::BootInstant>,
20    has_scan_results: bool,
21}
22
23impl PnoScanLogger {
24    pub fn new(cobalt_proxy: fidl_fuchsia_metrics::MetricEventLoggerProxy) -> Self {
25        Self { cobalt_proxy, enabled_at: None, has_scan_results: false }
26    }
27
28    pub async fn handle_pno_scan_enabled(&mut self, is_connected: bool) {
29        if self.enabled_at.is_none() {
30            self.enabled_at = Some(fasync::BootInstant::now());
31            self.has_scan_results = false;
32
33            if is_connected {
34                let metric_events = vec![MetricEvent {
35                    metric_id: metrics::PNO_SCAN_ENABLED_WHILE_CONNECTED_METRIC_ID,
36                    event_codes: vec![],
37                    payload: MetricEventPayload::Count(1),
38                }];
39                log_cobalt_batch!(
40                    self.cobalt_proxy,
41                    &metric_events,
42                    "pno_scan_enabled_while_connected"
43                );
44            }
45        } else {
46            // It is unexpected that PNO scans are enabled and then enabled again prior to a
47            // cancellation of the first request.  The accounting surrounding PNO scan enablement
48            // should not be updated in this case, since PNO scans have been enabled since the
49            // first enablement.
50            let metric_events = vec![MetricEvent {
51                metric_id: metrics::PNO_SCAN_REQUEST_COLLISION_METRIC_ID,
52                event_codes: vec![],
53                payload: MetricEventPayload::Count(1),
54            }];
55            log_cobalt_batch!(self.cobalt_proxy, &metric_events, "handle_pno_scan_enabled");
56        }
57    }
58
59    pub async fn handle_pno_scan_results_received(&mut self) {
60        // Log only the first instance of PNO scan results.  This metric is only logged if PNO
61        // scans are currently enabled.  Scan results are not expected to be received if PNO
62        // scans are not enabled.
63        if !self.has_scan_results {
64            self.has_scan_results = true;
65
66            if let Some(enabled_at) = self.enabled_at {
67                let elapsed = fasync::BootInstant::now() - enabled_at;
68                let metric_events = vec![MetricEvent {
69                    metric_id: metrics::PNO_SCAN_FIRST_RESULTS_ELAPSED_TIME_METRIC_ID,
70                    event_codes: vec![],
71                    payload: MetricEventPayload::IntegerValue(elapsed.into_millis()),
72                }];
73                log_cobalt_batch!(
74                    self.cobalt_proxy,
75                    &metric_events,
76                    "handle_pno_scan_results_received"
77                );
78            }
79        }
80    }
81
82    pub async fn handle_pno_scan_disabled(&mut self, reason: PnoScanDisabledReason) {
83        if let Some(enabled_at) = self.enabled_at.take() {
84            let now = fasync::BootInstant::now();
85            let elapsed = now - enabled_at;
86
87            let mut metric_events = vec![];
88
89            // Log elapsed time
90            metric_events.push(MetricEvent {
91                metric_id: metrics::PNO_SCAN_CANCELLED_ELAPSED_TIME_METRIC_ID,
92                event_codes: vec![if self.has_scan_results {
93                    metrics::PnoScanCancelledElapsedTimeMetricDimensionHadAnyScanResults::True
94                        as u32
95                } else {
96                    metrics::PnoScanCancelledElapsedTimeMetricDimensionHadAnyScanResults::False
97                        as u32
98                }],
99                payload: MetricEventPayload::IntegerValue(elapsed.into_millis()),
100            });
101
102            // Log cancellation source and presence of scan results
103            let had_scan_results = if self.has_scan_results {
104                metrics::PnoScanCancellationBreakdownByResultsAndSourceMetricDimensionHasScanResults::True as u32
105            } else {
106                metrics::PnoScanCancellationBreakdownByResultsAndSourceMetricDimensionHasScanResults::False as u32
107            };
108
109            let cancellation_source = match reason {
110                PnoScanDisabledReason::ApiRequest => metrics::PnoScanCancellationBreakdownByResultsAndSourceMetricDimensionCancellationSource::ApiRequest as u32,
111                PnoScanDisabledReason::Internal => metrics::PnoScanCancellationBreakdownByResultsAndSourceMetricDimensionCancellationSource::Internal as u32,
112                PnoScanDisabledReason::Firmware => metrics::PnoScanCancellationBreakdownByResultsAndSourceMetricDimensionCancellationSource::Firmware as u32,
113            };
114
115            metric_events.push(MetricEvent {
116                metric_id: metrics::PNO_SCAN_CANCELLATION_BREAKDOWN_BY_RESULTS_AND_SOURCE_METRIC_ID,
117                event_codes: vec![had_scan_results, cancellation_source],
118                payload: MetricEventPayload::Count(1),
119            });
120
121            log_cobalt_batch!(self.cobalt_proxy, &metric_events, "handle_pno_scan_disabled");
122        }
123
124        self.has_scan_results = false;
125    }
126
127    pub async fn handle_periodic_telemetry(&mut self) {
128        if let Some(enabled_at) = self.enabled_at {
129            let elapsed = fasync::BootInstant::now() - enabled_at;
130            let hours = elapsed.into_hours();
131            let capped_hours = std::cmp::min(hours, 24);
132
133            let metric_events = vec![MetricEvent {
134                metric_id: metrics::ONGOING_PNO_SCAN_ELAPSED_HOURS_METRIC_ID,
135                event_codes: vec![],
136                payload: MetricEventPayload::IntegerValue(capped_hours),
137            }];
138            log_cobalt_batch!(self.cobalt_proxy, &metric_events, "handle_periodic_telemetry");
139        }
140    }
141}
142
143#[cfg(test)]
144mod tests {
145    use super::*;
146    use crate::testing::setup_test;
147    use fidl_fuchsia_metrics::MetricEventPayload;
148    use futures::task::Poll;
149    use std::pin::pin;
150
151    #[fuchsia::test]
152    fn test_pno_scan_collision() {
153        let mut test_helper = setup_test();
154        let mut logger = PnoScanLogger::new(test_helper.cobalt_proxy.clone());
155
156        {
157            let mut test_fut = pin!(logger.handle_pno_scan_enabled(false));
158            assert_eq!(
159                test_helper.run_until_stalled_drain_cobalt_events(&mut test_fut),
160                Poll::Ready(())
161            );
162        }
163
164        // Call again to trigger collision
165        {
166            let mut test_fut = pin!(logger.handle_pno_scan_enabled(false));
167            assert_eq!(
168                test_helper.run_until_stalled_drain_cobalt_events(&mut test_fut),
169                Poll::Ready(())
170            );
171        }
172
173        let metrics = test_helper.get_logged_metrics(metrics::PNO_SCAN_REQUEST_COLLISION_METRIC_ID);
174        assert_eq!(metrics.len(), 1);
175        assert_eq!(metrics[0].payload, MetricEventPayload::Count(1));
176    }
177
178    #[fuchsia::test]
179    fn test_pno_scan_enabled_while_connected() {
180        let mut test_helper = setup_test();
181        let mut logger = PnoScanLogger::new(test_helper.cobalt_proxy.clone());
182
183        {
184            let mut test_fut = pin!(logger.handle_pno_scan_enabled(true));
185            assert_eq!(
186                test_helper.run_until_stalled_drain_cobalt_events(&mut test_fut),
187                Poll::Ready(())
188            );
189        }
190
191        let metrics =
192            test_helper.get_logged_metrics(metrics::PNO_SCAN_ENABLED_WHILE_CONNECTED_METRIC_ID);
193        assert_eq!(metrics.len(), 1);
194        assert_eq!(metrics[0].payload, MetricEventPayload::Count(1));
195    }
196
197    #[fuchsia::test]
198    fn test_pno_scan_results_received_metrics() {
199        let mut test_helper = setup_test();
200        let mut logger = PnoScanLogger::new(test_helper.cobalt_proxy.clone());
201
202        test_helper.exec.set_fake_time(fasync::MonotonicInstant::from_nanos(10_000_000));
203        {
204            let mut test_fut = pin!(logger.handle_pno_scan_enabled(false));
205            assert_eq!(
206                test_helper.run_until_stalled_drain_cobalt_events(&mut test_fut),
207                Poll::Ready(())
208            );
209        }
210
211        test_helper.exec.set_fake_time(fasync::MonotonicInstant::from_nanos(25_000_000));
212        {
213            let mut test_fut = pin!(logger.handle_pno_scan_results_received());
214            assert_eq!(
215                test_helper.run_until_stalled_drain_cobalt_events(&mut test_fut),
216                Poll::Ready(())
217            );
218        }
219
220        let metrics =
221            test_helper.get_logged_metrics(metrics::PNO_SCAN_FIRST_RESULTS_ELAPSED_TIME_METRIC_ID);
222        assert_eq!(metrics.len(), 1);
223        assert_eq!(metrics[0].payload, MetricEventPayload::IntegerValue(15)); // 15ms
224    }
225
226    #[fuchsia::test]
227    fn test_pno_scan_disabled_no_results() {
228        let mut test_helper = setup_test();
229        let mut logger = PnoScanLogger::new(test_helper.cobalt_proxy.clone());
230
231        test_helper.exec.set_fake_time(fasync::MonotonicInstant::from_nanos(10_000_000));
232        {
233            let mut test_fut = pin!(logger.handle_pno_scan_enabled(false));
234            assert_eq!(
235                test_helper.run_until_stalled_drain_cobalt_events(&mut test_fut),
236                Poll::Ready(())
237            );
238        }
239
240        test_helper.exec.set_fake_time(fasync::MonotonicInstant::from_nanos(30_000_000));
241        {
242            let mut test_fut =
243                pin!(logger.handle_pno_scan_disabled(PnoScanDisabledReason::Internal));
244            assert_eq!(
245                test_helper.run_until_stalled_drain_cobalt_events(&mut test_fut),
246                Poll::Ready(())
247            );
248        }
249
250        let metrics =
251            test_helper.get_logged_metrics(metrics::PNO_SCAN_CANCELLED_ELAPSED_TIME_METRIC_ID);
252        assert_eq!(metrics.len(), 1);
253        assert_eq!(metrics[0].payload, MetricEventPayload::IntegerValue(20)); // 20ms
254        assert_eq!(
255            metrics[0].event_codes,
256            vec![
257                metrics::PnoScanCancelledElapsedTimeMetricDimensionHadAnyScanResults::False as u32
258            ]
259        ); // no results
260
261        let metrics = test_helper.get_logged_metrics(
262            metrics::PNO_SCAN_CANCELLATION_BREAKDOWN_BY_RESULTS_AND_SOURCE_METRIC_ID,
263        );
264        assert_eq!(metrics.len(), 1);
265        assert_eq!(metrics[0].event_codes, vec![
266            metrics::PnoScanCancellationBreakdownByResultsAndSourceMetricDimensionHasScanResults::False as u32,
267            metrics::PnoScanCancellationBreakdownByResultsAndSourceMetricDimensionCancellationSource::Internal as u32
268        ]); // no results, Internal
269    }
270
271    #[fuchsia::test]
272    fn test_pno_scan_disabled_with_results() {
273        let mut test_helper = setup_test();
274        let mut logger = PnoScanLogger::new(test_helper.cobalt_proxy.clone());
275
276        test_helper.exec.set_fake_time(fasync::MonotonicInstant::from_nanos(10_000_000));
277        {
278            let mut test_fut = pin!(logger.handle_pno_scan_enabled(false));
279            assert_eq!(
280                test_helper.run_until_stalled_drain_cobalt_events(&mut test_fut),
281                Poll::Ready(())
282            );
283        }
284
285        test_helper.exec.set_fake_time(fasync::MonotonicInstant::from_nanos(20_000_000));
286        {
287            let mut test_fut = pin!(logger.handle_pno_scan_results_received());
288            assert_eq!(
289                test_helper.run_until_stalled_drain_cobalt_events(&mut test_fut),
290                Poll::Ready(())
291            );
292        }
293
294        let metrics =
295            test_helper.get_logged_metrics(metrics::PNO_SCAN_FIRST_RESULTS_ELAPSED_TIME_METRIC_ID);
296        assert_eq!(metrics.len(), 1);
297        assert_eq!(metrics[0].payload, MetricEventPayload::IntegerValue(10)); // 10ms
298
299        test_helper.exec.set_fake_time(fasync::MonotonicInstant::from_nanos(30_000_000));
300        {
301            let mut test_fut =
302                pin!(logger.handle_pno_scan_disabled(PnoScanDisabledReason::ApiRequest));
303            assert_eq!(
304                test_helper.run_until_stalled_drain_cobalt_events(&mut test_fut),
305                Poll::Ready(())
306            );
307        }
308
309        let metrics =
310            test_helper.get_logged_metrics(metrics::PNO_SCAN_CANCELLED_ELAPSED_TIME_METRIC_ID);
311        assert_eq!(metrics.len(), 1);
312        assert_eq!(metrics[0].payload, MetricEventPayload::IntegerValue(20)); // 20ms
313        assert_eq!(
314            metrics[0].event_codes,
315            vec![metrics::PnoScanCancelledElapsedTimeMetricDimensionHadAnyScanResults::True as u32]
316        ); // had results
317
318        let metrics = test_helper.get_logged_metrics(
319            metrics::PNO_SCAN_CANCELLATION_BREAKDOWN_BY_RESULTS_AND_SOURCE_METRIC_ID,
320        );
321        assert_eq!(metrics.len(), 1);
322        assert_eq!(metrics[0].event_codes, vec![
323            metrics::PnoScanCancellationBreakdownByResultsAndSourceMetricDimensionHasScanResults::True as u32,
324            metrics::PnoScanCancellationBreakdownByResultsAndSourceMetricDimensionCancellationSource::ApiRequest as u32
325        ]); // had results, ApiRequest
326    }
327
328    #[fuchsia::test]
329    fn test_pno_scan_periodic_telemetry() {
330        let mut test_helper = setup_test();
331        let mut logger = PnoScanLogger::new(test_helper.cobalt_proxy.clone());
332
333        test_helper.exec.set_fake_time(fasync::MonotonicInstant::from_nanos(10_000_000));
334        {
335            let mut test_fut = pin!(logger.handle_pno_scan_enabled(false));
336            assert_eq!(
337                test_helper.run_until_stalled_drain_cobalt_events(&mut test_fut),
338                Poll::Ready(())
339            );
340        }
341
342        // Advance time by 5 hours
343        test_helper.exec.set_fake_time(fasync::MonotonicInstant::from_nanos(
344            10_000_000 + 5 * 3600 * 1_000_000_000,
345        ));
346        {
347            let mut test_fut = pin!(logger.handle_periodic_telemetry());
348            assert_eq!(
349                test_helper.run_until_stalled_drain_cobalt_events(&mut test_fut),
350                Poll::Ready(())
351            );
352        }
353
354        let metrics =
355            test_helper.get_logged_metrics(metrics::ONGOING_PNO_SCAN_ELAPSED_HOURS_METRIC_ID);
356        assert_eq!(metrics.len(), 1);
357        assert_eq!(metrics[0].payload, MetricEventPayload::IntegerValue(5));
358
359        // Advance time by another 20 hours (total 25)
360        test_helper.exec.set_fake_time(fasync::MonotonicInstant::from_nanos(
361            10_000_000 + 25 * 3600 * 1_000_000_000,
362        ));
363        {
364            let mut test_fut = pin!(logger.handle_periodic_telemetry());
365            assert_eq!(
366                test_helper.run_until_stalled_drain_cobalt_events(&mut test_fut),
367                Poll::Ready(())
368            );
369        }
370
371        let metrics =
372            test_helper.get_logged_metrics(metrics::ONGOING_PNO_SCAN_ELAPSED_HOURS_METRIC_ID);
373        assert_eq!(metrics.len(), 2);
374        assert_eq!(metrics[1].payload, MetricEventPayload::IntegerValue(24)); // Capped at 24
375    }
376}