IperfUtility.py 20 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467
  1. # SPDX-FileCopyrightText: 2022-2023 Espressif Systems (Shanghai) CO LTD
  2. # SPDX-License-Identifier: Apache-2.0
  3. import logging
  4. import os
  5. import re
  6. import subprocess
  7. import time
  8. import pexpect
  9. from idf_iperf_test_util import LineChart
  10. from pytest_embedded import Dut
  11. try:
  12. from typing import Any, Tuple
  13. except ImportError:
  14. # Only used for type annotations
  15. pass
  16. # configurations
  17. TEST_TIME = TEST_TIMEOUT = 66
  18. WAIT_AP_POWER_ON_TIMEOUT = 90
  19. SCAN_TIMEOUT = 3
  20. SCAN_RETRY_COUNT = 3
  21. # constants
  22. FAILED_TO_SCAN_RSSI = -97
  23. INVALID_HEAP_SIZE = 0xFFFFFFFF
  24. PC_IPERF_TEMP_LOG_FILE = '.tmp_iperf.log'
  25. class TestResult(object):
  26. """ record, analysis test result and convert data to output format """
  27. PC_BANDWIDTH_LOG_PATTERN = re.compile(r'(\d+\.\d+)\s*-\s*(\d+.\d+)\s+sec\s+[\d.]+\s+MBytes\s+([\d.]+)\s+Mbits\/sec')
  28. DUT_BANDWIDTH_LOG_PATTERN = re.compile(r'(\d+)-\s+(\d+)\s+sec\s+([\d.]+)\s+Mbits/sec')
  29. ZERO_POINT_THRESHOLD = -88 # RSSI, dbm
  30. ZERO_THROUGHPUT_THRESHOLD = -92 # RSSI, dbm
  31. BAD_POINT_RSSI_THRESHOLD = -75 # RSSI, dbm
  32. BAD_POINT_MIN_THRESHOLD = 10 # Mbps
  33. BAD_POINT_PERCENTAGE_THRESHOLD = 0.3
  34. # we need at least 1/2 valid points to qualify the test result
  35. THROUGHPUT_QUALIFY_COUNT = TEST_TIME // 2
  36. RSSI_RANGE = [-x for x in range(10, 100)]
  37. ATT_RANGE = [x for x in range(0, 64)]
  38. def __init__(self, proto:str, direction:str, config_name:str) -> None:
  39. self.proto = proto
  40. self.direction = direction
  41. self.config_name = config_name
  42. self.throughput_by_rssi = dict() # type: dict
  43. self.throughput_by_att = dict() # type: dict
  44. self.att_rssi_map = dict() # type: dict
  45. self.heap_size = INVALID_HEAP_SIZE
  46. self.error_list = [] # type: list[str]
  47. def _save_result(self, throughput:float, ap_ssid:str, att:int, rssi:int, heap_size:str) -> None:
  48. """
  49. save the test results:
  50. * record the better throughput if att/rssi is the same.
  51. * record the min heap size.
  52. """
  53. if ap_ssid not in self.att_rssi_map:
  54. # for new ap, create empty dict()
  55. self.throughput_by_att[ap_ssid] = dict()
  56. self.throughput_by_rssi[ap_ssid] = dict()
  57. self.att_rssi_map[ap_ssid] = dict()
  58. self.att_rssi_map[ap_ssid][att] = rssi
  59. def record_throughput(database:dict, key_value:int) -> None:
  60. try:
  61. # we save the larger value for same att
  62. if throughput > database[ap_ssid][key_value]:
  63. database[ap_ssid][key_value] = throughput
  64. except KeyError:
  65. database[ap_ssid][key_value] = throughput
  66. record_throughput(self.throughput_by_att, att)
  67. record_throughput(self.throughput_by_rssi, rssi)
  68. if int(heap_size) < self.heap_size:
  69. self.heap_size = int(heap_size)
  70. def add_result(self, raw_data:str, ap_ssid:str, att:int, rssi:int, heap_size:str) -> float:
  71. """
  72. add result for one test
  73. :param raw_data: iperf raw data
  74. :param ap_ssid: ap ssid that tested
  75. :param att: attenuate value
  76. :param rssi: AP RSSI
  77. :param heap_size: min heap size during test
  78. :return: throughput
  79. """
  80. fall_to_0_recorded = 0
  81. throughput_list = []
  82. max_throughput = 0.0
  83. result_list = self.PC_BANDWIDTH_LOG_PATTERN.findall(raw_data)
  84. if not result_list:
  85. # failed to find raw data by PC pattern, it might be DUT pattern
  86. result_list = self.DUT_BANDWIDTH_LOG_PATTERN.findall(raw_data)
  87. for result in result_list:
  88. t_start = float(result[0])
  89. t_end = float(result[1])
  90. throughput = float(result[2])
  91. if int(t_end - t_start) != 1:
  92. # this could be summary, ignore this
  93. continue
  94. throughput_list.append(throughput)
  95. max_throughput = max(max_throughput, throughput)
  96. if throughput == 0 and rssi > self.ZERO_POINT_THRESHOLD \
  97. and fall_to_0_recorded < 1:
  98. # throughput fall to 0 error. we only record 1 records for one test
  99. self.error_list.append('[Error][fall to 0][{}][att: {}][rssi: {}]: 0 throughput interval: {}-{}'
  100. .format(ap_ssid, att, rssi, result[0], result[1]))
  101. fall_to_0_recorded += 1
  102. if len(throughput_list) < self.THROUGHPUT_QUALIFY_COUNT:
  103. self.error_list.append('[Error][Fatal][{}][att: {}][rssi: {}]: Only {} throughput values found, expected at least {}'
  104. .format(ap_ssid, att, rssi, len(throughput_list), self.THROUGHPUT_QUALIFY_COUNT))
  105. max_throughput = 0.0
  106. if max_throughput == 0 and rssi > self.ZERO_THROUGHPUT_THRESHOLD:
  107. self.error_list.append('[Error][Fatal][{}][att: {}][rssi: {}]: No throughput data found'
  108. .format(ap_ssid, att, rssi))
  109. self._save_result(max_throughput, ap_ssid, att, rssi, heap_size)
  110. return max_throughput
  111. def post_analysis(self) -> None:
  112. """
  113. some rules need to be checked after we collected all test raw data:
  114. 1. throughput value 30% worse than the next point with lower RSSI
  115. 2. throughput value 30% worse than the next point with larger attenuate
  116. """
  117. def analysis_bad_point(data:dict, index_type:str) -> None:
  118. for ap_ssid in data:
  119. result_dict = data[ap_ssid]
  120. index_list = list(result_dict.keys())
  121. index_list.sort()
  122. if index_type == 'att':
  123. index_list.reverse()
  124. for i, index_value in enumerate(index_list[1:]):
  125. if index_value < self.BAD_POINT_RSSI_THRESHOLD or \
  126. result_dict[index_list[i]] < self.BAD_POINT_MIN_THRESHOLD:
  127. continue
  128. _percentage = result_dict[index_value] / result_dict[index_list[i]]
  129. if _percentage < 1 - self.BAD_POINT_PERCENTAGE_THRESHOLD:
  130. self.error_list.append('[Error][Bad point][{}][{}: {}]: drop {:.02f}%'
  131. .format(ap_ssid, index_type, index_value,
  132. (1 - _percentage) * 100))
  133. analysis_bad_point(self.throughput_by_rssi, 'rssi')
  134. analysis_bad_point(self.throughput_by_att, 'att')
  135. def draw_throughput_figure(self, path:str, ap_ssid:str, draw_type:str) -> str:
  136. """
  137. :param path: folder to save figure. make sure the folder is already created.
  138. :param ap_ssid: ap ssid string or a list of ap ssid string
  139. :param draw_type: "att" or "rssi"
  140. :return: file_name
  141. """
  142. if draw_type == 'rssi':
  143. type_name = 'RSSI'
  144. data = self.throughput_by_rssi
  145. range_list = self.RSSI_RANGE
  146. elif draw_type == 'att':
  147. type_name = 'Att'
  148. data = self.throughput_by_att
  149. range_list = self.ATT_RANGE
  150. else:
  151. raise AssertionError('draw type not supported')
  152. if isinstance(ap_ssid, list):
  153. file_name = 'ThroughputVs{}_{}_{}.html'.format(type_name, self.proto, self.direction)
  154. else:
  155. file_name = 'ThroughputVs{}_{}_{}.html'.format(type_name, self.proto, self.direction)
  156. LineChart.draw_line_chart(os.path.join(path, file_name),
  157. 'Throughput Vs {} ({} {})'.format(type_name, self.proto, self.direction),
  158. '{} (dbm)'.format(type_name),
  159. 'Throughput (Mbps)',
  160. data, range_list)
  161. return file_name
  162. def draw_rssi_vs_att_figure(self, path:str, ap_ssid:str) -> str:
  163. """
  164. :param path: folder to save figure. make sure the folder is already created.
  165. :param ap_ssid: ap to use
  166. :return: file_name
  167. """
  168. if isinstance(ap_ssid, list):
  169. file_name = 'AttVsRSSI.html'
  170. else:
  171. file_name = 'AttVsRSSI.html'
  172. LineChart.draw_line_chart(os.path.join(path, file_name),
  173. 'Att Vs RSSI',
  174. 'Att (dbm)',
  175. 'RSSI (dbm)',
  176. self.att_rssi_map,
  177. self.ATT_RANGE)
  178. return file_name
  179. def get_best_throughput(self) -> Any:
  180. """ get the best throughput during test """
  181. best_for_aps = [max(self.throughput_by_att[ap_ssid].values())
  182. for ap_ssid in self.throughput_by_att]
  183. return max(best_for_aps)
  184. def __str__(self) -> str:
  185. """
  186. returns summary for this test:
  187. 1. test result (success or fail)
  188. 2. best performance for each AP
  189. 3. min free heap size during test
  190. """
  191. if self.throughput_by_att:
  192. ret = '[{}_{}][{}]: {}\r\n\r\n'.format(self.proto, self.direction, self.config_name,
  193. 'Fail' if self.error_list else 'Success')
  194. ret += 'Performance for each AP:\r\n'
  195. for ap_ssid in self.throughput_by_att:
  196. ret += '[{}]: {:.02f} Mbps\r\n'.format(ap_ssid, max(self.throughput_by_att[ap_ssid].values()))
  197. if self.heap_size != INVALID_HEAP_SIZE:
  198. ret += 'Minimum heap size: {}'.format(self.heap_size)
  199. else:
  200. ret = ''
  201. return ret
  202. class IperfTestUtility(object):
  203. """ iperf test implementation """
  204. def __init__(self, dut:Dut, config_name:str, ap_ssid:str, ap_password:str,
  205. pc_nic_ip:str, pc_iperf_log_file:str, test_result:Any=None) -> None:
  206. self.config_name = config_name
  207. self.dut = dut
  208. self.pc_iperf_log_file = pc_iperf_log_file
  209. self.ap_ssid = ap_ssid
  210. self.ap_password = ap_password
  211. self.pc_nic_ip = pc_nic_ip
  212. self.fail_to_scan = 0
  213. self.lowest_rssi_scanned = 0
  214. if test_result:
  215. self.test_result = test_result
  216. else:
  217. self.test_result = {
  218. 'tcp_tx': TestResult('tcp', 'tx', config_name),
  219. 'tcp_rx': TestResult('tcp', 'rx', config_name),
  220. 'udp_tx': TestResult('udp', 'tx', config_name),
  221. 'udp_rx': TestResult('udp', 'rx', config_name),
  222. }
  223. def setup(self) -> Tuple[str,int]:
  224. """
  225. setup iperf test:
  226. 1. kill current iperf process
  227. 2. reboot DUT (currently iperf is not very robust, need to reboot DUT)
  228. 3. scan to get AP RSSI
  229. 4. connect to AP
  230. """
  231. try:
  232. subprocess.check_output('sudo killall iperf 2>&1 > /dev/null', shell=True)
  233. except subprocess.CalledProcessError:
  234. pass
  235. time.sleep(5)
  236. self.dut.write('restart')
  237. self.dut.expect_exact("Type 'help' to get the list of commands.")
  238. self.dut.expect('iperf>')
  239. self.dut.write('scan {}'.format(self.ap_ssid))
  240. for _ in range(SCAN_RETRY_COUNT):
  241. try:
  242. rssi = int(self.dut.expect(r'\[{}]\[rssi=(-\d+)]'.format(self.ap_ssid),
  243. timeout=SCAN_TIMEOUT).group(1))
  244. break
  245. except pexpect.TIMEOUT:
  246. continue
  247. else:
  248. raise AssertionError('Failed to scan AP')
  249. self.dut.write('sta {} {}'.format(self.ap_ssid, self.ap_password))
  250. dut_ip = self.dut.expect(r'sta ip: ([\d.]+), mask: ([\d.]+), gw: ([\d.]+)').group(1)
  251. return dut_ip, rssi
  252. def _save_test_result(self, test_case:str, raw_data:str, att:int, rssi:int, heap_size:int) -> Any:
  253. return self.test_result[test_case].add_result(raw_data, self.ap_ssid, att, rssi, heap_size)
  254. def _test_once(self, proto:str, direction:str, bw_limit:int) -> Tuple[str, int, int]:
  255. """ do measure once for one type """
  256. # connect and scan to get RSSI
  257. dut_ip, rssi = self.setup()
  258. assert direction in ['rx', 'tx']
  259. assert proto in ['tcp', 'udp']
  260. # run iperf test
  261. if direction == 'tx':
  262. with open(PC_IPERF_TEMP_LOG_FILE, 'w') as f:
  263. if proto == 'tcp':
  264. process = subprocess.Popen(['iperf', '-s', '-B', self.pc_nic_ip,
  265. '-t', str(TEST_TIME), '-i', '1', '-f', 'm'],
  266. stdout=f, stderr=f)
  267. if bw_limit > 0:
  268. self.dut.write('iperf -c {} -i 1 -t {} -b {}'.format(self.pc_nic_ip, TEST_TIME, bw_limit))
  269. else:
  270. self.dut.write('iperf -c {} -i 1 -t {}'.format(self.pc_nic_ip, TEST_TIME))
  271. else:
  272. process = subprocess.Popen(['iperf', '-s', '-u', '-B', self.pc_nic_ip,
  273. '-t', str(TEST_TIME), '-i', '1', '-f', 'm'],
  274. stdout=f, stderr=f)
  275. if bw_limit > 0:
  276. self.dut.write('iperf -c {} -u -i 1 -t {} -b {}'.format(self.pc_nic_ip, TEST_TIME, bw_limit))
  277. else:
  278. self.dut.write('iperf -c {} -u -i 1 -t {}'.format(self.pc_nic_ip, TEST_TIME))
  279. for _ in range(TEST_TIMEOUT):
  280. if process.poll() is not None:
  281. break
  282. time.sleep(1)
  283. else:
  284. process.terminate()
  285. with open(PC_IPERF_TEMP_LOG_FILE, 'r') as f:
  286. pc_raw_data = server_raw_data = f.read()
  287. else:
  288. with open(PC_IPERF_TEMP_LOG_FILE, 'w') as f:
  289. if proto == 'tcp':
  290. self.dut.write('iperf -s -i 1 -t {}'.format(TEST_TIME))
  291. # wait until DUT TCP server created
  292. try:
  293. self.dut.expect('iperf: Socket created', timeout=5)
  294. except pexpect.TIMEOUT:
  295. # compatible with old iperf example binary
  296. logging.info('create iperf tcp server fail')
  297. if bw_limit > 0:
  298. process = subprocess.Popen(['iperf', '-c', dut_ip, '-b', str(bw_limit) + 'm',
  299. '-t', str(TEST_TIME), '-f', 'm'], stdout=f, stderr=f)
  300. else:
  301. process = subprocess.Popen(['iperf', '-c', dut_ip,
  302. '-t', str(TEST_TIME), '-f', 'm'], stdout=f, stderr=f)
  303. for _ in range(TEST_TIMEOUT):
  304. if process.poll() is not None:
  305. break
  306. time.sleep(1)
  307. else:
  308. process.terminate()
  309. else:
  310. self.dut.write('iperf -s -u -i 1 -t {}'.format(TEST_TIME))
  311. # wait until DUT TCP server created
  312. try:
  313. self.dut.expect('iperf: Socket bound', timeout=5)
  314. except pexpect.TIMEOUT:
  315. # compatible with old iperf example binary
  316. logging.info('create iperf udp server fail')
  317. if bw_limit > 0:
  318. process = subprocess.Popen(['iperf', '-c', dut_ip, '-u', '-b', str(bw_limit) + 'm',
  319. '-t', str(TEST_TIME), '-f', 'm'], stdout=f, stderr=f)
  320. for _ in range(TEST_TIMEOUT):
  321. if process.poll() is not None:
  322. break
  323. time.sleep(1)
  324. else:
  325. process.terminate()
  326. else:
  327. for bandwidth in range(50, 101, 5):
  328. process = subprocess.Popen(['iperf', '-c', dut_ip, '-u', '-b', str(bandwidth) + 'm',
  329. '-t', str(TEST_TIME / 11), '-f', 'm'], stdout=f, stderr=f)
  330. for _ in range(TEST_TIMEOUT):
  331. if process.poll() is not None:
  332. break
  333. time.sleep(1)
  334. else:
  335. process.terminate()
  336. server_raw_data = self.dut.expect(pexpect.TIMEOUT, timeout=0).decode('utf-8')
  337. with open(PC_IPERF_TEMP_LOG_FILE, 'r') as f:
  338. pc_raw_data = f.read()
  339. if os.path.exists(PC_IPERF_TEMP_LOG_FILE):
  340. os.remove(PC_IPERF_TEMP_LOG_FILE)
  341. # save PC iperf logs to console
  342. with open(self.pc_iperf_log_file, 'a+') as f:
  343. f.write('## [{}] `{}`\r\n##### {}'
  344. .format(self.config_name,
  345. '{}_{}'.format(proto, direction),
  346. time.strftime('%m-%d %H:%M:%S', time.localtime(time.time()))))
  347. f.write('\r\n```\r\n\r\n' + pc_raw_data + '\r\n```\r\n')
  348. self.dut.write('heap')
  349. heap_size = self.dut.expect(r'min heap size: (\d+)\D').group(1)
  350. # return server raw data (for parsing test results) and RSSI
  351. return server_raw_data, rssi, heap_size
  352. def run_test(self, proto:str, direction:str, atten_val:int, bw_limit:int) -> None:
  353. """
  354. run test for one type, with specified atten_value and save the test result
  355. :param proto: tcp or udp
  356. :param direction: tx or rx
  357. :param atten_val: attenuate value
  358. :param bw_limit: bandwidth limit
  359. """
  360. rssi = FAILED_TO_SCAN_RSSI
  361. heap_size = INVALID_HEAP_SIZE
  362. try:
  363. server_raw_data, rssi, heap_size = self._test_once(proto, direction, bw_limit)
  364. throughput = self._save_test_result('{}_{}'.format(proto, direction),
  365. server_raw_data, atten_val,
  366. rssi, heap_size)
  367. logging.info('[{}][{}_{}][{}][{}]: {:.02f}'
  368. .format(self.config_name, proto, direction, rssi, self.ap_ssid, throughput))
  369. self.lowest_rssi_scanned = min(self.lowest_rssi_scanned, rssi)
  370. except (ValueError, IndexError):
  371. self._save_test_result('{}_{}'.format(proto, direction), '', atten_val, rssi, heap_size)
  372. logging.info('Fail to get throughput results.')
  373. except AssertionError:
  374. self.fail_to_scan += 1
  375. logging.info('Fail to scan AP.')
  376. def run_all_cases(self, atten_val:int, bw_limit:int) -> None:
  377. """
  378. run test for all types (udp_tx, udp_rx, tcp_tx, tcp_rx).
  379. :param atten_val: attenuate value
  380. :param bw_limit: bandwidth limit
  381. """
  382. self.run_test('tcp', 'tx', atten_val, bw_limit)
  383. self.run_test('tcp', 'rx', atten_val, bw_limit)
  384. self.run_test('udp', 'tx', atten_val, bw_limit)
  385. self.run_test('udp', 'rx', atten_val, bw_limit)
  386. if self.fail_to_scan > 10:
  387. logging.info(
  388. 'Fail to scan AP for more than 10 times. Lowest RSSI scanned is {}'.format(self.lowest_rssi_scanned))
  389. raise AssertionError
  390. def wait_ap_power_on(self) -> bool:
  391. """
  392. AP need to take sometime to power on. It changes for different APs.
  393. This method will scan to check if the AP powers on.
  394. :return: True or False
  395. """
  396. self.dut.write('restart')
  397. self.dut.expect('iperf>')
  398. for _ in range(WAIT_AP_POWER_ON_TIMEOUT // SCAN_TIMEOUT):
  399. try:
  400. self.dut.write('scan {}'.format(self.ap_ssid))
  401. self.dut.expect(r'\[{}]\[rssi=(-\d+)]'.format(self.ap_ssid),
  402. timeout=SCAN_TIMEOUT)
  403. ret = True
  404. break
  405. except pexpect.TIMEOUT:
  406. pass
  407. else:
  408. ret = False
  409. return ret