IperfUtility.py 20 KB

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