1#  Copyright (C) 2024 The Android Open Source Project
2#
3#  Licensed under the Apache License, Version 2.0 (the "License");
4#  you may not use this file except in compliance with the License.
5#  You may obtain a copy of the License at
6#
7#       http://www.apache.org/licenses/LICENSE-2.0
8#
9#  Unless required by applicable law or agreed to in writing, software
10#  distributed under the License is distributed on an "AS IS" BASIS,
11#  WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12#  See the License for the specific language governing permissions and
13#  limitations under the License.
14
15"""Nearby Connection E2E stress tests for D2D wifi performance."""
16
17import abc
18import datetime
19import logging
20import time
21from typing import Any
22
23from mobly import asserts
24from mobly.controllers import android_device
25
26from betocq import iperf_utils
27from betocq import nc_base_test
28from betocq import nc_constants
29from betocq import nearby_connection_wrapper
30from betocq import setup_utils
31
32
33_DELAY_BETWEEN_EACH_TEST_CYCLE = datetime.timedelta(seconds=0)
34_BITS_PER_BYTE = 8
35_MAX_FREQ_2G_MHZ = 2500
36_MIN_FREQ_5G_DFS_MHZ = 5260
37_MAX_FREQ_5G_DFS_MHZ = 5720
38
39
40class D2dPerformanceTestBase(nc_base_test.NCBaseTestClass, abc.ABC):
41  """Abstract class for D2D performance test for different connection meidums."""
42
43  performance_test_iterations: int
44
45  # @typing.override
46  def __init__(self, configs):
47    super().__init__(configs)
48    self._is_mcc: bool = False
49    self._is_2g_d2d_wifi_medium: bool = False
50    self._is_dbs_mode: bool = False
51    self._throughput_low_string: str = ''
52    self._upgrade_medium_under_test: nc_constants.NearbyMedium = None
53    self._connection_medium: nc_constants.NearbyMedium = None
54    self._advertising_discovery_medium: nc_constants.NearbyMedium = None
55    self._current_test_result: nc_constants.SingleTestResult = (
56        nc_constants.SingleTestResult()
57    )
58    self._performance_test_metrics: nc_constants.NcPerformanceTestMetrics = (
59        nc_constants.NcPerformanceTestMetrics()
60    )
61    self._prior_bt_nc_fail_reason: nc_constants.SingleTestFailureReason = (
62        nc_constants.SingleTestFailureReason.UNINITIALIZED
63    )
64    self._active_nc_fail_reason: nc_constants.SingleTestFailureReason = (
65        nc_constants.SingleTestFailureReason.UNINITIALIZED
66    )
67    self._finished_test_iteration: int = 0
68    self._use_prior_bt: bool = False
69    self._start_time: datetime.datetime = datetime.datetime.now()
70    self._wifi_ssid: str = ''
71    self._test_results: list[nc_constants.SingleTestResult] = []
72
73  # @typing.override
74  def setup_test(self):
75    self._current_test_result: nc_constants.SingleTestResult = (
76        nc_constants.SingleTestResult()
77    )
78    self._prior_bt_nc_fail_reason = (
79        nc_constants.SingleTestFailureReason.UNINITIALIZED
80    )
81    self._active_nc_fail_reason = (
82        nc_constants.SingleTestFailureReason.UNINITIALIZED
83    )
84    super().setup_test()
85
86  def teardown_test(self):
87    self._write_current_test_report()
88    self._collect_current_test_metrics()
89    super().teardown_test()
90    time.sleep(_DELAY_BETWEEN_EACH_TEST_CYCLE.total_seconds())
91
92  @property
93  def _devices_capabilities_definition(self) -> dict[str, dict[str, bool]]:
94    """Returns the definition of devices capabilities."""
95    return {}
96
97  # @typing.override
98  def _get_skipped_test_class_reason(self) -> str | None:
99    if not self._is_wifi_ap_ready():
100      return 'Wifi AP is not ready for this test.'
101    skip_reason = self._check_devices_capabilities()
102    if skip_reason is not None:
103      return (
104          f'The test is not required per the device capabilities. {skip_reason}'
105      )
106    return None
107
108  @abc.abstractmethod
109  def _is_wifi_ap_ready(self) -> bool:
110    pass
111
112  def _check_devices_capabilities(self) -> str | None:
113    """Checks if all devices capabilities meet requirements."""
114    for ad_role, capabilities in self._devices_capabilities_definition.items():
115      for key, value in capabilities.items():
116        ad = getattr(self, ad_role)
117        capability = getattr(ad, key)
118        if capability != value:
119          return (
120              f'{ad} {ad_role}.{key} is'
121              f' {"enabled" if capability else "disabled"}'
122          )
123    return None
124
125  def _get_target_sta_frequency_and_max_link_speed(self) -> tuple[int, int]:
126    """Gets the STA frequency and max link speed."""
127    connection_info = self.advertiser.nearby.wifiGetConnectionInfo()
128    sta_frequency = int(
129        connection_info.get('mFrequency', nc_constants.INVALID_INT)
130    )
131
132    sta_max_link_speed_mbps = int(
133        connection_info.get(
134            'mMaxSupportedTxLinkSpeed', nc_constants.INVALID_INT
135        )
136    )
137    if sta_frequency == nc_constants.INVALID_INT:
138      sta_frequency = setup_utils.get_wifi_sta_frequency(self.advertiser)
139      sta_max_link_speed_mbps = setup_utils.get_wifi_sta_max_link_speed(
140          self.advertiser
141      )
142    return (sta_frequency, sta_max_link_speed_mbps)
143
144  def _get_throughput_benchmark(
145      self, sta_frequency: int, sta_max_link_speed_mbps: int
146  ) -> tuple[float, float]:
147    """Gets the throughput benchmark as MBps."""
148    max_num_streams = min(
149        self.discoverer.max_num_streams, self.advertiser.max_num_streams
150    )
151
152    if self._is_2g_d2d_wifi_medium:
153      max_phy_rate_mbps = min(
154          self.discoverer.max_phy_rate_2g_mbps,
155          self.advertiser.max_phy_rate_2g_mbps,
156      )
157      max_phy_rate_mbps = min(
158          max_phy_rate_mbps,
159          max_num_streams * nc_constants.MAX_PHY_RATE_PER_STREAM_N_20_MBPS,
160      )
161      min_throughput_mbyte_per_sec = int(
162          max_phy_rate_mbps
163          * nc_constants.MAX_PHY_RATE_TO_MIN_THROUGHPUT_RATIO_2G
164          / _BITS_PER_BYTE
165      )
166      nc_min_throughput_mbyte_per_sec = min_throughput_mbyte_per_sec
167    else:  # 5G wifi medium
168      max_phy_rate_mbps = min(
169          self.discoverer.max_phy_rate_5g_mbps,
170          self.advertiser.max_phy_rate_5g_mbps,
171      )
172      # max_num_streams could be smaller in DBS mode
173      if self._is_dbs_mode:
174        max_num_streams = self.advertiser.max_num_streams_dbs
175
176      max_phy_rate_ac80 = (
177          max_num_streams * nc_constants.MAX_PHY_RATE_PER_STREAM_AC_80_MBPS
178      )
179      max_phy_rate_mbps = min(max_phy_rate_mbps, max_phy_rate_ac80)
180
181      # if STA is connected to 5G AP with channel BW < 80,
182      # limit the max phy rate to AC 40.
183      if (
184          sta_frequency > 5000
185          and sta_max_link_speed_mbps > 0
186          and sta_max_link_speed_mbps < max_phy_rate_ac80
187      ):
188        max_phy_rate_mbps = min(
189            max_phy_rate_mbps,
190            max_num_streams * nc_constants.MAX_PHY_RATE_PER_STREAM_AC_40_MBPS,
191        )
192
193      min_throughput_mbyte_per_sec = int(
194          max_phy_rate_mbps
195          * nc_constants.MAX_PHY_RATE_TO_MIN_THROUGHPUT_RATIO_5G
196          / _BITS_PER_BYTE
197      )
198      if self._is_mcc:
199        min_throughput_mbyte_per_sec = int(
200            min_throughput_mbyte_per_sec
201            * nc_constants.MCC_THROUGHPUT_MULTIPLIER
202        )
203
204      nc_min_throughput_mbyte_per_sec = min_throughput_mbyte_per_sec
205      if (
206          self._current_test_result.quality_info.upgrade_medium
207          == nc_constants.NearbyConnectionMedium.WIFI_LAN
208      ):
209        nc_min_throughput_mbyte_per_sec = min(
210            nc_min_throughput_mbyte_per_sec,
211            nc_constants.WLAN_THROUGHPUT_CAP_MBPS,
212        )
213
214
215    self.advertiser.log.info(
216        f'target STA freq = {sta_frequency}, '
217        f'max STA speed (Mb/s): {sta_max_link_speed_mbps}, '
218        f'max D2D speed (MB/s): {max_phy_rate_mbps / _BITS_PER_BYTE}, '
219        f'min D2D speed (MB/s), iperf: {min_throughput_mbyte_per_sec}, '
220        f'nc: {nc_min_throughput_mbyte_per_sec}'
221    )
222    return (min_throughput_mbyte_per_sec, nc_min_throughput_mbyte_per_sec)
223
224  def _test_connection_medium_performance(
225      self,
226      upgrade_medium_under_test: nc_constants.NearbyMedium,
227      wifi_ssid: str = '',  # used by discoverer and possibly advertiser
228      wifi_password: str = '',  # used by discoverer and advertiser
229      force_disable_bt_multiplex: bool = False,
230      connection_medium: nc_constants.NearbyMedium = nc_constants.NearbyMedium.BT_ONLY,
231      wifi_ssid2: str = '',  # used by advertiser if not empty
232  ) -> None:
233    """Test the D2D performance with the specified upgrade medium."""
234    self._upgrade_medium_under_test = upgrade_medium_under_test
235    self._connection_medium = connection_medium
236    self._wifi_ssid = wifi_ssid
237
238    if self.test_parameters.toggle_airplane_mode_target_side:
239      setup_utils.toggle_airplane_mode(self.advertiser)
240    advertising_discovery_medium = nc_constants.NearbyMedium(
241        self.test_parameters.advertising_discovery_medium
242    )
243    self._advertising_discovery_medium = advertising_discovery_medium
244    if self.test_parameters.reset_wifi_connection:
245      self._reset_wifi_connection()
246    # 1. discoverer connect to wifi STA/AP
247    self._current_test_result = nc_constants.SingleTestResult()
248    if wifi_ssid:
249      self._active_nc_fail_reason = (
250          nc_constants.SingleTestFailureReason.SOURCE_WIFI_CONNECTION
251      )
252      discoverer_wifi_sta_latency = (
253          setup_utils.connect_to_wifi_sta_till_success(
254              self.discoverer, wifi_ssid, wifi_password
255          )
256      )
257      self._active_nc_fail_reason = nc_constants.SingleTestFailureReason.SUCCESS
258      self.discoverer.log.info(
259          'connecting to wifi in '
260          f'{round(discoverer_wifi_sta_latency.total_seconds())} s'
261      )
262      self._current_test_result.discoverer_sta_expected = True
263      self._current_test_result.discoverer_sta_latency = (
264          discoverer_wifi_sta_latency
265      )
266
267    # 2. set up BT connection if required
268    # Because target STA is not yet connected, discovery is done over BLE only.
269    advertising_discovery_medium = nc_constants.NearbyMedium.BLE_ONLY
270
271    connection_setup_timeouts = nc_constants.ConnectionSetupTimeouts(
272        nc_constants.FIRST_DISCOVERY_TIMEOUT,
273        nc_constants.FIRST_CONNECTION_INIT_TIMEOUT,
274        nc_constants.FIRST_CONNECTION_RESULT_TIMEOUT,
275    )
276    prior_bt_snippet = None
277    if (
278        not force_disable_bt_multiplex
279        and self.test_parameters.requires_bt_multiplex
280    ):
281      logging.info('set up a prior BT connection.')
282      self._use_prior_bt = True
283      prior_bt_snippet = nearby_connection_wrapper.NearbyConnectionWrapper(
284          self.advertiser,
285          self.discoverer,
286          self.advertiser.nearby2,
287          self.discoverer.nearby2,
288          advertising_discovery_medium=nc_constants.NearbyMedium.BLE_ONLY,
289          connection_medium=nc_constants.NearbyMedium.BT_ONLY,
290          upgrade_medium=nc_constants.NearbyMedium.BT_ONLY,
291      )
292
293      try:
294        prior_bt_snippet.start_nearby_connection(
295            timeouts=connection_setup_timeouts,
296            medium_upgrade_type=nc_constants.MediumUpgradeType.NON_DISRUPTIVE,
297        )
298      finally:
299        self._prior_bt_nc_fail_reason = prior_bt_snippet.test_failure_reason
300        self._current_test_result.prior_nc_quality_info = (
301            prior_bt_snippet.connection_quality_info
302        )
303
304    # set up Wifi connection and transfer
305    # 3. advertiser connect to wifi STA/AP
306    wifi_ssid_advertiser = wifi_ssid
307    if wifi_ssid2:
308      wifi_ssid_advertiser = wifi_ssid2
309    if wifi_ssid_advertiser:
310      self._active_nc_fail_reason = (
311          nc_constants.SingleTestFailureReason.TARGET_WIFI_CONNECTION
312      )
313      advertiser_wifi_sta_latency = (
314          setup_utils.connect_to_wifi_sta_till_success(
315              self.advertiser, wifi_ssid_advertiser, wifi_password
316          )
317      )
318      self.advertiser.log.info(
319          'connecting to wifi in '
320          f'{round(advertiser_wifi_sta_latency.total_seconds())} s'
321      )
322      self.advertiser.log.info(
323          self.advertiser.nearby.wifiGetConnectionInfo().get('mFrequency')
324      )
325      self._current_test_result.advertiser_wifi_expected = True
326      self._current_test_result.advertiser_sta_latency = (
327          advertiser_wifi_sta_latency
328      )
329      # Let scan, DHCP and internet validation complete before NC.
330      # This is important especially for the transfer speed test.
331      time.sleep(self.test_parameters.target_post_wifi_connection_idle_time_sec)
332
333    # 4. set up the D2D nearby connection
334    logging.info('set up a nearby connection for file transfer.')
335    active_snippet = nearby_connection_wrapper.NearbyConnectionWrapper(
336        self.advertiser,
337        self.discoverer,
338        self.advertiser.nearby,
339        self.discoverer.nearby,
340        advertising_discovery_medium=advertising_discovery_medium,
341        connection_medium=connection_medium,
342        upgrade_medium=upgrade_medium_under_test,
343    )
344    if prior_bt_snippet:
345      connection_setup_timeouts = nc_constants.ConnectionSetupTimeouts(
346          nc_constants.SECOND_DISCOVERY_TIMEOUT,
347          nc_constants.SECOND_CONNECTION_INIT_TIMEOUT,
348          nc_constants.SECOND_CONNECTION_RESULT_TIMEOUT,
349      )
350    try:
351      active_snippet.start_nearby_connection(
352          timeouts=connection_setup_timeouts,
353          medium_upgrade_type=nc_constants.MediumUpgradeType.DISRUPTIVE,
354          keep_alive_timeout_ms=self.test_parameters.keep_alive_timeout_ms,
355          keep_alive_interval_ms=self.test_parameters.keep_alive_interval_ms,
356      )
357    finally:
358      self._active_nc_fail_reason = active_snippet.test_failure_reason
359      self._current_test_result.quality_info = (
360          active_snippet.connection_quality_info
361      )
362
363    # 5. transfer file through the nearby connection and optionally run iperf
364    try:
365      self._current_test_result.file_transfer_throughput_kbps = (
366          active_snippet.transfer_file(
367              self._get_transfer_file_size(),
368              self._get_file_transfer_timeout(),
369              self.test_parameters.payload_type,
370          )
371      )
372      if (
373          self.test_parameters.run_iperf_test
374          and not self._is_mcc
375          and upgrade_medium_under_test
376          in [
377              nc_constants.NearbyMedium.UPGRADE_TO_WIFIDIRECT,
378              nc_constants.NearbyMedium.UPGRADE_TO_WIFIHOTSPOT,
379              nc_constants.NearbyMedium.WIFILAN_ONLY,
380              nc_constants.NearbyMedium.WIFIAWARE_ONLY,
381          ]
382      ):
383        # TODO: b/338094399 - update this part for the connection over WFD.
384        self._current_test_result.iperf_throughput_kbps = (
385            iperf_utils.run_iperf_test(
386                self.discoverer,
387                self.advertiser,
388                self._current_test_result.quality_info.upgrade_medium,
389            )
390        )
391
392    finally:
393      self._active_nc_fail_reason = active_snippet.test_failure_reason
394      self._check_ap_connection_and_speed(wifi_ssid_advertiser)
395
396    # 6. disconnect prior BT connection if required
397    if prior_bt_snippet:
398      prior_bt_snippet.disconnect_endpoint()
399    # 7. disconnect D2D active connection
400    active_snippet.disconnect_endpoint()
401
402  def _check_ap_connection_and_speed(self, wifi_ssid: str) -> None:
403    (sta_frequency, max_link_speed_mbps) = (
404        self._get_target_sta_frequency_and_max_link_speed()
405    )
406    self._current_test_result.sta_frequency = sta_frequency
407    self._current_test_result.max_sta_link_speed_mbps = max_link_speed_mbps
408    if wifi_ssid and(
409        sta_frequency == nc_constants.INVALID_INT
410        or max_link_speed_mbps == nc_constants.INVALID_INT
411    ):
412      self._active_nc_fail_reason = (
413          nc_constants.SingleTestFailureReason.DISCONNECTED_FROM_AP
414      )
415      asserts.fail(
416          'Target device is disconnected from AP. Check AP DHCP config.'
417      )
418
419    iperf_speed_min_mbps, nc_speed_min_mbps = self._get_throughput_benchmark(
420        sta_frequency, max_link_speed_mbps
421    )
422
423    if wifi_ssid and not self._is_valid_sta_frequency(wifi_ssid, sta_frequency):
424      self._active_nc_fail_reason = (
425          nc_constants.SingleTestFailureReason.WRONG_AP_FREQUENCY
426      )
427      asserts.fail(f'AP is set to a wrong frequency {sta_frequency}')
428
429    if (
430        self._current_test_result.quality_info.upgrade_medium
431        in [
432            nc_constants.NearbyConnectionMedium.WIFI_DIRECT,
433            nc_constants.NearbyConnectionMedium.WIFI_HOTSPOT,
434        ]
435    ):
436      p2p_frequency = setup_utils.get_wifi_p2p_frequency(self.advertiser)
437      self._current_test_result.quality_info.medium_frequency = p2p_frequency
438      if all([
439          p2p_frequency != nc_constants.INVALID_INT,
440          p2p_frequency != sta_frequency,
441          not self._is_mcc,
442          not self._is_dbs_mode,
443          nc_speed_min_mbps > 0,
444      ]):
445        self._active_nc_fail_reason = (
446            nc_constants.SingleTestFailureReason.WRONG_P2P_FREQUENCY
447        )
448        asserts.fail(
449            f'P2P frequeny ({p2p_frequency}) is different from STA frequency'
450            f' ({sta_frequency}) in SCC test case. Check the device capability'
451            ' configuration especially for DBS, DFS, indoor capabilities.'
452        )
453      if all([
454          p2p_frequency != nc_constants.INVALID_INT,
455          p2p_frequency == sta_frequency,
456          self._is_mcc,
457          nc_speed_min_mbps > 0,
458      ]):
459        self._active_nc_fail_reason = (
460            nc_constants.SingleTestFailureReason.WRONG_P2P_FREQUENCY
461        )
462        asserts.fail(
463            f'P2P frequeny ({p2p_frequency}) is same as STA frequency'
464            f' ({sta_frequency}) in MCC test case. Check the device capability'
465            ' configuration especially for DBS, DFS, indoor capabilities.'
466        )
467
468    if (
469        self._active_nc_fail_reason
470        is nc_constants.SingleTestFailureReason.SUCCESS
471    ):
472      iperf_speed_mbps = int(
473          self._current_test_result.iperf_throughput_kbps / 1024
474      )
475      nc_speed_mbps = round(
476          self._current_test_result.file_transfer_throughput_kbps / 1024, 2
477      )
478
479      if (nc_speed_mbps < nc_speed_min_mbps) or (
480          iperf_speed_mbps > 0 and iperf_speed_mbps < iperf_speed_min_mbps
481      ):
482        self._active_nc_fail_reason = (
483            nc_constants.SingleTestFailureReason.FILE_TRANSFER_THROUGHPUT_LOW
484        )
485        result_str = ''
486        if iperf_speed_mbps > 0 and iperf_speed_mbps < iperf_speed_min_mbps:
487          result_str = result_str + (
488              f'iperf speed {iperf_speed_mbps} < target {iperf_speed_min_mbps}'
489          )
490        if nc_speed_mbps < nc_speed_min_mbps:
491          result_str = result_str + (
492              f' file speed {nc_speed_mbps} < target {nc_speed_min_mbps}'
493          )
494        self._throughput_low_string = result_str + ' MB/s'
495        asserts.fail(self._throughput_low_string)
496
497  def _is_valid_sta_frequency(self, wifi_ssid: str, sta_frequency: int) -> bool:
498    if wifi_ssid == self.test_parameters.wifi_2g_ssid:
499      return sta_frequency <= _MAX_FREQ_2G_MHZ
500    elif wifi_ssid == self.test_parameters.wifi_5g_ssid:
501      return sta_frequency > _MAX_FREQ_2G_MHZ and (
502          sta_frequency < _MIN_FREQ_5G_DFS_MHZ
503          or sta_frequency > _MAX_FREQ_5G_DFS_MHZ
504      )
505    else:  # 5G DFS band
506      return (
507          sta_frequency >= _MIN_FREQ_5G_DFS_MHZ
508          and sta_frequency <= _MAX_FREQ_5G_DFS_MHZ
509      )
510
511  def _get_transfer_file_size(self) -> int:
512    return nc_constants.TRANSFER_FILE_SIZE_500MB
513
514  def _get_file_transfer_timeout(self) -> datetime.timedelta:
515    return nc_constants.WIFI_500M_PAYLOAD_TRANSFER_TIMEOUT
516
517  def _write_current_test_report(self) -> None:
518    """Writes test report for each iteration."""
519    self._current_test_result.test_iteration = self._finished_test_iteration
520    self._finished_test_iteration += 1
521    if (
522        self._use_prior_bt
523        and self._prior_bt_nc_fail_reason
524        is not nc_constants.SingleTestFailureReason.SUCCESS
525    ):
526      self._current_test_result.is_failed_with_prior_bt = True
527      self._current_test_result.failure_reason = self._prior_bt_nc_fail_reason
528    else:
529      self._current_test_result.failure_reason = self._active_nc_fail_reason
530    result_message = self._get_current_test_result_message()
531    self._current_test_result.result_message = result_message
532    self._test_results.append(self._current_test_result)
533
534    quality_info: list[Any] = []
535    if self._use_prior_bt:
536      quality_info.append(
537          'prior_bt:'
538          f'{self._current_test_result.prior_nc_quality_info.get_dict()}'
539      )
540    quality_info.append(
541        'file_transfer:'
542        f'{self._current_test_result.quality_info.get_dict()}'
543    )
544    quality_info.append(
545        'speed: '
546        f'{round(self._current_test_result.file_transfer_throughput_kbps/1024, 1)}'
547        'MBps'
548    )
549    if self._current_test_result.iperf_throughput_kbps > 0:
550      quality_info.append(
551          'iperf: '
552          f'{round(self._current_test_result.iperf_throughput_kbps/1024, 1)}'
553          'MBps'
554      )
555
556    if self._current_test_result.discoverer_sta_expected:
557      src_connection_latency = round(
558          self._current_test_result.discoverer_sta_latency.total_seconds()
559      )
560      quality_info.append(f'src_sta: {src_connection_latency}s')
561    if self._current_test_result.advertiser_wifi_expected:
562      tgt_connection_latency = round(
563          self._current_test_result.advertiser_sta_latency.total_seconds()
564      )
565      quality_info.append(f'tgt_sta: {tgt_connection_latency}s')
566
567    test_report = {
568        'result': result_message,
569        'quality_info': quality_info,
570    }
571
572    self.discoverer.log.info(test_report)
573    self.record_data({
574        'Test Class': self.TAG,
575        'Test Name': self.current_test_info.name,
576        'properties': test_report,
577    })
578
579  def _get_current_test_result_message(self) -> str:
580    if (
581        self._use_prior_bt
582        and self._prior_bt_nc_fail_reason
583        is not nc_constants.SingleTestFailureReason.SUCCESS
584    ):
585      return ''.join([
586          'FAIL (The prior BT connection): ',
587          f'{self._prior_bt_nc_fail_reason.name} - ',
588          nc_constants.COMMON_TRIAGE_TIP.get(self._prior_bt_nc_fail_reason),
589      ])
590
591    if (
592        self._active_nc_fail_reason
593        == nc_constants.SingleTestFailureReason.SUCCESS
594    ):
595      return 'PASS'
596    if (
597        self._active_nc_fail_reason
598        == nc_constants.SingleTestFailureReason.SOURCE_WIFI_CONNECTION
599    ):
600      return ''.join([
601          f'FAIL: {self._active_nc_fail_reason.name} - ',
602          nc_constants.COMMON_TRIAGE_TIP.get(self._active_nc_fail_reason),
603      ])
604
605    if (
606        self._active_nc_fail_reason
607        is nc_constants.SingleTestFailureReason.WIFI_MEDIUM_UPGRADE
608    ):
609      return ''.join([
610          f'FAIL: {self._active_nc_fail_reason.name} - ',
611          self._get_medium_upgrade_failure_tip(),
612      ])
613    if (
614        self._active_nc_fail_reason
615        is nc_constants.SingleTestFailureReason.FILE_TRANSFER_FAIL
616    ):
617      return ''.join([
618          f'{self._active_nc_fail_reason.name} - ',
619          self._get_file_transfer_failure_tip(),
620      ])
621    if (
622        self._active_nc_fail_reason
623        is nc_constants.SingleTestFailureReason.FILE_TRANSFER_THROUGHPUT_LOW
624    ):
625      return ''.join([
626          f'{self._active_nc_fail_reason.name} - ',
627          self._get_throughput_low_tip(),
628      ])
629
630    return ''.join([
631        f'{self._active_nc_fail_reason.name} - ',
632        nc_constants.COMMON_TRIAGE_TIP.get(
633            self._active_nc_fail_reason, 'UNKNOWN'
634        ),
635    ])
636
637  def _get_medium_upgrade_failure_tip(self) -> str:
638    return nc_constants.MEDIUM_UPGRADE_FAIL_TRIAGE_TIPS.get(
639        self._upgrade_medium_under_test,
640        f'unexpected upgrade medium - {self._upgrade_medium_under_test}',
641    )
642
643  @abc.abstractmethod
644  def _get_file_transfer_failure_tip(self) -> str:
645    pass
646
647  @abc.abstractmethod
648  def _get_throughput_low_tip(self) -> str:
649    pass
650
651  def _collect_current_test_metrics(self) -> None:
652    """Collects test result metrics for each iteration."""
653    if self._use_prior_bt:
654      self._performance_test_metrics.prior_bt_discovery_latencies.append(
655          self._current_test_result.prior_nc_quality_info.discovery_latency
656      )
657      self._performance_test_metrics.prior_bt_connection_latencies.append(
658          self._current_test_result.prior_nc_quality_info.connection_latency
659      )
660
661    self._performance_test_metrics.file_transfer_discovery_latencies.append(
662        self._current_test_result.quality_info.discovery_latency
663    )
664    self._performance_test_metrics.file_transfer_connection_latencies.append(
665        self._current_test_result.quality_info.connection_latency
666    )
667    self._performance_test_metrics.upgraded_wifi_transfer_mediums.append(
668        self._current_test_result.quality_info.upgrade_medium
669    )
670    self._performance_test_metrics.file_transfer_throughputs_kbps.append(
671        self._current_test_result.file_transfer_throughput_kbps
672    )
673    self._performance_test_metrics.iperf_throughputs_kbps.append(
674        self._current_test_result.iperf_throughput_kbps
675    )
676    self._performance_test_metrics.discoverer_wifi_sta_latencies.append(
677        self._current_test_result.discoverer_sta_latency
678    )
679    self._performance_test_metrics.advertiser_wifi_sta_latencies.append(
680        self._current_test_result.advertiser_sta_latency
681    )
682    if (
683        self._current_test_result.quality_info.medium_upgrade_expected
684    ):
685      self._performance_test_metrics.medium_upgrade_latencies.append(
686          self._current_test_result.quality_info.medium_upgrade_latency
687      )
688
689  def __convert_kbps_to_mbps(self, throughput_kbps: float) -> float:
690    """Convert throughput from kbyte/s to mbyte/s."""
691    return round(throughput_kbps / 1024, 1)
692
693  def __get_transfer_stats(
694      self,
695      throughput_indicators: list[float],
696  ) -> nc_constants.TestResultStats:
697    """get the min, median and max throughput from iterations which finished file transfer."""
698    filtered = [
699        x
700        for x in throughput_indicators
701        if x != nc_constants.UNSET_THROUGHPUT_KBPS
702    ]
703    if not filtered:
704      # all test cases are failed
705      return nc_constants.TestResultStats(0, 0, 0, 0)
706    # use the descenting order of the throughput
707    filtered.sort(reverse=True)
708    return nc_constants.TestResultStats(
709        len(filtered),
710        self.__convert_kbps_to_mbps(filtered[len(filtered) - 1]),
711        self.__convert_kbps_to_mbps(
712            filtered[int(len(filtered) * nc_constants.PERCENTILE_50_FACTOR)]
713        ),
714        self.__convert_kbps_to_mbps(filtered[0]),
715    )
716
717  def __get_latency_stats(
718      self, latency_indicators: list[datetime.timedelta]
719  ) -> nc_constants.TestResultStats:
720    filtered = [
721        latency.total_seconds()
722        for latency in latency_indicators
723        if latency != nc_constants.UNSET_LATENCY
724    ]
725    if not filtered:
726      # All test cases are failed.
727      return nc_constants.TestResultStats(0, 0, 0, 0)
728
729    filtered.sort()
730
731    percentile_50 = round(
732        filtered[int(len(filtered) * nc_constants.PERCENTILE_50_FACTOR)],
733        nc_constants.LATENCY_PRECISION_DIGITS,
734    )
735    return nc_constants.TestResultStats(
736        len(filtered),
737        round(filtered[0], nc_constants.LATENCY_PRECISION_DIGITS),
738        percentile_50,
739        round(
740            filtered[len(filtered) - 1], nc_constants.LATENCY_PRECISION_DIGITS
741        ),
742    )
743
744  # @typing.override
745  def _summary_test_results(self) -> None:
746    """Summarizes test results of all iterations."""
747    success_count = sum(
748        test_result.failure_reason
749        == nc_constants.SingleTestFailureReason.SUCCESS
750        for test_result in self._test_results
751    )
752    passed = success_count >= round(
753        self.performance_test_iterations * nc_constants.SUCCESS_RATE_TARGET
754    )
755    final_result_message = (
756        'PASS'
757        if passed
758        else (
759            'FAIL: low successe rate: '
760            f' {success_count / self.performance_test_iterations:.2%} is lower'
761            f' than the target {nc_constants.SUCCESS_RATE_TARGET:.2%}'
762        )
763    )
764    detailed_stats = [
765        f'Required Iterations: {self.performance_test_iterations}',
766        f'Finished Iterations: {len(self._test_results)}',
767    ]
768    detailed_stats.append('Failed Iterations:')
769    detailed_stats.extend(self.__get_failed_iteration_messages())
770    detailed_stats.append('File Transfer Connection Stats:')
771    detailed_stats.extend(self.__get_file_transfer_connection_stats())
772
773    if self._use_prior_bt:
774      detailed_stats.append('Prior BT Connection Stats:')
775      detailed_stats.extend(self.__get_prior_bt_connection_stats())
776
777    self.record_data({
778        'Test Class': self.TAG,
779        'properties': {
780            '01_test_result': final_result_message,
781            '02_source_device': '\n'.join(
782                self.__get_device_attributes(self.discoverer)
783            ),
784            '03_target_device': '\n'.join(
785                self.__get_device_attributes(self.advertiser)
786            ),
787            '04_test_config': '\n'.join([
788                f'Country Code: {self._get_country_code()}',
789                f'MCC mode: {self._is_mcc}',
790                f'2G medium {self._is_2g_d2d_wifi_medium}',
791                f'DBS mode: {self._is_dbs_mode}',
792                (
793                    'advertising_discovery_medium:'
794                    f' {self._advertising_discovery_medium.name}'
795                ),
796                f'connection_medium: {self._connection_medium.name}',
797                f'upgrade_medium: {self._upgrade_medium_under_test.name}',
798                f'wifi_ssid: {self._wifi_ssid}',
799                f'Start time: {self._start_time}',
800                f'End time: {datetime.datetime.now()}',
801            ]),
802            '05_detailed_stats': '\n'.join(detailed_stats),
803        },
804    })
805
806    asserts.assert_true(passed, final_result_message)
807
808  def __get_failed_iteration_messages(self) -> list[str]:
809    stats = []
810    for test_result in self._test_results:
811      if (
812          test_result.failure_reason
813          is not nc_constants.SingleTestFailureReason.SUCCESS
814      ):
815        stats.append(
816            f'- Iter: {test_result.test_iteration}: {test_result.start_time}'
817            f' {test_result.result_message}\n'
818            f' sta freq: {test_result.sta_frequency},'
819            f' sta max link speed: {test_result.max_sta_link_speed_mbps},'
820            f' used medium: {test_result.quality_info.get_medium_name()},'
821            f' medium freq: {test_result.quality_info.medium_frequency}.'
822        )
823
824    if stats:
825      return stats
826    else:
827      return ['  - NA']
828
829  def __get_prior_bt_connection_stats(self) -> list[str]:
830    if not self._use_prior_bt:
831      return []
832    discovery_latency_stats = self.__get_latency_stats(
833        self._performance_test_metrics.prior_bt_discovery_latencies
834    )
835    connection_latency_stats = self.__get_latency_stats(
836        self._performance_test_metrics.prior_bt_connection_latencies
837    )
838    return [
839        (
840            '  - Min / Median / Max Discovery Latency'
841            f' ({discovery_latency_stats.success_count} discovery):'
842            f' {discovery_latency_stats.min_val} /'
843            f' {discovery_latency_stats.median_val} /'
844            f' {discovery_latency_stats.max_val}s '
845        ),
846        (
847            '  - Min / Median / Max Connection Latency'
848            f' ({connection_latency_stats.success_count} connections):'
849            f' {connection_latency_stats.min_val} /'
850            f' {connection_latency_stats.median_val} /'
851            f' {connection_latency_stats.max_val}s '
852        ),
853    ]
854
855  def __get_file_transfer_connection_stats(self) -> list[str]:
856    discovery_latency_stats = self.__get_latency_stats(
857        self._performance_test_metrics.file_transfer_discovery_latencies
858    )
859    connection_latency_stats = self.__get_latency_stats(
860        self._performance_test_metrics.file_transfer_connection_latencies
861    )
862    transfer_stats = self.__get_transfer_stats(
863        self._performance_test_metrics.file_transfer_throughputs_kbps
864    )
865    iperf_stats = self.__get_transfer_stats(
866        self._performance_test_metrics.iperf_throughputs_kbps
867    )
868    stats = [
869        (
870            '  - Min / Median / Max Discovery Latency'
871            f' ({discovery_latency_stats.success_count} discovery):'
872            f' {discovery_latency_stats.min_val} /'
873            f' {discovery_latency_stats.median_val} /'
874            f' {discovery_latency_stats.max_val}s '
875        ),
876        (
877            '  - Min / Median / Max Connection Latency'
878            f' ({connection_latency_stats.success_count} connections):'
879            f' {connection_latency_stats.min_val} /'
880            f' {connection_latency_stats.median_val} /'
881            f' {connection_latency_stats.max_val}s '
882        ),
883        (
884            '  - Min / Median / Max Speed'
885            f' ({transfer_stats.success_count} transfer):'
886            f' {transfer_stats.min_val} / {transfer_stats.median_val} /'
887            f' {transfer_stats.max_val} MBps'
888        ),
889        (
890            '  - Min / Median / Max iperf Speed'
891            f' ({iperf_stats.success_count} transfer):'
892            f' {iperf_stats.min_val} / {iperf_stats.median_val} /'
893            f' {iperf_stats.max_val} MBps'
894        ),
895    ]
896    if nc_constants.is_high_quality_medium(self._upgrade_medium_under_test):
897      medium_upgrade_latency_stats = self.__get_latency_stats(
898          self._performance_test_metrics.medium_upgrade_latencies
899      )
900      stats.extend([
901          (
902              '  - Min / Median / Max Upgrade Latency '
903              f' ({medium_upgrade_latency_stats. success_count} upgrade):'
904              f' {medium_upgrade_latency_stats.min_val} /'
905              f' {medium_upgrade_latency_stats.median_val} /'
906              f' {medium_upgrade_latency_stats.max_val}s '
907          ),
908          '  - Upgrade Medium Stats:',
909      ])
910      stats.extend(self._summary_upgraded_wifi_transfer_mediums())
911
912    return stats
913
914  def _summary_upgraded_wifi_transfer_mediums(self) -> list[str]:
915    medium_counts = {}
916    for (
917        upgraded_medium
918    ) in self._performance_test_metrics.upgraded_wifi_transfer_mediums:
919      if upgraded_medium:
920        medium_counts[upgraded_medium.name] = (
921            medium_counts.get(upgraded_medium.name, 0) + 1
922        )
923    return [f'    - {name}: {count}' for name, count in medium_counts.items()]
924
925  def __get_device_attributes(
926      self, ad: android_device.AndroidDevice
927  ) -> list[str]:
928    return [
929        f'Device Serial: {ad.serial}',
930        f'Device Model: {ad.model}',
931        f'Build: {ad.build_info}',
932        f'Wifi chipset: {ad.wifi_chipset}',
933        f'Wifi FW: {ad.adb.getprop("vendor.wlan.firmware.version")}',
934        f'Supports 5G Wifi: {ad.supports_5g}',
935        f'Supports DBS: {ad.supports_dbs_sta_wfd}',
936        (
937            'Enable STA DFS channel for peer network:'
938            f' {ad.enable_sta_dfs_channel_for_peer_network}'
939        ),
940        (
941            'Enable STA Indoor channel for peer network:'
942            f' {ad.enable_sta_indoor_channel_for_peer_network}'
943        ),
944        f'Max num of streams: {ad.max_num_streams}',
945        f'Max num of streams (DBS): {ad.max_num_streams_dbs}',
946        f'Android Version: {ad.android_version}',
947        f'GMS_version: {setup_utils.dump_gms_version(ad)}',
948    ]
949