IperfUtility.py 20 KB

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